├── public ├── robots.txt ├── favicon.ico ├── img │ └── user.png ├── apple-touch-icon.png ├── favicons │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ └── android-chrome-512x512.png ├── apple-touch-icon-120x120.png ├── apple-touch-icon-precomposed.png ├── apple-touch-icon-152x152-precomposed.png ├── fonts │ └── nunito-sans │ │ ├── nunito-sans-v12-latin_latin-ext-200.woff │ │ ├── nunito-sans-v12-latin_latin-ext-200.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-300.woff │ │ ├── nunito-sans-v12-latin_latin-ext-300.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-600.woff │ │ ├── nunito-sans-v12-latin_latin-ext-600.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-700.woff │ │ ├── nunito-sans-v12-latin_latin-ext-700.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-800.woff │ │ ├── nunito-sans-v12-latin_latin-ext-800.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-900.woff │ │ ├── nunito-sans-v12-latin_latin-ext-900.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-regular.woff │ │ ├── nunito-sans-v12-latin_latin-ext-200italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-300italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-600italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-700italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-800italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-900italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-regular.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-200italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-300italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-600italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-700italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-800italic.woff2 │ │ └── nunito-sans-v12-latin_latin-ext-900italic.woff2 └── site.webmanifest ├── .npmrc ├── app ├── styles │ └── tailwind.css ├── routes │ ├── _marketing+ │ │ ├── about.tsx │ │ ├── privacy.tsx │ │ ├── support.tsx │ │ ├── tos.tsx │ │ ├── logos │ │ │ ├── docker.png │ │ │ ├── remix.png │ │ │ ├── stars.jpg │ │ │ ├── mailgun.png │ │ │ ├── kody-rocket.png │ │ │ ├── testing-library.png │ │ │ ├── radix.svg │ │ │ ├── github.svg │ │ │ ├── typescript.svg │ │ │ ├── eslint.svg │ │ │ ├── sentry.svg │ │ │ ├── msw.svg │ │ │ ├── vitest.svg │ │ │ ├── logos.ts │ │ │ ├── tailwind.svg │ │ │ ├── playwright.svg │ │ │ └── prisma.svg │ │ └── index.tsx │ ├── users+ │ │ ├── $username_+ │ │ │ ├── notes.index.tsx │ │ │ ├── notes.new.tsx │ │ │ ├── notes.$noteId_.edit.tsx │ │ │ ├── notes.$noteId.tsx │ │ │ └── notes.tsx │ │ └── $username.tsx │ ├── _auth+ │ │ ├── logout.tsx │ │ ├── login.tsx │ │ ├── reset-password.tsx │ │ └── signup_.verify.tsx │ ├── resources+ │ │ ├── file.$fileId.tsx │ │ ├── healthcheck.tsx │ │ ├── delete-image.tsx │ │ ├── image-upload.tsx │ │ ├── delete-note.tsx │ │ ├── theme.tsx │ │ ├── delete-image.test.tsx │ │ └── note-editor.tsx │ ├── me.tsx │ ├── admin+ │ │ ├── cache_.lru.$cacheKey.ts │ │ ├── cache_.sqlite.$cacheKey.ts │ │ └── cache_.sqlite.tsx │ └── settings+ │ │ ├── profile.two-factor.tsx │ │ └── profile.two-factor.index.tsx ├── utils │ ├── nonce-provider.ts │ ├── monitoring.server.ts │ ├── misc.server.ts │ ├── zod-extensions.ts │ ├── db.server.ts │ ├── request-info.ts │ ├── singleton.server.ts │ ├── permissions.server.ts │ ├── devtools.tsx │ ├── user-validation.ts │ ├── user.ts │ ├── monitoring.client.tsx │ ├── email.server.ts │ ├── session.server.ts │ ├── env.server.ts │ ├── forms.module.css │ ├── totp.server.test.ts │ ├── misc.ts │ ├── timing.server.ts │ ├── client-hints.tsx │ ├── auth.server.ts │ └── cache.server.ts ├── entry.client.tsx ├── components │ ├── spinner.tsx │ ├── spacer.tsx │ └── error-boundary.tsx ├── models │ └── note.ts └── entry.server.tsx ├── .dockerignore ├── reset.d.ts ├── remix.env.d.ts ├── postcss.config.js ├── tests ├── fixtures │ ├── test-profile.jpg │ └── images │ │ └── user │ │ ├── 0.jpg │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ ├── 3.jpg │ │ ├── 4.jpg │ │ ├── 5.jpg │ │ ├── 6.jpg │ │ ├── 7.jpg │ │ ├── 8.jpg │ │ ├── 9.jpg │ │ ├── kody.png │ │ └── README.md ├── setup │ ├── vitejs-plugin-react.cjs │ ├── setup-env-vars.ts │ ├── matchers.cjs │ ├── paths.ts │ ├── setup-test-env.ts │ ├── global-setup.ts │ └── utils.ts ├── mocks │ ├── README.md │ ├── index.ts │ └── utils.ts ├── vitest-utils.ts ├── db-utils.ts ├── e2e │ ├── 2fa.test.ts │ └── settings-profile.test.ts ├── memoize-unique.ts └── playwright-utils.ts ├── prisma ├── migrations │ ├── migration_lock.toml │ ├── 20230613173726_priority_model │ │ └── migration.sql │ └── 20230608211059_init │ │ └── migration.sql ├── schema.prisma └── seed.ts ├── .vscode └── extensions.json ├── .prettierignore ├── types ├── priority.ts └── vitest.d.ts ├── index.js ├── deps.d.ts ├── .env.example ├── .gitignore ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── deploy.yml ├── other ├── setup-swap.js ├── litefs.yml └── build-server.ts ├── server └── dev-server.js ├── scripts └── sentry-create-release.js ├── .prettierrc.cjs ├── vitest.config.ts ├── remix.config.js ├── tsconfig.json ├── playwright.config.ts ├── .eslintrc.cjs ├── fly.toml ├── README.md ├── Dockerfile ├── tailwind.config.ts └── package.json /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | registry=https://registry.npmjs.org/ 3 | -------------------------------------------------------------------------------- /app/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | *.log 3 | .DS_Store 4 | .env 5 | /.cache 6 | /public/build 7 | /build 8 | -------------------------------------------------------------------------------- /reset.d.ts: -------------------------------------------------------------------------------- 1 | // Do not add any other lines of code to this file! 2 | import '@total-typescript/ts-reset' 3 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /app/routes/_marketing+/about.tsx: -------------------------------------------------------------------------------- 1 | export default function AboutRoute() { 2 | return
About page
3 | } 4 | -------------------------------------------------------------------------------- /app/routes/_marketing+/privacy.tsx: -------------------------------------------------------------------------------- 1 | export default function PrivacyRoute() { 2 | return
Privacy
3 | } 4 | -------------------------------------------------------------------------------- /app/routes/_marketing+/support.tsx: -------------------------------------------------------------------------------- 1 | export default function SupportRoute() { 2 | return
Support
3 | } 4 | -------------------------------------------------------------------------------- /app/routes/_marketing+/tos.tsx: -------------------------------------------------------------------------------- 1 | export default function TermsOfServiceRoute() { 2 | return
Terms of service
3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/img/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/img/user.png -------------------------------------------------------------------------------- /app/routes/users+/$username_+/notes.index.tsx: -------------------------------------------------------------------------------- 1 | export default function NotesIndexRoute() { 2 | return

Select a note

3 | } 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | 'tailwindcss/nesting': {}, 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /tests/fixtures/test-profile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/tests/fixtures/test-profile.jpg -------------------------------------------------------------------------------- /public/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /tests/fixtures/images/user/0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/tests/fixtures/images/user/0.jpg -------------------------------------------------------------------------------- /tests/fixtures/images/user/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/tests/fixtures/images/user/1.jpg -------------------------------------------------------------------------------- /tests/fixtures/images/user/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/tests/fixtures/images/user/2.jpg -------------------------------------------------------------------------------- /tests/fixtures/images/user/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/tests/fixtures/images/user/3.jpg -------------------------------------------------------------------------------- /tests/fixtures/images/user/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/tests/fixtures/images/user/4.jpg -------------------------------------------------------------------------------- /tests/fixtures/images/user/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/tests/fixtures/images/user/5.jpg -------------------------------------------------------------------------------- /tests/fixtures/images/user/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/tests/fixtures/images/user/6.jpg -------------------------------------------------------------------------------- /tests/fixtures/images/user/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/tests/fixtures/images/user/7.jpg -------------------------------------------------------------------------------- /tests/fixtures/images/user/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/tests/fixtures/images/user/8.jpg -------------------------------------------------------------------------------- /tests/fixtures/images/user/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/tests/fixtures/images/user/9.jpg -------------------------------------------------------------------------------- /public/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /public/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /tests/fixtures/images/user/kody.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/tests/fixtures/images/user/kody.png -------------------------------------------------------------------------------- /tests/setup/vitejs-plugin-react.cjs: -------------------------------------------------------------------------------- 1 | // react types are missing when import as default to ESM module 2 | export { default as react } from '@vitejs/plugin-react' 3 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/docker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/app/routes/_marketing+/logos/docker.png -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/remix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/app/routes/_marketing+/logos/remix.png -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/stars.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/app/routes/_marketing+/logos/stars.jpg -------------------------------------------------------------------------------- /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" -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/mailgun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/app/routes/_marketing+/logos/mailgun.png -------------------------------------------------------------------------------- /tests/fixtures/images/user/README.md: -------------------------------------------------------------------------------- 1 | # User Images 2 | 3 | This is used when creating users with images. If you don't do that, feel free to 4 | delete this directory. 5 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/kody-rocket.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/app/routes/_marketing+/logos/kody-rocket.png -------------------------------------------------------------------------------- /public/favicons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/favicons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/favicons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/favicons/android-chrome-512x512.png -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/testing-library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/app/routes/_marketing+/logos/testing-library.png -------------------------------------------------------------------------------- /public/apple-touch-icon-152x152-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/apple-touch-icon-152x152-precomposed.png -------------------------------------------------------------------------------- /tests/setup/setup-env-vars.ts: -------------------------------------------------------------------------------- 1 | import { DATABASE_PATH, DATABASE_URL } from './paths.ts' 2 | 3 | process.env.DATABASE_PATH = DATABASE_PATH 4 | process.env.DATABASE_URL = DATABASE_URL 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "prisma.prisma", 6 | "bradlc.vscode-tailwindcss" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tests/setup/matchers.cjs: -------------------------------------------------------------------------------- 1 | // matchers types are missing when import as default to ESM module 2 | export { 3 | default as matchers, 4 | TestingLibraryMatchers, 5 | } from '@testing-library/jest-dom/matchers.js' 6 | -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200.woff -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200.woff2 -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300.woff -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300.woff2 -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600.woff -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600.woff2 -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700.woff -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700.woff2 -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800.woff -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800.woff2 -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900.woff -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900.woff2 -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-italic.woff -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-italic.woff2 -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-regular.woff -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200italic.woff -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300italic.woff -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600italic.woff -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700italic.woff -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800italic.woff -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900italic.woff -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-regular.woff2 -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200italic.woff2 -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300italic.woff2 -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600italic.woff2 -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700italic.woff2 -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800italic.woff2 -------------------------------------------------------------------------------- /public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Steinmacher/epic-stack-with-prisma-client-extensions/HEAD/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900italic.woff2 -------------------------------------------------------------------------------- /app/utils/nonce-provider.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export const NonceContext = React.createContext('') 4 | export const NonceProvider = NonceContext.Provider 5 | export const useNonce = () => React.useContext(NonceContext) 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | /server-build 6 | .env 7 | 8 | /test-results/ 9 | /playwright-report/ 10 | /playwright/.cache/ 11 | /tests/fixtures/email/*.json 12 | /coverage 13 | /prisma/migrations 14 | 15 | package-lock.json 16 | -------------------------------------------------------------------------------- /types/priority.ts: -------------------------------------------------------------------------------- 1 | // We keep the type for the enum in a separate file so that we can import it 2 | // into both client and server code. 3 | export const PriorityEnum = { 4 | IMPORTANT: "Important!", 5 | MODERATE: "Moderate", 6 | LOW: "Low", 7 | BACKLOG: "Backlog" 8 | } as const; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | 3 | if (process.env.MOCKS === 'true') { 4 | await import('./tests/mocks/index.ts') 5 | } 6 | 7 | if (process.env.NODE_ENV === 'production') { 8 | await import('./server-build/index.js') 9 | } else { 10 | await import('./server/index.ts') 11 | } 12 | -------------------------------------------------------------------------------- /types/vitest.d.ts: -------------------------------------------------------------------------------- 1 | import { type TestingLibraryMatchers } from '@testing-library/jest-dom/matchers.js' 2 | 3 | declare module 'vitest' { 4 | interface Assertion extends TestingLibraryMatchers {} 5 | interface AsymmetricMatchersContaining 6 | extends TestingLibraryMatchers {} 7 | } 8 | -------------------------------------------------------------------------------- /deps.d.ts: -------------------------------------------------------------------------------- 1 | // This module should contain type definitions for modules which do not have 2 | // their own type definitions and are not available on DefinitelyTyped. 3 | 4 | declare module 'thirty-two' { 5 | export function encode(data: string | Buffer): string 6 | export function decode(data: string): Buffer 7 | } 8 | -------------------------------------------------------------------------------- /app/utils/monitoring.server.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/remix' 2 | 3 | export function init() { 4 | Sentry.init({ 5 | dsn: ENV.SENTRY_DSN, 6 | tracesSampleRate: 1, 7 | // TODO: Make this work with Prisma 8 | // integrations: [new Sentry.Integrations.Prisma({ client: prisma })], 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /app/utils/misc.server.ts: -------------------------------------------------------------------------------- 1 | export function getDomainUrl(request: Request) { 2 | const host = 3 | request.headers.get('X-Forwarded-Host') ?? request.headers.get('host') 4 | if (!host) { 5 | throw new Error('Could not determine domain URL.') 6 | } 7 | const protocol = host.includes('localhost') ? 'http' : 'https' 8 | return `${protocol}://${host}` 9 | } 10 | -------------------------------------------------------------------------------- /app/routes/_auth+/logout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect, type DataFunctionArgs } from '@remix-run/node' 2 | import { authenticator } from '~/utils/auth.server.ts' 3 | 4 | export async function action({ request }: DataFunctionArgs) { 5 | await authenticator.logout(request, { redirectTo: '/' }) 6 | } 7 | 8 | export async function loader() { 9 | return redirect('/') 10 | } 11 | -------------------------------------------------------------------------------- /app/utils/zod-extensions.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const checkboxSchema = (msgWhenRequired?: string) => { 4 | const transformedValue = z 5 | .literal('on') 6 | .optional() 7 | .transform(value => value === 'on') 8 | return msgWhenRequired 9 | ? transformedValue.refine(_ => _, { message: msgWhenRequired }) 10 | : transformedValue 11 | } 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | LITEFS_DIR="/litefs/data" 2 | DATABASE_PATH="./prisma/data.db" 3 | DATABASE_URL="file:./data.db?connection_limit=1" 4 | CACHE_DATABASE_PATH="./other/cache.db" 5 | SESSION_SECRET="super-duper-s3cret" 6 | INTERNAL_COMMAND_TOKEN="some-made-up-token" 7 | MAILGUN_DOMAIN="mg.example.com" 8 | MAILGUN_SENDING_KEY="some-api-token-with-dashes" 9 | SENTRY_DSN="your-dsn" 10 | -------------------------------------------------------------------------------- /app/utils/db.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | import { singleton } from './singleton.server.ts' 3 | import { NoteValidation } from '~/models/note.ts' 4 | 5 | // We pass in the NoteValidation into the $extends client-level method 6 | const prisma = singleton('prisma', () => new PrismaClient().$extends(NoteValidation)) 7 | prisma.$connect() 8 | 9 | export { prisma } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | /server-build 6 | .env 7 | 8 | /prisma/data.db 9 | /prisma/data.db-journal 10 | /tests/prisma 11 | 12 | /test-results/ 13 | /playwright-report/ 14 | /playwright/.cache/ 15 | /tests/fixtures/email/ 16 | /coverage 17 | 18 | /other/cache.db 19 | 20 | # Easy way to create temporary files/folders that won't accidentally be added to git 21 | *.local.* 22 | -------------------------------------------------------------------------------- /tests/mocks/README.md: -------------------------------------------------------------------------------- 1 | # Mocks 2 | 3 | Use this to mock any third party HTTP resources that you don't have running 4 | locally and want to have mocked for local development as well as tests. 5 | 6 | Learn more about how to use this at [mswjs.io](https://mswjs.io/) 7 | 8 | For an extensive example, see the 9 | [source code for kentcdodds.com](https://github.com/kentcdodds/kentcdodds.com/blob/main/mocks/index.ts) 10 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Test Plan 4 | 5 | 6 | 7 | ## Checklist 8 | 9 | - [ ] Tests updated 10 | - [ ] Docs updated 11 | 12 | ## Screenshots 13 | 14 | 16 | -------------------------------------------------------------------------------- /app/utils/request-info.ts: -------------------------------------------------------------------------------- 1 | import { type SerializeFrom } from '@remix-run/node' 2 | import { useRouteLoaderData } from '@remix-run/react' 3 | import { type loader as rootLoader } from '~/root.tsx' 4 | 5 | /** 6 | * @returns the request info from the root loader 7 | */ 8 | export function useRequestInfo() { 9 | const data = useRouteLoaderData('root') as SerializeFrom 10 | return data.requestInfo 11 | } 12 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/radix.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /other/setup-swap.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { $ } from 'execa' 4 | import { writeFile } from 'node:fs/promises' 5 | 6 | console.log('setting up swapfile...') 7 | await $`fallocate -l 512M /swapfile` 8 | await $`chmod 0600 /swapfile` 9 | await $`mkswap /swapfile` 10 | await writeFile('/proc/sys/vm/swappiness', '10') 11 | await $`swapon /swapfile` 12 | await writeFile('/proc/sys/vm/overcommit_memory', '1') 13 | console.log('swapfile setup complete') 14 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Epic Notes", 3 | "short_name": "Epic Notes", 4 | "icons": [ 5 | { 6 | "src": "/favicons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/favicons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#A9ADC1", 17 | "background_color": "#1f2028", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from '@remix-run/react' 2 | import { startTransition } from 'react' 3 | import { hydrateRoot } from 'react-dom/client' 4 | 5 | if (ENV.MODE === 'development') { 6 | import('~/utils/devtools.tsx').then(({ init }) => init()) 7 | } 8 | if (ENV.MODE === 'production' && ENV.SENTRY_DSN) { 9 | import('~/utils/monitoring.client.tsx').then(({ init }) => init()) 10 | } 11 | startTransition(() => { 12 | hydrateRoot(document, ) 13 | }) 14 | -------------------------------------------------------------------------------- /app/routes/users+/$username_+/notes.new.tsx: -------------------------------------------------------------------------------- 1 | import { json } from '@remix-run/router' 2 | import { type DataFunctionArgs } from '@remix-run/server-runtime' 3 | import { NoteEditor } from '~/routes/resources+/note-editor.tsx' 4 | import { requireUserId } from '~/utils/auth.server.ts' 5 | 6 | export async function loader({ request }: DataFunctionArgs) { 7 | await requireUserId(request) 8 | return json({}) 9 | } 10 | 11 | export default function NewNoteRoute() { 12 | return 13 | } 14 | -------------------------------------------------------------------------------- /tests/setup/paths.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | const databaseFile = `./tests/prisma/data.${process.env.VITEST_POOL_ID || 0}.db` 4 | export const DATABASE_PATH = path.join(process.cwd(), databaseFile) 5 | export const DATABASE_URL = `file:${DATABASE_PATH}?connection_limit=1` 6 | 7 | const baseDatabaseFile = `./tests/prisma/base.db` 8 | export const BASE_DATABASE_PATH = path.join(process.cwd(), baseDatabaseFile) 9 | export const BASE_DATABASE_URL = `file:${BASE_DATABASE_PATH}?connection_limit=1` 10 | -------------------------------------------------------------------------------- /app/utils/singleton.server.ts: -------------------------------------------------------------------------------- 1 | // since the dev server re-requires the bundle, do some shenanigans to make 2 | // certain things persist across that 😆 3 | // Borrowed/modified from https://github.com/jenseng/abuse-the-platform/blob/2993a7e846c95ace693ce61626fa072174c8d9c7/app/utils/singleton.ts 4 | 5 | export function singleton(name: string, value: () => Value): Value { 6 | const yolo = global as any 7 | yolo.__singletons ??= {} 8 | yolo.__singletons[name] ??= value() 9 | return yolo.__singletons[name] 10 | } 11 | -------------------------------------------------------------------------------- /tests/vitest-utils.ts: -------------------------------------------------------------------------------- 1 | import { authenticator } from '~/utils/auth.server.ts' 2 | import { commitSession, getSession } from '~/utils/session.server.ts' 3 | 4 | export const BASE_URL = 'https://epicstack.dev' 5 | 6 | export async function getSessionSetCookieHeader( 7 | session: { id: string }, 8 | existingCookie?: string, 9 | ) { 10 | const cookieSession = await getSession(existingCookie) 11 | cookieSession.set(authenticator.sessionKey, session.id) 12 | const setCookieHeader = await commitSession(cookieSession) 13 | return setCookieHeader 14 | } 15 | -------------------------------------------------------------------------------- /server/dev-server.js: -------------------------------------------------------------------------------- 1 | import { execa } from 'execa' 2 | 3 | if (process.env.NODE_ENV === 'production') { 4 | await import('./index.js') 5 | } else { 6 | const command = 7 | 'tsx watch --clear-screen=false --ignore "app/**" --ignore "build/**" --ignore "node_modules/**" --inspect ./index.js' 8 | execa(command, { 9 | stdio: ['ignore', 'inherit', 'inherit'], 10 | shell: true, 11 | env: { 12 | ...process.env, 13 | FORCE_COLOR: true, 14 | MOCKS: true, 15 | }, 16 | // https://github.com/sindresorhus/execa/issues/433 17 | windowsHide: false, 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /scripts/sentry-create-release.js: -------------------------------------------------------------------------------- 1 | import { createRelease } from '@sentry/remix/scripts/createRelease.js' 2 | import 'dotenv/config' 3 | 4 | const DEFAULT_URL_PREFIX = '~/build/' 5 | const DEFAULT_BUILD_PATH = 'public/build' 6 | 7 | // exit with non-zero code if we have everything for Sentry 8 | if ( 9 | process.env.SENTRY_DSN && 10 | process.env.SENTRY_ORG && 11 | process.env.SENTRY_PROJECT && 12 | process.env.SENTRY_AUTH_TOKEN 13 | ) { 14 | createRelease({}, DEFAULT_URL_PREFIX, DEFAULT_BUILD_PATH) 15 | } else { 16 | console.log( 17 | 'Missing Sentry environment variables, skipping sourcemap upload.', 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /app/routes/resources+/file.$fileId.tsx: -------------------------------------------------------------------------------- 1 | import { type DataFunctionArgs } from '@remix-run/node' 2 | import { prisma } from '~/utils/db.server.ts' 3 | 4 | export async function loader({ params }: DataFunctionArgs) { 5 | const image = await prisma.image.findUnique({ 6 | where: { fileId: params.fileId }, 7 | select: { contentType: true, file: { select: { blob: true } } }, 8 | }) 9 | 10 | if (!image) throw new Response('Not found', { status: 404 }) 11 | 12 | return new Response(image.file.blob, { 13 | headers: { 14 | 'Content-Type': image.contentType, 15 | 'Cache-Control': 'max-age=31536000', 16 | }, 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | bracketSameLine: false, 4 | bracketSpacing: true, 5 | embeddedLanguageFormatting: 'auto', 6 | endOfLine: 'lf', 7 | htmlWhitespaceSensitivity: 'css', 8 | insertPragma: false, 9 | jsxSingleQuote: false, 10 | printWidth: 80, 11 | proseWrap: 'always', 12 | quoteProps: 'as-needed', 13 | requirePragma: false, 14 | semi: false, 15 | singleAttributePerLine: false, 16 | singleQuote: true, 17 | tabWidth: 2, 18 | trailingComma: 'all', 19 | useTabs: true, 20 | overrides: [ 21 | { 22 | files: ['**/*.json'], 23 | options: { 24 | useTabs: false, 25 | }, 26 | }, 27 | ], 28 | } 29 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import { react } from './tests/setup/vitejs-plugin-react.cjs' 5 | import { defineConfig } from 'vite' 6 | import tsconfigPaths from 'vite-tsconfig-paths' 7 | 8 | export default defineConfig({ 9 | plugins: [react(), tsconfigPaths()], 10 | css: { postcss: { plugins: [] } }, 11 | test: { 12 | include: ['./app/**/*.test.{ts,tsx}'], 13 | environment: 'jsdom', 14 | setupFiles: ['./tests/setup/setup-test-env.ts'], 15 | globalSetup: ['./tests/setup/global-setup.ts'], 16 | coverage: { 17 | include: ['app/**/*.{ts,tsx}'], 18 | all: true, 19 | }, 20 | }, 21 | }) 22 | -------------------------------------------------------------------------------- /tests/setup/setup-test-env.ts: -------------------------------------------------------------------------------- 1 | import './setup-env-vars.ts' 2 | import { afterAll, afterEach, expect } from 'vitest' 3 | import { installGlobals } from '@remix-run/node' 4 | import { matchers } from './matchers.cjs' 5 | import 'dotenv/config' 6 | import fs from 'fs' 7 | import { BASE_DATABASE_PATH, DATABASE_PATH } from './paths.ts' 8 | import { deleteAllData } from './utils.ts' 9 | import { prisma } from '~/utils/db.server.ts' 10 | 11 | expect.extend(matchers) 12 | 13 | installGlobals() 14 | fs.copyFileSync(BASE_DATABASE_PATH, DATABASE_PATH) 15 | 16 | afterEach(() => deleteAllData()) 17 | 18 | afterAll(async () => { 19 | await prisma.$disconnect() 20 | await fs.promises.rm(DATABASE_PATH) 21 | }) 22 | -------------------------------------------------------------------------------- /app/utils/permissions.server.ts: -------------------------------------------------------------------------------- 1 | import { json } from '@remix-run/node' 2 | import { requireUserId } from './auth.server.ts' 3 | import { prisma } from './db.server.ts' 4 | 5 | export async function requireUserWithPermission( 6 | name: string, 7 | request: Request, 8 | ) { 9 | const userId = await requireUserId(request) 10 | const user = await prisma.user.findFirst({ 11 | where: { id: userId, roles: { some: { permissions: { some: { name } } } } }, 12 | }) 13 | if (!user) { 14 | throw json({ error: 'Unauthorized', requiredRole: name }, { status: 403 }) 15 | } 16 | return user 17 | } 18 | 19 | export async function requireAdmin(request: Request) { 20 | return requireUserWithPermission('admin', request) 21 | } 22 | -------------------------------------------------------------------------------- /app/utils/devtools.tsx: -------------------------------------------------------------------------------- 1 | // TODO: make this a more proper implementation of devtools 2 | export function init() { 3 | navigator.geolocation.getCurrentPosition = function (success, error) { 4 | success({ 5 | timestamp: 0, 6 | coords: { 7 | accuracy: 0, 8 | altitude: 0, 9 | altitudeAccuracy: 0, 10 | heading: 0, 11 | speed: 0, 12 | // London 13 | latitude: 51.507351, 14 | longitude: -0.127758, 15 | 16 | // latitude: 6.7952, 17 | // longitude: 92.5083, 18 | }, 19 | }) 20 | } 21 | 22 | // @ts-expect-error 23 | navigator.permissions.query = function (options) { 24 | return Promise.resolve({ state: 'granted' }) 25 | // return Promise.resolve({ state: 'denied' }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/utils/user-validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const usernameSchema = z 4 | .string() 5 | .min(3, { message: 'Username is too short' }) 6 | .max(20, { message: 'Username is too long' }) 7 | .regex(/^[^@]*$/, 'Username can not contain "@"') 8 | export const passwordSchema = z 9 | .string() 10 | .min(6, { message: 'Password is too short' }) 11 | .max(100, { message: 'Password is too long' }) 12 | export const nameSchema = z 13 | .string() 14 | .min(3, { message: 'Name is too short' }) 15 | .max(40, { message: 'Name is too long' }) 16 | export const emailSchema = z 17 | .string() 18 | .email({ message: 'Email is invalid' }) 19 | .min(3, { message: 'Email is too short' }) 20 | .max(100, { message: 'Email is too long' }) 21 | -------------------------------------------------------------------------------- /tests/db-utils.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import bcrypt from 'bcryptjs' 3 | import { memoizeUnique } from './memoize-unique.ts' 4 | 5 | const unique = memoizeUnique(faker.internet.userName) 6 | 7 | export function createUser() { 8 | const firstName = faker.person.firstName() 9 | const lastName = faker.person.lastName() 10 | 11 | const username = unique({ 12 | firstName: firstName.toLowerCase(), 13 | lastName: lastName.toLowerCase(), 14 | }) 15 | return { 16 | username, 17 | name: `${firstName} ${lastName}`, 18 | email: `${username}@example.com`, 19 | } 20 | } 21 | 22 | export function createPassword(username: string = faker.internet.userName()) { 23 | return { 24 | hash: bcrypt.hashSync(username, 10), 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/setup/global-setup.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { execaCommand } from 'execa' 3 | import fsExtra from 'fs-extra' 4 | import { BASE_DATABASE_PATH, BASE_DATABASE_URL } from './paths.ts' 5 | 6 | export async function setup() { 7 | await fsExtra.ensureDir(path.dirname(BASE_DATABASE_PATH)) 8 | await ensureDbReady() 9 | return async function teardown() {} 10 | } 11 | 12 | async function ensureDbReady() { 13 | if (!(await fsExtra.pathExists(BASE_DATABASE_PATH))) { 14 | await execaCommand( 15 | 'prisma migrate reset --force --skip-seed --skip-generate', 16 | { 17 | stdio: 'inherit', 18 | env: { 19 | ...process.env, 20 | DATABASE_PATH: BASE_DATABASE_PATH, 21 | DATABASE_URL: BASE_DATABASE_URL, 22 | }, 23 | }, 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/routes/me.tsx: -------------------------------------------------------------------------------- 1 | import { redirect, type DataFunctionArgs } from '@remix-run/node' 2 | import { authenticator, requireUserId } from '~/utils/auth.server.ts' 3 | import { prisma } from '~/utils/db.server.ts' 4 | 5 | export async function loader({ request }: DataFunctionArgs) { 6 | const userId = await requireUserId(request) 7 | const user = await prisma.user.findUnique({ where: { id: userId } }) 8 | if (!user) { 9 | const requestUrl = new URL(request.url) 10 | const loginParams = new URLSearchParams([ 11 | ['redirectTo', `${requestUrl.pathname}${requestUrl.search}`], 12 | ]) 13 | const redirectTo = `/login?${loginParams}` 14 | await authenticator.logout(request, { redirectTo }) 15 | return redirect(redirectTo) 16 | } 17 | return redirect(`/users/${user.username}`) 18 | } 19 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | import { flatRoutes } from 'remix-flat-routes' 2 | 3 | /** 4 | * @type {import('@remix-run/dev').AppConfig} 5 | */ 6 | export default { 7 | cacheDirectory: './node_modules/.cache/remix', 8 | ignoredRouteFiles: ['**/*'], 9 | serverModuleFormat: 'esm', 10 | serverPlatform: 'node', 11 | tailwind: true, 12 | postcss: true, 13 | watchPaths: ['./tailwind.config.ts'], 14 | future: { 15 | v2_headers: true, 16 | v2_meta: true, 17 | v2_errorBoundary: true, 18 | v2_normalizeFormMethod: true, 19 | v2_routeConvention: true, 20 | unstable_dev: true, 21 | }, 22 | routes: async defineRoutes => { 23 | return flatRoutes('routes', defineRoutes, { 24 | ignoredRouteFiles: [ 25 | '.*', 26 | '**/*.css', 27 | '**/*.test.{js,jsx,ts,tsx}', 28 | '**/__*.*', 29 | ], 30 | }) 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /app/routes/users+/$username_+/notes.$noteId_.edit.tsx: -------------------------------------------------------------------------------- 1 | import { json, type DataFunctionArgs } from '@remix-run/node' 2 | import { useLoaderData } from '@remix-run/react' 3 | import { NoteEditor } from '~/routes/resources+/note-editor.tsx' 4 | import { requireUserId } from '~/utils/auth.server.ts' 5 | import { prisma } from '~/utils/db.server.ts' 6 | 7 | export async function loader({ params, request }: DataFunctionArgs) { 8 | const userId = await requireUserId(request) 9 | const note = await prisma.note.findFirst({ 10 | where: { 11 | id: params.noteId, 12 | ownerId: userId, 13 | }, 14 | }) 15 | if (!note) { 16 | throw new Response('Not found', { status: 404 }) 17 | } 18 | return json({ note: note }) 19 | } 20 | 21 | export default function NoteEdit() { 22 | const data = useLoaderData() 23 | 24 | return 25 | } 26 | -------------------------------------------------------------------------------- /app/utils/user.ts: -------------------------------------------------------------------------------- 1 | import { type SerializeFrom } from '@remix-run/node' 2 | import { useRouteLoaderData } from '@remix-run/react' 3 | import { type loader as rootLoader } from '~/root.tsx' 4 | 5 | function isUser(user: any): user is SerializeFrom['user'] { 6 | return user && typeof user === 'object' && typeof user.id === 'string' 7 | } 8 | 9 | export function useOptionalUser() { 10 | const data = useRouteLoaderData('root') as SerializeFrom 11 | if (!data || !isUser(data.user)) { 12 | return undefined 13 | } 14 | return data.user 15 | } 16 | 17 | export function useUser() { 18 | const maybeUser = useOptionalUser() 19 | if (!maybeUser) { 20 | throw new Error( 21 | 'No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead.', 22 | ) 23 | } 24 | return maybeUser 25 | } 26 | -------------------------------------------------------------------------------- /app/components/spinner.tsx: -------------------------------------------------------------------------------- 1 | export function Spinner({ showSpinner }: { showSpinner: boolean }) { 2 | return ( 3 |
8 | 16 | Loading 17 | 25 | 30 | 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /app/utils/monitoring.client.tsx: -------------------------------------------------------------------------------- 1 | import { useLocation, useMatches } from '@remix-run/react' 2 | import * as Sentry from '@sentry/remix' 3 | import { useEffect } from 'react' 4 | 5 | export function init() { 6 | Sentry.init({ 7 | dsn: ENV.SENTRY_DSN, 8 | integrations: [ 9 | new Sentry.BrowserTracing({ 10 | routingInstrumentation: Sentry.remixRouterInstrumentation( 11 | useEffect, 12 | useLocation, 13 | useMatches, 14 | ), 15 | }), 16 | // Replay is only available in the client 17 | new Sentry.Replay(), 18 | ], 19 | 20 | // Set tracesSampleRate to 1.0 to capture 100% 21 | // of transactions for performance monitoring. 22 | // We recommend adjusting this value in production 23 | tracesSampleRate: 1.0, 24 | 25 | // Capture Replay for 10% of all sessions, 26 | // plus for 100% of sessions with an error 27 | replaysSessionSampleRate: 0.1, 28 | replaysOnErrorSampleRate: 1.0, 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /app/routes/resources+/healthcheck.tsx: -------------------------------------------------------------------------------- 1 | // learn more: https://fly.io/docs/reference/configuration/#services-http_checks 2 | import { type DataFunctionArgs } from '@remix-run/node' 3 | 4 | import { prisma } from '~/utils/db.server.ts' 5 | 6 | export async function loader({ request }: DataFunctionArgs) { 7 | const host = 8 | request.headers.get('X-Forwarded-Host') ?? request.headers.get('host') 9 | 10 | try { 11 | const url = new URL('/', `http://${host}`) 12 | // if we can connect to the database and make a simple query 13 | // and make a HEAD request to ourselves, then we're good. 14 | await Promise.all([ 15 | prisma.user.count(), 16 | fetch(url.toString(), { method: 'HEAD' }).then(r => { 17 | if (!r.ok) return Promise.reject(r) 18 | }), 19 | ]) 20 | return new Response('OK') 21 | } catch (error: unknown) { 22 | console.error('healthcheck ❌', { error }) 23 | return new Response('ERROR', { status: 500 }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/utils/email.server.ts: -------------------------------------------------------------------------------- 1 | export async function sendEmail(email: { 2 | to: string 3 | subject: string 4 | html: string 5 | text: string 6 | }) { 7 | if (!process.env.MAILGUN_SENDING_KEY && !process.env.MOCKS) { 8 | console.error(`MAILGUN_SENDING_KEY not set and we're not in mocks mode.`) 9 | console.error( 10 | `To send emails, set MAILGUN_SENDING_KEY and MAILGUN_DOMAIN environment variables.`, 11 | ) 12 | console.error(`Failing to send the following email:`, JSON.stringify(email)) 13 | return 14 | } 15 | const auth = `${Buffer.from( 16 | `api:${process.env.MAILGUN_SENDING_KEY}`, 17 | ).toString('base64')}` 18 | 19 | const body = new URLSearchParams({ 20 | ...email, 21 | from: 'hello@epicstack.dev', 22 | }) 23 | 24 | return fetch( 25 | `https://api.mailgun.net/v3/${process.env.MAILGUN_DOMAIN}/messages`, 26 | { 27 | method: 'POST', 28 | body, 29 | headers: { 30 | Authorization: `Basic ${auth}`, 31 | }, 32 | }, 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /app/utils/session.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage } from '@remix-run/node' 2 | 3 | export const sessionStorage = createCookieSessionStorage({ 4 | cookie: { 5 | name: '_session', 6 | sameSite: 'lax', 7 | path: '/', 8 | httpOnly: true, 9 | secrets: [process.env.SESSION_SECRET], 10 | secure: process.env.NODE_ENV === 'production', 11 | }, 12 | }) 13 | 14 | export const { getSession, commitSession, destroySession } = sessionStorage 15 | 16 | type Session = Awaited> 17 | 18 | const themeKey = 'theme' 19 | 20 | export function getTheme(session: Session): 'dark' | 'light' | null { 21 | const theme = session.get(themeKey) 22 | if (theme === 'dark' || theme === 'light') return theme 23 | return null 24 | } 25 | 26 | export function setTheme(session: Session, theme: 'dark' | 'light') { 27 | session.set(themeKey, theme) 28 | } 29 | 30 | export function deleteTheme(session: Session) { 31 | session.unset(themeKey) 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "remix.env.d.ts", 4 | "**/*.ts", 5 | "**/*.tsx", 6 | "./tests/setup/setup-test-env.ts" 7 | ], 8 | "compilerOptions": { 9 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 10 | "types": [], 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "jsx": "react-jsx", 14 | "module": "nodenext", 15 | "moduleResolution": "nodenext", 16 | "resolveJsonModule": true, 17 | "target": "ES2022", 18 | "strict": true, 19 | "noImplicitAny": true, 20 | "allowJs": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "baseUrl": ".", 23 | "paths": { 24 | "~/*": ["./app/*"], 25 | "prisma/*": ["./prisma/*"], 26 | "tests/*": ["./tests/*"] 27 | }, 28 | "skipLibCheck": true, 29 | "allowImportingTsExtensions": true, 30 | "typeRoots": ["./types", "./node_modules/@types"], 31 | 32 | // Remix takes care of building everything in `remix build`. 33 | "noEmit": true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { devices, type PlaywrightTestConfig } from '@playwright/test' 2 | import 'dotenv/config' 3 | 4 | const PORT = process.env.PORT || '3000' 5 | 6 | export default { 7 | testDir: './tests/e2e', 8 | timeout: 15 * 1000, 9 | expect: { 10 | timeout: 5 * 1000, 11 | }, 12 | fullyParallel: true, 13 | forbidOnly: !!process.env.CI, 14 | retries: process.env.CI ? 2 : 0, 15 | workers: process.env.CI ? 1 : undefined, 16 | reporter: 'html', 17 | use: { 18 | actionTimeout: 0, 19 | baseURL: `http://localhost:${PORT}/`, 20 | trace: 'on-first-retry', 21 | }, 22 | 23 | projects: [ 24 | { 25 | name: 'chromium', 26 | use: { 27 | ...devices['Desktop Chrome'], 28 | }, 29 | }, 30 | ], 31 | 32 | webServer: { 33 | command: process.env.CI 34 | ? `cross-env PORT=${PORT} npm run start:mocks` 35 | : `cross-env PORT=${PORT} npm run dev`, 36 | port: Number(PORT), 37 | reuseExistingServer: true, 38 | stdout: 'pipe', 39 | stderr: 'pipe', 40 | }, 41 | } satisfies PlaywrightTestConfig 42 | -------------------------------------------------------------------------------- /app/components/spacer.tsx: -------------------------------------------------------------------------------- 1 | export function Spacer({ 2 | size, 3 | }: { 4 | /** 5 | * The size of the space 6 | * 7 | * 4xs: h-4 (16px) 8 | * 9 | * 3xs: h-8 (32px) 10 | * 11 | * 2xs: h-12 (48px) 12 | * 13 | * xs: h-16 (64px) 14 | * 15 | * sm: h-20 (80px) 16 | * 17 | * md: h-24 (96px) 18 | * 19 | * lg: h-28 (112px) 20 | * 21 | * xl: h-32 (128px) 22 | * 23 | * 2xl: h-36 (144px) 24 | * 25 | * 3xl: h-40 (160px) 26 | * 27 | * 4xl: h-44 (176px) 28 | */ 29 | size: 30 | | '4xs' 31 | | '3xs' 32 | | '2xs' 33 | | 'xs' 34 | | 'sm' 35 | | 'md' 36 | | 'lg' 37 | | 'xl' 38 | | '2xl' 39 | | '3xl' 40 | | '4xl' 41 | }) { 42 | const options: Record = { 43 | '4xs': 'h-4', 44 | '3xs': 'h-8', 45 | '2xs': 'h-12', 46 | xs: 'h-16', 47 | sm: 'h-20', 48 | md: 'h-24', 49 | lg: 'h-28', 50 | xl: 'h-32', 51 | '2xl': 'h-36', 52 | '3xl': 'h-40', 53 | '4xl': 'h-44', 54 | } 55 | const className = options[size] 56 | return
57 | } 58 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('@types/eslint').Linter.BaseConfig} */ 2 | module.exports = { 3 | extends: [ 4 | '@remix-run/eslint-config', 5 | '@remix-run/eslint-config/node', 6 | 'prettier', 7 | ], 8 | overrides: [ 9 | { 10 | extends: ['@remix-run/eslint-config/jest-testing-library'], 11 | files: ['app/**/__tests__/**/*', 'app/**/*.{spec,test}.*'], 12 | rules: { 13 | 'testing-library/no-await-sync-events': 'off', 14 | 'jest-dom/prefer-in-document': 'off', 15 | }, 16 | // we're using vitest which has a very similar API to jest 17 | // (so the linting plugins work nicely), but it means we have to explicitly 18 | // set the jest version. 19 | settings: { 20 | jest: { 21 | version: 28, 22 | }, 23 | }, 24 | }, 25 | ], 26 | rules: { 27 | '@typescript-eslint/consistent-type-imports': [ 28 | 'warn', 29 | { 30 | prefer: 'type-imports', 31 | disallowTypeAnnotations: true, 32 | fixStyle: 'inline-type-imports', 33 | }, 34 | ], 35 | '@typescript-eslint/no-duplicate-imports': 'warn', 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /prisma/migrations/20230613173726_priority_model/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `priority` to the `Note` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- RedefineTables 8 | PRAGMA foreign_keys=OFF; 9 | CREATE TABLE "new_Note" ( 10 | "id" TEXT NOT NULL PRIMARY KEY, 11 | "title" TEXT NOT NULL, 12 | "content" TEXT NOT NULL, 13 | "priority" TEXT NOT NULL, 14 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 15 | "updatedAt" DATETIME NOT NULL, 16 | "ownerId" TEXT NOT NULL, 17 | CONSTRAINT "Note_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE 18 | ); 19 | INSERT INTO "new_Note" ("content", "createdAt", "id", "ownerId", "title", "updatedAt") SELECT "content", "createdAt", "id", "ownerId", "title", "updatedAt" FROM "Note"; 20 | DROP TABLE "Note"; 21 | ALTER TABLE "new_Note" RENAME TO "Note"; 22 | CREATE UNIQUE INDEX "Note_id_key" ON "Note"("id"); 23 | PRAGMA foreign_key_check; 24 | PRAGMA foreign_keys=ON; 25 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | app = "enum-validation-133c" 2 | primary_region = "sjc" 3 | kill_signal = "SIGINT" 4 | kill_timeout = 5 5 | processes = [ ] 6 | 7 | [experimental] 8 | allowed_public_ports = [ ] 9 | auto_rollback = true 10 | 11 | [mounts] 12 | source = "data" 13 | destination = "/data" 14 | 15 | [deploy] 16 | release_command = "node ./scripts/sentry-create-release" 17 | 18 | [[services]] 19 | internal_port = 8080 20 | processes = [ "app" ] 21 | protocol = "tcp" 22 | script_checks = [ ] 23 | 24 | [services.concurrency] 25 | hard_limit = 100 26 | soft_limit = 80 27 | type = "connections" 28 | 29 | [[services.ports]] 30 | handlers = [ "http" ] 31 | port = 80 32 | force_https = true 33 | 34 | [[services.ports]] 35 | handlers = [ "tls", "http" ] 36 | port = 443 37 | 38 | [[services.tcp_checks]] 39 | grace_period = "1s" 40 | interval = "15s" 41 | restart_limit = 0 42 | timeout = "2s" 43 | 44 | [[services.http_checks]] 45 | interval = "10s" 46 | grace_period = "5s" 47 | method = "get" 48 | path = "/resources/healthcheck" 49 | protocol = "http" 50 | timeout = "2s" 51 | tls_skip_verify = false 52 | headers = { } 53 | -------------------------------------------------------------------------------- /app/routes/admin+/cache_.lru.$cacheKey.ts: -------------------------------------------------------------------------------- 1 | import type { DataFunctionArgs } from '@remix-run/node' 2 | import { json } from '@remix-run/node' 3 | import invariant from 'tiny-invariant' 4 | import { getAllInstances, getInstanceInfo } from 'litefs-js' 5 | import { ensureInstance } from 'litefs-js/remix.js' 6 | import { lruCache } from '~/utils/cache.server.ts' 7 | import { requireAdmin } from '~/utils/permissions.server.ts' 8 | 9 | export async function loader({ request, params }: DataFunctionArgs) { 10 | await requireAdmin(request) 11 | const searchParams = new URL(request.url).searchParams 12 | const currentInstanceInfo = await getInstanceInfo() 13 | const allInstances = await getAllInstances() 14 | const instance = 15 | searchParams.get('instance') ?? currentInstanceInfo.currentInstance 16 | await ensureInstance(instance) 17 | 18 | const { cacheKey } = params 19 | invariant(cacheKey, 'cacheKey is required') 20 | return json({ 21 | instance: { 22 | hostname: instance, 23 | region: allInstances[instance], 24 | isPrimary: currentInstanceInfo.primaryInstance === instance, 25 | }, 26 | cacheKey, 27 | value: lruCache.get(cacheKey), 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /app/routes/admin+/cache_.sqlite.$cacheKey.ts: -------------------------------------------------------------------------------- 1 | import type { DataFunctionArgs } from '@remix-run/node' 2 | import { json } from '@remix-run/node' 3 | import invariant from 'tiny-invariant' 4 | import { getAllInstances, getInstanceInfo } from 'litefs-js' 5 | import { ensureInstance } from 'litefs-js/remix.js' 6 | import { cache } from '~/utils/cache.server.ts' 7 | import { requireAdmin } from '~/utils/permissions.server.ts' 8 | 9 | export async function loader({ request, params }: DataFunctionArgs) { 10 | await requireAdmin(request) 11 | const searchParams = new URL(request.url).searchParams 12 | const currentInstanceInfo = await getInstanceInfo() 13 | const allInstances = await getAllInstances() 14 | const instance = 15 | searchParams.get('instance') ?? currentInstanceInfo.currentInstance 16 | await ensureInstance(instance) 17 | 18 | const { cacheKey } = params 19 | invariant(cacheKey, 'cacheKey is required') 20 | return json({ 21 | instance: { 22 | hostname: instance, 23 | region: allInstances[instance], 24 | isPrimary: currentInstanceInfo.primaryInstance === instance, 25 | }, 26 | cacheKey, 27 | value: cache.get(cacheKey), 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /other/litefs.yml: -------------------------------------------------------------------------------- 1 | # Documented example: https://github.com/superfly/litefs/blob/dec5a7353292068b830001bd2df4830e646f6a2f/cmd/litefs/etc/litefs.yml 2 | fuse: 3 | # Required. This is the mount directory that applications will 4 | # use to access their SQLite databases. 5 | dir: '${LITEFS_DIR}' 6 | 7 | data: 8 | # Path to internal data storage. 9 | dir: '/data/litefs' 10 | 11 | proxy: 12 | # matches the internal_port in fly.toml 13 | addr: ':${INTERNAL_PORT}' 14 | target: 'localhost:${PORT}' 15 | db: '${DATABASE_FILENAME}' 16 | 17 | # The lease section specifies how the cluster will be managed. We're using the 18 | # "consul" lease type so that our application can dynamically change the primary. 19 | # 20 | # These environment variables will be available in your Fly.io application. 21 | lease: 22 | type: 'consul' 23 | candidate: ${FLY_REGION == PRIMARY_REGION} 24 | promote: true 25 | advertise-url: 'http://${HOSTNAME}.vm.${FLY_APP_NAME}.internal:20202' 26 | 27 | consul: 28 | url: '${FLY_CONSUL_URL}' 29 | key: 'litefs/${FLY_APP_NAME}' 30 | 31 | exec: 32 | - cmd: node ./other/setup-swap.js 33 | 34 | - cmd: npx prisma migrate deploy 35 | if-candidate: true 36 | 37 | - cmd: npm start 38 | -------------------------------------------------------------------------------- /tests/mocks/index.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw' 2 | import { setupServer } from 'msw/node' 3 | import closeWithGrace from 'close-with-grace' 4 | import { requiredHeader, writeEmail } from './utils.ts' 5 | 6 | const handlers = [ 7 | process.env.REMIX_DEV_HTTP_ORIGIN 8 | ? rest.post(`${process.env.REMIX_DEV_HTTP_ORIGIN}/ping`, req => 9 | req.passthrough(), 10 | ) 11 | : null, 12 | process.env.MAILGUN_DOMAIN 13 | ? rest.post( 14 | `https://api.mailgun.net/v3/${process.env.MAILGUN_DOMAIN}/messages`, 15 | async (req, res, ctx) => { 16 | requiredHeader(req.headers, 'Authorization') 17 | const body = Object.fromEntries(new URLSearchParams(await req.text())) 18 | console.info('🔶 mocked email contents:', body) 19 | 20 | await writeEmail(body) 21 | 22 | const randomId = '20210321210543.1.E01B8B612C44B41B' 23 | const id = `<${randomId}>@${req.params.domain}` 24 | return res(ctx.json({ id, message: 'Queued. Thank you.' })) 25 | }, 26 | ) 27 | : null, 28 | ].filter(Boolean) 29 | 30 | const server = setupServer(...handlers) 31 | 32 | server.listen({ onUnhandledRequest: 'warn' }) 33 | console.info('🔶 Mock server installed') 34 | 35 | closeWithGrace(() => { 36 | server.close() 37 | }) 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Setting up Prisma Client extensions for enums in Epic Stack 3 | ## Why I did this 4 | Prisma dosn't support enums out of the box and this project shows a way to set up enum like behaviors in your Epic Stack application. 5 | [Video of what I did](https://www.loom.com/share/7a8d9619085e4afd88bab9e3c33a56aa?sid=18c6a548-192a-4792-86fe-b46b5455ebca). 6 | 7 | I also left comments in the code to describe what and why I did to get the enum to work in the project. 8 | Pretty much to enable the feature flag in the `prisma.schema` you need to add the `clientExtensions` feature flag to your `generator` block as in below. 9 | ```ts 10 | generator client { 11 | provider = "prisma-client-js" 12 | previewFeatures = ["clientExtensions"] 13 | } 14 | ``` 15 | Then run the generate command 16 | ```bash 17 | npx prisma generate 18 | ``` 19 | Now you're good to go to create and use your extension! 20 | 21 | --- 22 | 23 | [Prisma Docs](https://www.prisma.io/docs/concepts/components/prisma-client/client-extensions) to learn more on client extensions in your project. I found these helpful. 24 | 25 | [Matt Pocock ](https://youtu.be/jjMbPt_H3RQ?t=312) 'splaining the TypesScrip magic that is taking place. 26 | 27 | I hope this helps! Have a great day! 28 | -------------------------------------------------------------------------------- /app/components/error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | isRouteErrorResponse, 3 | useParams, 4 | useRouteError, 5 | } from '@remix-run/react' 6 | import { type ErrorResponse } from '@remix-run/router' 7 | import { getErrorMessage } from '~/utils/misc.ts' 8 | 9 | type StatusHandler = (info: { 10 | error: ErrorResponse 11 | params: Record 12 | }) => JSX.Element | null 13 | 14 | export function GeneralErrorBoundary({ 15 | defaultStatusHandler = ({ error }) => ( 16 |

17 | {error.status} {error.data} 18 |

19 | ), 20 | statusHandlers, 21 | unexpectedErrorHandler = error =>

{getErrorMessage(error)}

, 22 | }: { 23 | defaultStatusHandler?: StatusHandler 24 | statusHandlers?: Record 25 | unexpectedErrorHandler?: (error: unknown) => JSX.Element | null 26 | }) { 27 | const error = useRouteError() 28 | const params = useParams() 29 | 30 | if (typeof document !== 'undefined') { 31 | console.error(error) 32 | } 33 | 34 | return ( 35 |
36 | {isRouteErrorResponse(error) 37 | ? (statusHandlers?.[error.status] ?? defaultStatusHandler)({ 38 | error, 39 | params, 40 | }) 41 | : unexpectedErrorHandler(error)} 42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/typescript.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | TypeScript logo 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/eslint.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 10 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /other/build-server.ts: -------------------------------------------------------------------------------- 1 | import fsExtra from 'fs-extra' 2 | import path from 'path' 3 | import { fileURLToPath } from 'url' 4 | import { globSync } from 'glob' 5 | import esbuild from 'esbuild' 6 | 7 | const pkg = fsExtra.readJsonSync(path.join(process.cwd(), 'package.json')) 8 | 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 10 | const here = (...s: Array) => path.join(__dirname, ...s) 11 | const globsafe = (s: string) => s.replace(/\\/g, '/') 12 | 13 | const allFiles = globSync(globsafe(here('../server/**/*.*')), { 14 | ignore: [ 15 | 'server/dev-server.js', // for development only 16 | '**/tsconfig.json', 17 | '**/eslint*', 18 | '**/__tests__/**', 19 | ], 20 | }) 21 | 22 | const entries = [] 23 | for (const file of allFiles) { 24 | if (/\.(ts|js|tsx|jsx)$/.test(file)) { 25 | entries.push(file) 26 | } else { 27 | const dest = file.replace(here('../server'), here('../server-build')) 28 | fsExtra.ensureDirSync(path.parse(dest).dir) 29 | fsExtra.copySync(file, dest) 30 | console.log(`copied: ${file.replace(`${here('../server')}/`, '')}`) 31 | } 32 | } 33 | 34 | console.log() 35 | console.log('building...') 36 | 37 | esbuild 38 | .build({ 39 | entryPoints: entries, 40 | outdir: here('../server-build'), 41 | target: [`node${pkg.engines.node}`], 42 | platform: 'node', 43 | format: 'esm', 44 | logLevel: 'info', 45 | }) 46 | .catch((error: unknown) => { 47 | console.error(error) 48 | process.exit(1) 49 | }) 50 | -------------------------------------------------------------------------------- /app/utils/env.server.ts: -------------------------------------------------------------------------------- 1 | import invariant from 'tiny-invariant' 2 | 3 | const requiredServerEnvs = [ 4 | 'NODE_ENV', 5 | 'DATABASE_PATH', 6 | 'DATABASE_URL', 7 | 'SESSION_SECRET', 8 | 'INTERNAL_COMMAND_TOKEN', 9 | 'CACHE_DATABASE_PATH', 10 | // If you plan to use Mailgun, uncomment these lines 11 | // 'MAILGUN_SENDING_KEY', 12 | // 'MAILGUN_DOMAIN', 13 | // If you plan on using Sentry, uncomment this line 14 | // 'SENTRY_DSN', 15 | ] as const 16 | 17 | declare global { 18 | namespace NodeJS { 19 | interface ProcessEnv 20 | extends Record<(typeof requiredServerEnvs)[number], string> {} 21 | } 22 | } 23 | 24 | export function init() { 25 | for (const env of requiredServerEnvs) { 26 | invariant(process.env[env], `${env} is required`) 27 | } 28 | } 29 | 30 | /** 31 | * This is used in both `entry.server.ts` and `root.tsx` to ensure that 32 | * the environment variables are set and globally available before the app is 33 | * started. 34 | * 35 | * NOTE: Do *not* add any environment variables in here that you do not wish to 36 | * be included in the client. 37 | * @returns all public ENV variables 38 | */ 39 | export function getEnv() { 40 | invariant(process.env.NODE_ENV, 'NODE_ENV should be defined') 41 | 42 | return { 43 | MODE: process.env.NODE_ENV, 44 | SENTRY_DSN: process.env.SENTRY_DSN, 45 | } 46 | } 47 | 48 | type ENV = ReturnType 49 | 50 | declare global { 51 | var ENV: ENV 52 | interface Window { 53 | ENV: ENV 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/sentry.svg: -------------------------------------------------------------------------------- 1 | 3 | 6 | -------------------------------------------------------------------------------- /app/routes/resources+/delete-image.tsx: -------------------------------------------------------------------------------- 1 | import { parse } from '@conform-to/zod' 2 | import { json, type DataFunctionArgs } from '@remix-run/node' 3 | import { z } from 'zod' 4 | import { requireUserId } from '~/utils/auth.server.ts' 5 | import { prisma } from '~/utils/db.server.ts' 6 | 7 | export const ROUTE_PATH = '/resources/delete-image' 8 | 9 | const DeleteFormSchema = z.object({ 10 | imageId: z.string(), 11 | }) 12 | 13 | export async function action({ request }: DataFunctionArgs) { 14 | const userId = await requireUserId(request, { redirectTo: null }) 15 | const formData = await request.formData() 16 | const submission = parse(formData, { 17 | schema: DeleteFormSchema, 18 | acceptMultipleErrors: () => true, 19 | }) 20 | if (!submission.value) { 21 | return json( 22 | { 23 | status: 'error', 24 | submission, 25 | } as const, 26 | { status: 400 }, 27 | ) 28 | } 29 | if (submission.intent !== 'submit') { 30 | return json({ status: 'success', submission } as const) 31 | } 32 | const { imageId } = submission.value 33 | const image = await prisma.image.findFirst({ 34 | select: { fileId: true }, 35 | where: { 36 | fileId: imageId, 37 | user: { id: userId }, 38 | }, 39 | }) 40 | if (!image) { 41 | submission.error.imageId = ['Image not found'] 42 | return json( 43 | { 44 | status: 'error', 45 | submission, 46 | } as const, 47 | { status: 404 }, 48 | ) 49 | } 50 | 51 | await prisma.image.delete({ 52 | where: { fileId: image.fileId }, 53 | }) 54 | 55 | return json({ status: 'success' } as const) 56 | } 57 | -------------------------------------------------------------------------------- /app/utils/forms.module.css: -------------------------------------------------------------------------------- 1 | .field { 2 | position: relative; 3 | min-height: 96px; 4 | } 5 | 6 | .textareaField { 7 | position: relative; 8 | min-height: 226px; 9 | } 10 | 11 | .field input + label, 12 | .field textarea + label, 13 | .textareaField input + label, 14 | .textareaField textarea + label { 15 | @apply absolute left-0 origin-top-left translate-x-4 text-body-xs text-night-200 transition-transform; 16 | } 17 | 18 | .field input:required + label:after, 19 | .textareaField textarea:required + label:after, 20 | .checkboxField button:required + label:after { 21 | content: ' *'; 22 | } 23 | 24 | .field input:placeholder-shown:not(:focus) + label, 25 | .textareaField textarea:placeholder-shown:not(:focus) + label { 26 | @apply translate-y-[1.4rem] scale-100; 27 | } 28 | 29 | .field input:not(:placeholder-shown) + label, 30 | .field input:focus + label, 31 | .textareaField textarea:not(:placeholder-shown) + label, 32 | .textareaField textarea:focus + label { 33 | @apply translate-y-2 scale-75; 34 | } 35 | 36 | .checkboxField { 37 | min-height: 56px; 38 | } 39 | 40 | .checkboxField button { 41 | @apply flex h-6 w-6 items-center justify-center rounded border; 42 | } 43 | 44 | .checkboxField button[aria-checked='false'] { 45 | @apply border-night-400 bg-night-700; 46 | } 47 | 48 | .checkboxField button[aria-checked='true'] { 49 | @apply border-accent-pink bg-accent-pink; 50 | } 51 | 52 | .field input[aria-invalid='true'], 53 | .textareaField textarea[aria-invalid='true'], 54 | .checkboxField button[aria-invalid='true'] { 55 | @apply border-accent-red; 56 | } 57 | -------------------------------------------------------------------------------- /tests/mocks/utils.ts: -------------------------------------------------------------------------------- 1 | import fsExtra from 'fs-extra' 2 | import path from 'path' 3 | import { fileURLToPath } from 'url' 4 | import { z } from 'zod' 5 | 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 7 | const fixturesDirPath = path.join(__dirname, '..', 'fixtures') 8 | 9 | export async function readFixture(subdir: string, name: string) { 10 | return fsExtra.readJSON(path.join(fixturesDirPath, subdir, `${name}.json`)) 11 | } 12 | 13 | export async function createFixture( 14 | subdir: string, 15 | name: string, 16 | data: unknown, 17 | ) { 18 | const dir = path.join(fixturesDirPath, subdir) 19 | await fsExtra.ensureDir(dir) 20 | return fsExtra.writeJSON(path.join(dir, `./${name}.json`), data) 21 | } 22 | 23 | export const emailSchema = z.object({ 24 | to: z.string(), 25 | from: z.string(), 26 | subject: z.string(), 27 | text: z.string(), 28 | html: z.string(), 29 | }) 30 | 31 | export async function writeEmail(rawEmail: unknown) { 32 | const email = emailSchema.parse(rawEmail) 33 | await createFixture('email', email.to, email) 34 | } 35 | 36 | export async function readEmail(recipient: string) { 37 | try { 38 | const email = await readFixture('email', recipient) 39 | return emailSchema.parse(email) 40 | } catch (error) { 41 | console.error(`Error reading email`, error) 42 | return null 43 | } 44 | } 45 | 46 | export function requiredHeader(headers: Headers, header: string) { 47 | if (!headers.get(header)) { 48 | const headersString = JSON.stringify( 49 | Object.fromEntries(headers.entries()), 50 | null, 51 | 2, 52 | ) 53 | throw new Error( 54 | `Header "${header}" required, but not found in ${headersString}`, 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/routes/settings+/profile.two-factor.tsx: -------------------------------------------------------------------------------- 1 | import * as Dialog from '@radix-ui/react-dialog' 2 | import { json, type DataFunctionArgs } from '@remix-run/node' 3 | import { Link, Outlet, useNavigate } from '@remix-run/react' 4 | import { requireUserId } from '~/utils/auth.server.ts' 5 | import { prisma } from '~/utils/db.server.ts' 6 | 7 | export const twoFAVerificationType = '2fa' 8 | 9 | export async function loader({ request }: DataFunctionArgs) { 10 | const userId = await requireUserId(request) 11 | const verification = await prisma.verification.findFirst({ 12 | where: { type: twoFAVerificationType, target: userId }, 13 | select: { id: true }, 14 | }) 15 | return json({ is2FAEnabled: Boolean(verification) }) 16 | } 17 | 18 | export default function TwoFactorRoute() { 19 | const navigate = useNavigate() 20 | 21 | const dismissModal = () => navigate('..', { preventScrollReset: true }) 22 | return ( 23 | 24 | 25 | 26 | 31 | 32 |

Two-Factor Authentication

33 |
34 |
35 | 36 |
37 | 38 | 44 | ❌ 45 | 46 | 47 |
48 |
49 |
50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /app/routes/resources+/image-upload.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | json, 3 | unstable_createMemoryUploadHandler, 4 | unstable_parseMultipartFormData, 5 | type DataFunctionArgs, 6 | } from '@remix-run/node' 7 | import { useFetcher } from '@remix-run/react' 8 | import invariant from 'tiny-invariant' 9 | import { prisma } from '~/utils/db.server.ts' 10 | 11 | const MAX_SIZE = 1024 * 1024 * 5 // 5MB 12 | 13 | export async function action({ request }: DataFunctionArgs) { 14 | const contentLength = Number(request.headers.get('Content-Length')) 15 | if ( 16 | contentLength && 17 | Number.isFinite(contentLength) && 18 | contentLength > MAX_SIZE 19 | ) { 20 | return json({ errors: 'File too large' }, { status: 400 }) 21 | } 22 | const formData = await unstable_parseMultipartFormData( 23 | request, 24 | unstable_createMemoryUploadHandler({ maxPartSize: MAX_SIZE }), 25 | ) 26 | 27 | const file = formData.get('file') 28 | invariant(file instanceof File, 'file not the right type') 29 | const altText = formData.get('altText') 30 | const image = await prisma.image.create({ 31 | select: { fileId: true }, 32 | data: { 33 | contentType: file.type, 34 | altText: typeof altText === 'string' ? altText : undefined, 35 | file: { 36 | create: { 37 | blob: Buffer.from(await file.arrayBuffer()), 38 | }, 39 | }, 40 | }, 41 | }) 42 | 43 | return json({ fileId: image.fileId }) 44 | } 45 | 46 | export function ImageUpload() { 47 | const fetcher = useFetcher() 48 | 49 | return ( 50 | 55 | 56 | 57 | 58 | 59 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /app/routes/admin+/cache_.sqlite.tsx: -------------------------------------------------------------------------------- 1 | import type { DataFunctionArgs } from '@remix-run/node' 2 | import { json, redirect } from '@remix-run/node' 3 | import { z } from 'zod' 4 | import { getInstanceInfo, getInternalInstanceDomain } from 'litefs-js' 5 | import { cache } from '~/utils/cache.server.ts' 6 | 7 | export async function action({ request }: DataFunctionArgs) { 8 | const { currentIsPrimary, primaryInstance } = await getInstanceInfo() 9 | if (!currentIsPrimary) { 10 | throw new Error( 11 | `${request.url} should only be called on the primary instance (${primaryInstance})}`, 12 | ) 13 | } 14 | const token = process.env.INTERNAL_COMMAND_TOKEN 15 | const isAuthorized = 16 | request.headers.get('Authorization') === `Bearer ${token}` 17 | if (!isAuthorized) { 18 | // rick roll them 19 | return redirect('https://www.youtube.com/watch?v=dQw4w9WgXcQ') 20 | } 21 | const { key, cacheValue } = z 22 | .object({ key: z.string(), cacheValue: z.unknown().optional() }) 23 | .parse(await request.json()) 24 | if (cacheValue === undefined) { 25 | await cache.delete(key) 26 | } else { 27 | // @ts-expect-error - we don't reliably know the type of cacheValue 28 | await cache.set(key, cacheValue) 29 | } 30 | return json({ success: true }) 31 | } 32 | 33 | export async function updatePrimaryCacheValue({ 34 | key, 35 | cacheValue, 36 | }: { 37 | key: string 38 | cacheValue: any 39 | }) { 40 | const { currentIsPrimary, primaryInstance } = await getInstanceInfo() 41 | if (currentIsPrimary) { 42 | throw new Error( 43 | `updatePrimaryCacheValue should not be called on the primary instance (${primaryInstance})}`, 44 | ) 45 | } 46 | const domain = getInternalInstanceDomain(primaryInstance) 47 | const token = process.env.INTERNAL_COMMAND_TOKEN 48 | return fetch(`${domain}/admin/cache/sqlite`, { 49 | method: 'POST', 50 | headers: { 51 | Authorization: `Bearer ${token}`, 52 | 'Content-Type': 'application/json', 53 | }, 54 | body: JSON.stringify({ key, cacheValue }), 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /tests/e2e/2fa.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { expect, insertNewUser, test } from '../playwright-utils.ts' 3 | import { generateTOTP } from '~/utils/totp.server.ts' 4 | 5 | test('Users can add 2FA to their account and use it when logging in', async ({ 6 | login, 7 | page, 8 | }) => { 9 | const password = faker.internet.password() 10 | const user = await insertNewUser({ password }) 11 | await login(user) 12 | await page.goto('/settings/profile') 13 | 14 | await page.getByRole('link', { name: /enable 2fa/i }).click() 15 | 16 | await expect(page).toHaveURL(`/settings/profile/two-factor`) 17 | const dialog = page.getByRole('dialog') 18 | await dialog.getByRole('button', { name: /enable 2fa/i }).click() 19 | const otpUriString = await dialog 20 | .getByLabel(/One-Time Password URI/i) 21 | .innerText() 22 | 23 | const otpUri = new URL(otpUriString) 24 | const options = Object.fromEntries(otpUri.searchParams.entries()) 25 | 26 | await dialog 27 | .getByRole('textbox', { name: /code/i }) 28 | .fill(generateTOTP(options).otp) 29 | await dialog.getByRole('button', { name: /confirm/i }).click() 30 | 31 | await expect(dialog).toHaveText( 32 | /You have enabled two-factor authentication./i, 33 | ) 34 | await expect( 35 | dialog.getByRole('button', { name: /disable 2fa/i }), 36 | ).toBeVisible() 37 | 38 | await dialog.getByRole('link', { name: /close/i }).click() 39 | await page.getByRole('link', { name: user.name ?? user.username }).click() 40 | await page.getByRole('menuitem', { name: /logout/i }).click() 41 | 42 | await page.goto('/login') 43 | await expect(page).toHaveURL(`/login`) 44 | await page.getByRole('textbox', { name: /username/i }).fill(user.username) 45 | await page.getByLabel(/^password$/i).fill(password) 46 | await page.getByRole('button', { name: /log in/i }).click() 47 | 48 | await page 49 | .getByRole('textbox', { name: /code/i }) 50 | .fill(generateTOTP(options).otp) 51 | 52 | await page.getByRole('button', { name: /confirm/i }).click() 53 | 54 | await expect( 55 | page.getByRole('link', { name: user.name ?? user.username }), 56 | ).toBeVisible() 57 | }) 58 | -------------------------------------------------------------------------------- /app/routes/_auth+/login.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | json, 3 | type DataFunctionArgs, 4 | type V2_MetaFunction, 5 | } from '@remix-run/node' 6 | import { useLoaderData, useSearchParams } from '@remix-run/react' 7 | import { GeneralErrorBoundary } from '~/components/error-boundary.tsx' 8 | import { Spacer } from '~/components/spacer.tsx' 9 | import { authenticator, requireAnonymous } from '~/utils/auth.server.ts' 10 | import { commitSession, getSession } from '~/utils/session.server.ts' 11 | import { InlineLogin } from '../resources+/login.tsx' 12 | import { Verifier, unverifiedSessionKey } from '../resources+/verify.tsx' 13 | 14 | export async function loader({ request }: DataFunctionArgs) { 15 | await requireAnonymous(request) 16 | const session = await getSession(request.headers.get('cookie')) 17 | const error = session.get(authenticator.sessionErrorKey) 18 | let errorMessage: string | null = null 19 | if (typeof error?.message === 'string') { 20 | errorMessage = error.message 21 | } 22 | return json( 23 | { formError: errorMessage, unverified: session.has(unverifiedSessionKey) }, 24 | { 25 | headers: { 26 | 'Set-Cookie': await commitSession(session), 27 | }, 28 | }, 29 | ) 30 | } 31 | 32 | export const meta: V2_MetaFunction = () => { 33 | return [{ title: 'Login to Epic Notes' }] 34 | } 35 | 36 | export default function LoginPage() { 37 | const [searchParams] = useSearchParams() 38 | const data = useLoaderData() 39 | 40 | const redirectTo = searchParams.get('redirectTo') || '/' 41 | 42 | return ( 43 |
44 |
45 |
46 |

Welcome back!

47 |

48 | Please enter your details. 49 |

50 |
51 | 52 | {data.unverified ? ( 53 | 54 | ) : ( 55 | 56 | )} 57 |
58 |
59 | ) 60 | } 61 | 62 | export function ErrorBoundary() { 63 | return 64 | } 65 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/msw.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | LOGO 4 | 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # base node image 2 | FROM node:18-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 fuse3 openssl sqlite3 ca-certificates 9 | 10 | # Install all node_modules, including dev dependencies 11 | FROM base as deps 12 | 13 | WORKDIR /myapp 14 | 15 | ADD package.json package-lock.json .npmrc ./ 16 | RUN npm install --include=dev 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 package-lock.json .npmrc ./ 25 | RUN npm prune --omit=dev 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 FLY="true" 44 | ENV LITEFS_DIR="/litefs/data" 45 | ENV DATABASE_FILENAME="sqlite.db" 46 | ENV DATABASE_PATH="$LITEFS_DIR/$DATABASE_FILENAME" 47 | ENV DATABASE_URL="file:$DATABASE_PATH" 48 | ENV CACHE_DATABASE_FILENAME="cache.db" 49 | ENV CACHE_DATABASE_PATH="/$LITEFS_DIR/$CACHE_DATABASE_FILENAME" 50 | ENV INTERNAL_PORT="8080" 51 | ENV PORT="8081" 52 | ENV NODE_ENV="production" 53 | 54 | # add shortcut for connecting to database CLI 55 | RUN echo "#!/bin/sh\nset -x\nsqlite3 \$DATABASE_URL" > /usr/local/bin/database-cli && chmod +x /usr/local/bin/database-cli 56 | 57 | WORKDIR /myapp 58 | 59 | COPY --from=production-deps /myapp/node_modules /myapp/node_modules 60 | COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma 61 | 62 | COPY --from=build /myapp/server-build /myapp/server-build 63 | COPY --from=build /myapp/build /myapp/build 64 | COPY --from=build /myapp/public /myapp/public 65 | COPY --from=build /myapp/package.json /myapp/package.json 66 | COPY --from=build /myapp/prisma /myapp/prisma 67 | 68 | # prepare for litefs 69 | COPY --from=flyio/litefs:0.4.0 /usr/local/bin/litefs /usr/local/bin/litefs 70 | ADD other/litefs.yml /etc/litefs.yml 71 | RUN mkdir -p /data ${LITEFS_DIR} 72 | 73 | ADD . . 74 | 75 | CMD ["litefs", "mount"] 76 | -------------------------------------------------------------------------------- /app/models/note.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Prisma } from "@prisma/client"; 3 | import { PriorityEnum } from "types/priority.ts"; 4 | 5 | // We declare the properties for the "Enum" as a POJO (Plain Old Javascript object) and make it immutable with the as const type decleration. 6 | 7 | 8 | /* 9 | * The type ObjectValues ObjectValues is a type alias that is used to convert the values of an object into a union type. 10 | * It takes a generic parameter T, which represents the object type, and returns the union type of all the values in that object. 11 | */ 12 | type ObjectValues = T[keyof T]; 13 | type priorityEnum = ObjectValues; 14 | 15 | export type Note = { 16 | title: string; 17 | content: string; 18 | ownerId: string; 19 | priority: priorityEnum; 20 | } 21 | 22 | /* 23 | * We declare in the schema that hte priority is a zod enum and use the POJO PriorityEnum values to be validated. 24 | */ 25 | const noteSchema = z.object({ 26 | title: z.string().min(5).max(100), 27 | content: z.string().min(5).max(1500), 28 | priority: z.enum(Object.values(PriorityEnum) as [string, ...string[]]), 29 | ownerId: z.string(), 30 | }) satisfies z.Schema; 31 | 32 | /* 33 | * By defining this extension, you can ensure that the note entity data being created, updated, 34 | * or upserted adheres to a specific schema or validation rules defined in the noteSchema object. 35 | */ 36 | export const NoteValidation = Prisma.defineExtension({ 37 | query: { 38 | note: { 39 | create({ args, query }) { 40 | args.data = noteSchema.parse(args.data); 41 | return query(args); 42 | }, 43 | update ({ args, query }) { 44 | args.data = noteSchema.partial().parse(args.data); 45 | return query(args); 46 | }, 47 | updateMany ({ args, query }) { 48 | args.data = noteSchema.partial().parse(args.data); 49 | return query(args); 50 | }, 51 | upsert ({ args, query }) { 52 | args.create = noteSchema.parse(args.create); 53 | args.update = noteSchema.partial().parse(args.update); 54 | return query(args) 55 | }, 56 | } 57 | } 58 | }) -------------------------------------------------------------------------------- /tests/memoize-unique.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * based on https://github.com/faker-js/faker/issues/1785#issuecomment-1407773744 3 | * replace this code with memoize-unique when it is published 4 | */ 5 | 6 | export interface Store { 7 | [key: string]: T 8 | } 9 | 10 | export interface MemoizeUniqueOptions { 11 | /** 12 | * The time in milliseconds this method may take before throwing an error. 13 | * 14 | * @default 50 15 | */ 16 | maxTime?: number 17 | 18 | /** 19 | * The total number of attempts to try before throwing an error. 20 | * 21 | * @default 50 22 | */ 23 | maxRetries?: number 24 | 25 | /** 26 | * The value or values that should be excluded. 27 | * 28 | * If the callback returns one of these values, it will be called again and the internal retries counter will be incremented. 29 | * 30 | * @default [] 31 | */ 32 | exclude?: T | T[] 33 | 34 | /** 35 | * The store of already fetched results. 36 | * 37 | * @default {} 38 | */ 39 | readonly store?: Store 40 | } 41 | 42 | export type MemoizeUniqueReturn< 43 | T, 44 | U extends readonly any[] = readonly any[], 45 | > = (...args: [...U]) => T 46 | 47 | export function memoizeUnique( 48 | callback: MemoizeUniqueReturn, 49 | options: MemoizeUniqueOptions = {}, 50 | ): MemoizeUniqueReturn { 51 | const { maxTime = 50, maxRetries = 50, store = {} } = options 52 | let { exclude = [] } = options 53 | 54 | if (!Array.isArray(exclude)) { 55 | exclude = [exclude] 56 | } 57 | 58 | return (...args) => { 59 | let startTime = Date.now() 60 | let retries = 0 61 | 62 | let result: T 63 | 64 | do { 65 | if (Date.now() - startTime > maxTime) { 66 | throw new Error( 67 | `memoizeUnique: maxTime of ${maxTime}ms exceeded after ${retries} retries`, 68 | ) 69 | } 70 | 71 | if (retries > maxRetries) { 72 | throw new Error(`memoizeUnique: maxRetries of ${maxRetries} exceeded`) 73 | } 74 | 75 | retries++ 76 | 77 | result = callback(...args) 78 | 79 | if ((exclude as T[]).includes(result)) { 80 | continue 81 | } 82 | 83 | const key = JSON.stringify(args) + JSON.stringify(result) 84 | if (!store.hasOwnProperty(key)) { 85 | store[key] = result 86 | break 87 | } 88 | } while (true) 89 | 90 | return result 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/vitest.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/routes/_marketing+/index.tsx: -------------------------------------------------------------------------------- 1 | import type { V2_MetaFunction } from '@remix-run/node' 2 | import { kodyRocket, logos, stars } from './logos/logos.ts' 3 | 4 | export const meta: V2_MetaFunction = () => [{ title: 'Epic Notes' }] 5 | 6 | export default function Index() { 7 | return ( 8 |
9 |
10 |
11 |
12 |
13 | 14 |
15 |
16 |
17 |

18 | 22 | Epic Stack 23 | 24 |

25 |

26 | Check the{' '} 27 | 31 | Getting Started 32 | {' '} 33 | guide file for instructions on how to get your project off the 34 | ground! 35 |

36 | 37 | Illustration of a Koala riding a rocket 42 | 43 |
44 |
45 |
46 | 47 |
48 |
49 | {logos.map(img => ( 50 | 55 | {img.alt} 56 | 57 | ))} 58 |
59 |
60 |
61 |
62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /app/routes/users+/$username_+/notes.$noteId.tsx: -------------------------------------------------------------------------------- 1 | import { json, type DataFunctionArgs } from '@remix-run/node' 2 | import { useLoaderData } from '@remix-run/react' 3 | import { type PriorityEnum } from 'types/priority.ts' 4 | import { GeneralErrorBoundary } from '~/components/error-boundary.tsx' 5 | import { DeleteNote } from '~/routes/resources+/delete-note.tsx' 6 | import { getUserId } from '~/utils/auth.server.ts' 7 | import { prisma } from '~/utils/db.server.ts' 8 | import { ButtonLink } from '~/utils/forms.tsx' 9 | 10 | export async function loader({ request, params }: DataFunctionArgs) { 11 | const userId = await getUserId(request) 12 | const note = await prisma.note.findUnique({ 13 | where: { 14 | id: params.noteId, 15 | }, 16 | select: { 17 | id: true, 18 | title: true, 19 | content: true, 20 | ownerId: true, 21 | priority: true, 22 | }, 23 | }) 24 | if (!note) { 25 | throw new Response('Not found', { status: 404 }) 26 | } 27 | return json({ note, isOwner: userId === note.ownerId }) 28 | } 29 | 30 | export default function NoteRoute() { 31 | const data = useLoaderData() 32 | type ObjectValues = T[keyof T] 33 | type priority = ObjectValues 34 | // Even the type of the priority is checked with the enum 35 | // Try changeing on of the cases to something else and see what happens! 36 | function getColor(priority: priority) { 37 | switch(priority) { 38 | case `Important!`: 39 | return 'text-red-500'; 40 | case 'Moderate': 41 | return 'text-yellow-500'; 42 | case 'Low': 43 | return 'text-green-500'; 44 | case 'Backlog': 45 | return 'text-blue-500'; 46 | default: 47 | return 'text-gray-500'; 48 | } 49 | } 50 | 51 | return ( 52 |
53 |
54 |

{data.note.title}

55 |

{data.note.content}

56 |

{data.note.priority}

57 |
58 | {data.isOwner ? ( 59 |
60 | 61 | 62 | Edit 63 | 64 |
65 | ) : null} 66 |
67 | ) 68 | } 69 | 70 | export function ErrorBoundary() { 71 | return ( 72 |

Note not found

, 75 | }} 76 | /> 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /app/routes/resources+/delete-note.tsx: -------------------------------------------------------------------------------- 1 | import { json, type DataFunctionArgs, redirect } from '@remix-run/node' 2 | import { useFetcher } from '@remix-run/react' 3 | import { Button, ErrorList } from '~/utils/forms.tsx' 4 | import { useForm } from '@conform-to/react' 5 | import { getFieldsetConstraint, parse } from '@conform-to/zod' 6 | import { z } from 'zod' 7 | import { requireUserId } from '~/utils/auth.server.ts' 8 | import { prisma } from '~/utils/db.server.ts' 9 | 10 | const DeleteFormSchema = z.object({ 11 | noteId: z.string(), 12 | }) 13 | 14 | export async function action({ request }: DataFunctionArgs) { 15 | const userId = await requireUserId(request) 16 | const formData = await request.formData() 17 | const submission = parse(formData, { 18 | schema: DeleteFormSchema, 19 | acceptMultipleErrors: () => true, 20 | }) 21 | if (!submission.value || submission.intent !== 'submit') { 22 | return json( 23 | { 24 | status: 'error', 25 | submission, 26 | } as const, 27 | { status: 400 }, 28 | ) 29 | } 30 | 31 | const { noteId } = submission.value 32 | 33 | const note = await prisma.note.findFirst({ 34 | select: { id: true, owner: { select: { username: true } } }, 35 | where: { 36 | id: noteId, 37 | ownerId: userId, 38 | }, 39 | }) 40 | if (!note) { 41 | submission.error.noteId = ['Note not found'] 42 | return json({ status: 'error', submission } as const, { 43 | status: 404, 44 | }) 45 | } 46 | 47 | await prisma.note.delete({ 48 | where: { id: note.id }, 49 | }) 50 | 51 | return redirect(`/users/${note.owner.username}/notes`) 52 | } 53 | 54 | export function DeleteNote({ id }: { id: string }) { 55 | const noteDeleteFetcher = useFetcher() 56 | 57 | const [form] = useForm({ 58 | id: 'delete-note', 59 | constraint: getFieldsetConstraint(DeleteFormSchema), 60 | onValidate({ formData }) { 61 | return parse(formData, { schema: DeleteFormSchema }) 62 | }, 63 | }) 64 | 65 | return ( 66 | 71 | 72 | 85 | 86 | 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { Response, type HandleDocumentRequestFunction } from '@remix-run/node' 2 | import { RemixServer } from '@remix-run/react' 3 | import isbot from 'isbot' 4 | import { getInstanceInfo } from 'litefs-js' 5 | import { renderToPipeableStream } from 'react-dom/server' 6 | import { PassThrough } from 'stream' 7 | import { getEnv, init } from './utils/env.server.ts' 8 | import { NonceProvider } from './utils/nonce-provider.ts' 9 | 10 | const ABORT_DELAY = 5000 11 | 12 | init() 13 | global.ENV = getEnv() 14 | 15 | if (ENV.MODE === 'production' && ENV.SENTRY_DSN) { 16 | import('~/utils/monitoring.server.ts').then(({ init }) => init()) 17 | } 18 | 19 | type DocRequestArgs = Parameters 20 | 21 | export default async function handleRequest(...args: DocRequestArgs) { 22 | const [ 23 | request, 24 | responseStatusCode, 25 | responseHeaders, 26 | remixContext, 27 | loadContext, 28 | ] = args 29 | const { currentInstance, primaryInstance } = await getInstanceInfo() 30 | responseHeaders.set('fly-region', process.env.FLY_REGION ?? 'unknown') 31 | responseHeaders.set('fly-app', process.env.FLY_APP_NAME ?? 'unknown') 32 | responseHeaders.set('fly-primary-instance', primaryInstance) 33 | responseHeaders.set('fly-instance', currentInstance) 34 | 35 | const callbackName = isbot(request.headers.get('user-agent')) 36 | ? 'onAllReady' 37 | : 'onShellReady' 38 | 39 | const nonce = String(loadContext.cspNonce) ?? undefined 40 | return new Promise((resolve, reject) => { 41 | let didError = false 42 | 43 | const { pipe, abort } = renderToPipeableStream( 44 | 45 | 46 | , 47 | { 48 | [callbackName]: () => { 49 | const body = new PassThrough() 50 | responseHeaders.set('Content-Type', 'text/html') 51 | resolve( 52 | new Response(body, { 53 | headers: responseHeaders, 54 | status: didError ? 500 : responseStatusCode, 55 | }), 56 | ) 57 | pipe(body) 58 | }, 59 | onShellError: (err: unknown) => { 60 | reject(err) 61 | }, 62 | onError: (error: unknown) => { 63 | didError = true 64 | 65 | console.error(error) 66 | }, 67 | }, 68 | ) 69 | 70 | setTimeout(abort, ABORT_DELAY) 71 | }) 72 | } 73 | 74 | export async function handleDataRequest(response: Response) { 75 | const { currentInstance, primaryInstance } = await getInstanceInfo() 76 | response.headers.set('fly-region', process.env.FLY_REGION ?? 'unknown') 77 | response.headers.set('fly-app', process.env.FLY_APP_NAME ?? 'unknown') 78 | response.headers.set('fly-primary-instance', primaryInstance) 79 | response.headers.set('fly-instance', currentInstance) 80 | 81 | return response 82 | } 83 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/logos.ts: -------------------------------------------------------------------------------- 1 | import remix from './remix.png' 2 | import fly from './fly.svg' 3 | import sqlite from './sqlite.svg' 4 | import prisma from './prisma.svg' 5 | import zod from './zod.svg' 6 | import github from './github.svg' 7 | import mailgun from './mailgun.png' 8 | import tailwind from './tailwind.svg' 9 | import radixUI from './radix.svg' 10 | import playwright from './playwright.svg' 11 | import msw from './msw.svg' 12 | import fakerJS from './faker.svg' 13 | import vitest from './vitest.svg' 14 | import testingLibrary from './testing-library.png' 15 | import docker from './docker.png' 16 | import typescript from './typescript.svg' 17 | import prettier from './prettier.svg' 18 | import eslint from './eslint.svg' 19 | import sentry from './sentry.svg' 20 | 21 | export { default as stars } from './stars.jpg' 22 | 23 | export { default as kodyRocket } from './kody-rocket.png' 24 | 25 | export const logos = [ 26 | { 27 | src: remix, 28 | alt: 'Remix', 29 | href: 'https://remix.run', 30 | }, 31 | { 32 | src: fly, 33 | alt: 'Fly.io', 34 | href: 'https://fly.io', 35 | }, 36 | { 37 | src: sqlite, 38 | alt: 'SQLite', 39 | href: 'https://sqlite.org', 40 | }, 41 | { 42 | src: prisma, 43 | alt: 'Prisma', 44 | href: 'https://prisma.io', 45 | }, 46 | { 47 | src: zod, 48 | alt: 'Zod', 49 | href: 'https://zod.dev/', 50 | }, 51 | { 52 | src: github, 53 | alt: 'GitHub', 54 | href: 'https://github.com', 55 | }, 56 | { 57 | src: mailgun, 58 | alt: 'Mailgun', 59 | href: 'https://mailgun.com', 60 | }, 61 | { 62 | src: tailwind, 63 | alt: 'Tailwind', 64 | href: 'https://tailwindcss.com', 65 | }, 66 | { 67 | src: radixUI, 68 | alt: 'Radix UI', 69 | href: 'https://www.radix-ui.com/', 70 | }, 71 | { 72 | src: playwright, 73 | alt: 'Playwright', 74 | href: 'https://playwright.dev/', 75 | }, 76 | { 77 | src: msw, 78 | alt: 'MSW', 79 | href: 'https://mswjs.io', 80 | }, 81 | { 82 | src: fakerJS, 83 | alt: 'Faker.js', 84 | href: 'https://fakerjs.dev/', 85 | }, 86 | { 87 | src: vitest, 88 | alt: 'Vitest', 89 | href: 'https://vitest.dev', 90 | }, 91 | { 92 | src: testingLibrary, 93 | alt: 'Testing Library', 94 | href: 'https://testing-library.com', 95 | }, 96 | { 97 | src: docker, 98 | alt: 'Docker', 99 | href: 'https://www.docker.com', 100 | }, 101 | { 102 | src: typescript, 103 | alt: 'TypeScript', 104 | href: 'https://typescriptlang.org', 105 | }, 106 | { 107 | src: prettier, 108 | alt: 'Prettier', 109 | href: 'https://prettier.io', 110 | }, 111 | { 112 | src: eslint, 113 | alt: 'ESLint', 114 | href: 'https://eslint.org', 115 | }, 116 | { 117 | src: sentry, 118 | alt: 'Sentry', 119 | href: 'https://sentry.io', 120 | }, 121 | ] 122 | -------------------------------------------------------------------------------- /tests/setup/utils.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import Database from 'better-sqlite3' 3 | 4 | /** 5 | * Deletes all data from all tables in the database, (except for the 6 | * _prisma_migrations table). This function ensures that the tables are deleted 7 | * in the correct order to avoid foreign key constraint errors. 8 | * 9 | * Run this between tests to ensure that the database is in a clean state. This 10 | * will keep your tests isolated from each other. 11 | * 12 | * NOTE: It's better than deleting the database and recreating it because it 13 | * doesn't require you to re-run migrations. 14 | */ 15 | export function deleteAllData() { 16 | const excludedTables = ['_prisma_migrations'] 17 | const db = new Database(process.env.DATABASE_PATH) 18 | // Get a list of all tables in the database 19 | const allTableNamesRaw = db 20 | .prepare( 21 | "SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ($excludedTables)", 22 | ) 23 | .all({ 24 | excludedTables: excludedTables.join(','), 25 | }) 26 | const allTableNames = z 27 | .array(z.object({ name: z.string() })) 28 | .parse(allTableNamesRaw) 29 | .map(({ name }) => name) 30 | 31 | // Get a list of foreign key constraints for each table 32 | // p.s. thanks chatgpt... 33 | const foreignKeysRaw = db 34 | .prepare( 35 | /* sql */ ` 36 | SELECT 37 | m.name as table_name, 38 | p."table" as parent_table_name 39 | FROM 40 | sqlite_master AS m 41 | JOIN 42 | pragma_foreign_key_list(m.name) AS p 43 | WHERE 44 | m.type = 'table' AND 45 | m.name NOT IN ($excludedTables) 46 | `, 47 | ) 48 | .all({ 49 | excludedTables: excludedTables.join(','), 50 | }) 51 | const foreignKeys = z 52 | .array( 53 | z.object({ 54 | table_name: z.string(), 55 | parent_table_name: z.string(), 56 | }), 57 | ) 58 | .parse(foreignKeysRaw) 59 | 60 | // Build a dependency graph for the tables 61 | const graph: { [key: string]: Set } = {} 62 | for (const tableName of allTableNames) { 63 | graph[tableName] = new Set() 64 | } 65 | for (const { table_name, parent_table_name } of foreignKeys) { 66 | graph[parent_table_name].add(table_name) 67 | } 68 | 69 | // Topologically sort the tables 70 | const sortedTableNames: Array = [] 71 | const visited = new Set() 72 | const visit = (tableName: string) => { 73 | if (visited.has(tableName)) { 74 | return 75 | } 76 | visited.add(tableName) 77 | for (const dependentTableName of graph[tableName]) { 78 | visit(dependentTableName) 79 | } 80 | sortedTableNames.push(tableName) 81 | } 82 | for (const tableName of allTableNames) { 83 | visit(tableName) 84 | } 85 | 86 | // Delete all data in each table in the proper order 87 | for (const tableName of sortedTableNames) { 88 | db.prepare(`DELETE FROM ${tableName}`).run() 89 | } 90 | 91 | db.close() 92 | } 93 | -------------------------------------------------------------------------------- /app/utils/totp.server.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, vi, afterEach } from 'vitest' 2 | import * as totp from './totp.server.ts' 3 | import { faker } from '@faker-js/faker' 4 | 5 | afterEach(() => { 6 | vi.useRealTimers() 7 | }) 8 | 9 | test('OTP can be generated and verified', () => { 10 | const { secret, otp, algorithm, period } = totp.generateTOTP() 11 | const result = totp.verifyTOTP({ otp, secret }) 12 | expect(result).toEqual({ delta: 0 }) 13 | expect(algorithm).toBe('sha1') 14 | expect(period).toBe(30) 15 | }) 16 | 17 | test('Verify TOTP within the specified time window', () => { 18 | const { otp, secret } = totp.generateTOTP() 19 | const result = totp.verifyTOTP({ otp, secret, window: 0 }) 20 | expect(result).not.toBeNull() 21 | }) 22 | 23 | test('Fail to verify an invalid TOTP', () => { 24 | const secret = faker.string.alphanumeric() 25 | const tooShortNumber = faker.string.numeric({ length: 5 }) 26 | const result = totp.verifyTOTP({ otp: tooShortNumber, secret }) 27 | expect(result).toBeNull() 28 | }) 29 | 30 | test('Fail to verify TOTP outside the specified time window', () => { 31 | vi.useFakeTimers() 32 | const { otp, secret: key } = totp.generateTOTP() 33 | const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24) 34 | vi.setSystemTime(futureDate) 35 | const result = totp.verifyTOTP({ otp, secret: key }) 36 | expect(result).toBeNull() 37 | }) 38 | 39 | test('Clock drift is handled by window', () => { 40 | vi.useFakeTimers() 41 | const { otp, secret: key } = totp.generateTOTP({ period: 60 }) 42 | const futureDate = new Date(Date.now() + 1000 * 61) 43 | vi.setSystemTime(futureDate) 44 | const result = totp.verifyTOTP({ otp, secret: key, window: 1, period: 60 }) 45 | expect(result).toEqual({ delta: -1 }) 46 | }) 47 | 48 | test('Setting a different seconds config for generating and verifying will fail', () => { 49 | const desiredperiod = 60 50 | const { otp, secret, period } = totp.generateTOTP({ 51 | period: desiredperiod, 52 | }) 53 | expect(period).toBe(desiredperiod) 54 | const result = totp.verifyTOTP({ otp, secret, period: period + 1 }) 55 | expect(result).toBeNull() 56 | }) 57 | 58 | test('Setting a different algo config for generating and verifying will fail', () => { 59 | const desiredAlgo = 'sha512' 60 | const { otp, secret, algorithm } = totp.generateTOTP({ 61 | algorithm: desiredAlgo, 62 | }) 63 | expect(algorithm).toBe(desiredAlgo) 64 | const result = totp.verifyTOTP({ otp, secret, algorithm: 'sha1' }) 65 | expect(result).toBeNull() 66 | }) 67 | 68 | test('OTP Auth URI can be generated', () => { 69 | const { otp: _otp, secret, ...totpConfig } = totp.generateTOTP() 70 | const issuer = faker.company.name() 71 | const accountName = faker.internet.userName() 72 | const uri = totp.getTOTPAuthUri({ 73 | issuer, 74 | accountName, 75 | secret, 76 | ...totpConfig, 77 | }) 78 | expect(uri).toMatch(/^otpauth:\/\/totp\/(.*)\?/) 79 | }) 80 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | import defaultTheme from 'tailwindcss/defaultTheme.js' 3 | import tailwindcssRadix from 'tailwindcss-radix' 4 | 5 | export default { 6 | content: ['./app/**/*.{ts,tsx,jsx,js}'], 7 | darkMode: 'class', 8 | theme: { 9 | extend: { 10 | colors: { 11 | night: { 12 | 100: '#DADADA', 13 | 200: '#AAAAAA', 14 | 300: '#717171', 15 | 400: '#494949', 16 | 500: '#1E1E20', 17 | 600: '#141414', 18 | 700: '#090909', 19 | }, 20 | day: { 21 | 100: '#F7F5FF', 22 | 200: '#E4E4FB', 23 | 300: '#DDDDF4', 24 | 400: '#D0D0E8', 25 | 500: '#9696E0', 26 | 600: '#9999CC', 27 | 700: '#6A44FF', 28 | }, 29 | accent: { 30 | purple: '#6A44FF', 31 | pink: '#F183FF', 32 | yellow: '#FFBE3F', 33 | 'yellow-muted': '#FFD262', 34 | red: '#EF5A5A', 35 | }, 36 | }, 37 | fontFamily: { 38 | sans: [ 39 | 'Nunito Sans', 40 | 'Nunito Sans Fallback', 41 | ...defaultTheme.fontFamily.sans, 42 | ], 43 | }, 44 | fontSize: { 45 | // 1rem = 16px 46 | /** 80px size / 84px high / bold */ 47 | mega: ['5rem', { lineHeight: '5.25rem', fontWeight: '700' }], 48 | /** 56px size / 62px high / bold */ 49 | h1: ['3.5rem', { lineHeight: '3.875rem', fontWeight: '700' }], 50 | /** 40px size / 48px high / bold */ 51 | h2: ['2.5rem', { lineHeight: '3rem', fontWeight: '700' }], 52 | /** 32px size / 36px high / bold */ 53 | h3: ['2rem', { lineHeight: '2.25rem', fontWeight: '700' }], 54 | /** 28px size / 36px high / bold */ 55 | h4: ['1.75rem', { lineHeight: '2.25rem', fontWeight: '700' }], 56 | /** 24px size / 32px high / bold */ 57 | h5: ['1.5rem', { lineHeight: '2rem', fontWeight: '700' }], 58 | /** 16px size / 20px high / bold */ 59 | h6: ['1rem', { lineHeight: '1.25rem', fontWeight: '700' }], 60 | 61 | /** 32px size / 36px high / normal */ 62 | 'body-2xl': ['2rem', { lineHeight: '2.25rem' }], 63 | /** 28px size / 36px high / normal */ 64 | 'body-xl': ['1.75rem', { lineHeight: '2.25rem' }], 65 | /** 24px size / 32px high / normal */ 66 | 'body-lg': ['1.5rem', { lineHeight: '2rem' }], 67 | /** 20px size / 28px high / normal */ 68 | 'body-md': ['1.25rem', { lineHeight: '1.75rem' }], 69 | /** 16px size / 20px high / normal */ 70 | 'body-sm': ['1rem', { lineHeight: '1.25rem' }], 71 | /** 14px size / 18px high / normal */ 72 | 'body-xs': ['0.875rem', { lineHeight: '1.125rem' }], 73 | /** 12px size / 16px high / normal */ 74 | 'body-2xs': ['0.75rem', { lineHeight: '1rem' }], 75 | 76 | /** 18px size / 24px high / semibold */ 77 | caption: ['1.125rem', { lineHeight: '1.5rem', fontWeight: '600' }], 78 | /** 12px size / 16px high / bold */ 79 | button: ['0.75rem', { lineHeight: '1rem', fontWeight: '700' }], 80 | }, 81 | }, 82 | }, 83 | plugins: [tailwindcssRadix], 84 | } satisfies Config 85 | -------------------------------------------------------------------------------- /tests/e2e/settings-profile.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { expect, insertNewUser, test } from '../playwright-utils.ts' 3 | import { createUser } from '../../tests/db-utils.ts' 4 | import { verifyLogin } from '~/utils/auth.server.ts' 5 | 6 | test('Users can update their basic info', async ({ login, page }) => { 7 | await login() 8 | await page.goto('/settings/profile') 9 | 10 | const newUserData = createUser() 11 | 12 | await page.getByRole('textbox', { name: /^name/i }).fill(newUserData.name) 13 | await page 14 | .getByRole('textbox', { name: /^username/i }) 15 | .fill(newUserData.username) 16 | // TODO: support changing the email... probably test this in another test though 17 | // await page.getByRole('textbox', {name: /^email/i}).fill(newUserData.email) 18 | 19 | await page.getByRole('button', { name: /^save/i }).click() 20 | 21 | await expect(page).toHaveURL(`/users/${newUserData.username}`) 22 | }) 23 | 24 | test('Users can update their password', async ({ login, page }) => { 25 | const oldPassword = faker.internet.password() 26 | const newPassword = faker.internet.password() 27 | const user = await insertNewUser({ password: oldPassword }) 28 | await login(user) 29 | await page.goto('/settings/profile') 30 | 31 | const fieldset = page.getByRole('group', { name: /change password/i }) 32 | 33 | await fieldset 34 | .getByRole('textbox', { name: /^current password/i }) 35 | .fill(oldPassword) 36 | await fieldset 37 | .getByRole('textbox', { name: /^new password/i }) 38 | .fill(newPassword) 39 | 40 | await page.getByRole('button', { name: /^save/i }).click() 41 | 42 | await expect(page).toHaveURL(`/users/${user.username}`) 43 | 44 | expect( 45 | await verifyLogin(user.username, oldPassword), 46 | 'Old password still works', 47 | ).toEqual(null) 48 | expect( 49 | await verifyLogin(user.username, newPassword), 50 | 'New password does not work', 51 | ).toEqual({ id: user.id }) 52 | }) 53 | 54 | test('Users can update their profile photo', async ({ login, page }) => { 55 | const user = await login() 56 | await page.goto('/settings/profile') 57 | 58 | const beforeSrc = await page 59 | .getByAltText(user.name ?? user.username) 60 | .getAttribute('src') 61 | 62 | await page.getByRole('link', { name: /change profile photo/i }).click() 63 | 64 | await expect(page).toHaveURL(`/settings/profile/photo`) 65 | 66 | await page 67 | .getByRole('dialog', { name: /profile photo/i }) 68 | .getByLabel(/change/i) 69 | .setInputFiles('./tests/fixtures/test-profile.jpg') 70 | 71 | await page 72 | .getByRole('dialog', { name: /profile photo/i }) 73 | .getByRole('button', { name: /save/i }) 74 | .click() 75 | 76 | await expect( 77 | page, 78 | 'Was not redirected after saving the profile photo', 79 | ).toHaveURL(`/settings/profile`) 80 | 81 | const afterSrc = await page 82 | .getByAltText(user.name ?? user.username) 83 | .getAttribute('src') 84 | 85 | expect(beforeSrc).not.toEqual(afterSrc) 86 | }) 87 | -------------------------------------------------------------------------------- /app/utils/misc.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const DEFAULT_REDIRECT = '/' 4 | 5 | /** 6 | * This should be used any time the redirect path is user-provided 7 | * (Like the query string on our login/signup pages). This avoids 8 | * open-redirect vulnerabilities. 9 | * @param {string} to The redirect destination 10 | * @param {string} defaultRedirect The redirect to use if the to is unsafe. 11 | */ 12 | export function safeRedirect( 13 | to: FormDataEntryValue | string | null | undefined, 14 | defaultRedirect: string = DEFAULT_REDIRECT, 15 | ) { 16 | if (!to || typeof to !== 'string') { 17 | return defaultRedirect 18 | } 19 | 20 | if (!to.startsWith('/') || to.startsWith('//')) { 21 | return defaultRedirect 22 | } 23 | 24 | return to 25 | } 26 | 27 | export function getUserImgSrc(imageId?: string | null) { 28 | return imageId ? `/resources/file/${imageId}` : `/img/user.png` 29 | } 30 | 31 | export function getErrorMessage(error: unknown) { 32 | if (typeof error === 'string') return error 33 | if ( 34 | error && 35 | typeof error === 'object' && 36 | 'message' in error && 37 | typeof error.message === 'string' 38 | ) { 39 | return error.message 40 | } 41 | console.error('Unable to get error message for error', error) 42 | return 'Unknown Error' 43 | } 44 | 45 | function debounce) => void>( 46 | fn: Callback, 47 | delay: number, 48 | ) { 49 | let timer: ReturnType | null = null 50 | return (...args: Parameters) => { 51 | if (timer) clearTimeout(timer) 52 | timer = setTimeout(() => { 53 | fn(...args) 54 | }, delay) 55 | } 56 | } 57 | 58 | export function useDebounce< 59 | Callback extends (...args: Parameters) => ReturnType, 60 | >(callback: Callback, delay: number) { 61 | const callbackRef = React.useRef(callback) 62 | React.useEffect(() => { 63 | callbackRef.current = callback 64 | }) 65 | return React.useMemo( 66 | () => 67 | debounce( 68 | (...args: Parameters) => callbackRef.current(...args), 69 | delay, 70 | ), 71 | [delay], 72 | ) 73 | } 74 | 75 | function callAll>( 76 | ...fns: Array<((...args: Args) => unknown) | undefined> 77 | ) { 78 | return (...args: Args) => fns.forEach(fn => fn?.(...args)) 79 | } 80 | 81 | export function useDoubleCheck() { 82 | const [doubleCheck, setDoubleCheck] = React.useState(false) 83 | 84 | function getButtonProps(props?: JSX.IntrinsicElements['button']) { 85 | const onBlur: JSX.IntrinsicElements['button']['onBlur'] = () => 86 | setDoubleCheck(false) 87 | 88 | const onClick: JSX.IntrinsicElements['button']['onClick'] = doubleCheck 89 | ? undefined 90 | : e => { 91 | e.preventDefault() 92 | setDoubleCheck(true) 93 | } 94 | 95 | return { 96 | ...props, 97 | onBlur: callAll(onBlur, props?.onBlur), 98 | onClick: callAll(onClick, props?.onClick), 99 | } 100 | } 101 | 102 | return { doubleCheck, getButtonProps } 103 | } 104 | -------------------------------------------------------------------------------- /tests/playwright-utils.ts: -------------------------------------------------------------------------------- 1 | import { test as base, type Page } from '@playwright/test' 2 | import { parse } from 'cookie' 3 | import { authenticator, getPasswordHash } from '~/utils/auth.server.ts' 4 | import { prisma } from '~/utils/db.server.ts' 5 | import { commitSession, getSession } from '~/utils/session.server.ts' 6 | import { createUser } from '../tests/db-utils.ts' 7 | 8 | export const dataCleanup = { 9 | users: new Set(), 10 | } 11 | 12 | export function deleteUserByUsername(username: string) { 13 | return prisma.user.delete({ where: { username } }) 14 | } 15 | 16 | export async function insertNewUser({ password }: { password?: string } = {}) { 17 | const userData = createUser() 18 | const user = await prisma.user.create({ 19 | data: { 20 | ...userData, 21 | password: { 22 | create: { 23 | hash: await getPasswordHash(password || userData.username), 24 | }, 25 | }, 26 | }, 27 | select: { id: true, name: true, username: true, email: true }, 28 | }) 29 | dataCleanup.users.add(user.id) 30 | return user 31 | } 32 | 33 | export const test = base.extend<{ 34 | login: (user?: { id: string }) => ReturnType 35 | }>({ 36 | login: [ 37 | async ({ page, baseURL }, use) => { 38 | use(user => loginPage({ page, baseURL, user })) 39 | }, 40 | { auto: true }, 41 | ], 42 | }) 43 | 44 | export const { expect } = test 45 | 46 | export async function loginPage({ 47 | page, 48 | baseURL = `http://localhost:${process.env.PORT}/`, 49 | user: givenUser, 50 | }: { 51 | page: Page 52 | baseURL: string | undefined 53 | user?: { id: string } 54 | }) { 55 | const user = givenUser 56 | ? await prisma.user.findUniqueOrThrow({ 57 | where: { id: givenUser.id }, 58 | select: { 59 | id: true, 60 | email: true, 61 | username: true, 62 | name: true, 63 | }, 64 | }) 65 | : await insertNewUser() 66 | const session = await prisma.session.create({ 67 | data: { 68 | expirationDate: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), 69 | userId: user.id, 70 | }, 71 | select: { id: true }, 72 | }) 73 | 74 | const cookieSession = await getSession() 75 | cookieSession.set(authenticator.sessionKey, session.id) 76 | const cookieValue = await commitSession(cookieSession) 77 | const { _session } = parse(cookieValue) 78 | await page.context().addCookies([ 79 | { 80 | name: '_session', 81 | sameSite: 'Lax', 82 | url: baseURL, 83 | httpOnly: true, 84 | secure: process.env.NODE_ENV === 'production', 85 | value: _session, 86 | }, 87 | ]) 88 | return user 89 | } 90 | 91 | test.afterEach(async () => { 92 | type Delegate = { 93 | deleteMany: (opts: { 94 | where: { id: { in: Array } } 95 | }) => Promise 96 | } 97 | async function deleteAll(items: Set, delegate: Delegate) { 98 | if (items.size > 0) { 99 | await delegate.deleteMany({ 100 | where: { id: { in: [...items] } }, 101 | }) 102 | } 103 | } 104 | await deleteAll(dataCleanup.users, prisma.user) 105 | }) 106 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/tailwind.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "sqlite" 3 | url = env("DATABASE_URL") 4 | } 5 | 6 | // Added previewFeature to the client 7 | generator client { 8 | provider = "prisma-client-js" 9 | previewFeatures = ["clientExtensions"] 10 | } 11 | 12 | model File { 13 | id String @id @unique @default(cuid()) 14 | blob Bytes 15 | 16 | createdAt DateTime @default(now()) 17 | updatedAt DateTime @updatedAt 18 | image Image? 19 | } 20 | 21 | model Image { 22 | file File @relation(fields: [fileId], references: [id], onDelete: Cascade, onUpdate: Cascade) 23 | fileId String @unique 24 | 25 | contentType String 26 | altText String? 27 | 28 | createdAt DateTime @default(now()) 29 | updatedAt DateTime @updatedAt 30 | 31 | user User? 32 | } 33 | 34 | model Role { 35 | id String @id @unique @default(cuid()) 36 | name String @unique 37 | 38 | createdAt DateTime @default(now()) 39 | updatedAt DateTime @updatedAt 40 | 41 | users User[] 42 | permissions Permission[] 43 | } 44 | 45 | model Permission { 46 | id String @id @unique @default(cuid()) 47 | name String @unique 48 | 49 | createdAt DateTime @default(now()) 50 | updatedAt DateTime @updatedAt 51 | 52 | roles Role[] 53 | } 54 | 55 | model User { 56 | id String @id @unique @default(cuid()) 57 | email String @unique 58 | username String @unique 59 | name String? 60 | 61 | createdAt DateTime @default(now()) 62 | updatedAt DateTime @updatedAt 63 | 64 | image Image? @relation(fields: [imageId], references: [fileId]) 65 | imageId String? @unique 66 | 67 | password Password? 68 | notes Note[] 69 | roles Role[] 70 | session Session[] 71 | } 72 | 73 | model Password { 74 | hash String 75 | 76 | user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) 77 | userId String @unique 78 | } 79 | 80 | model Verification { 81 | id String @id @default(cuid()) 82 | createdAt DateTime @default(now()) 83 | 84 | /// The type of verification, e.g. "email" or "phone" 85 | type String 86 | 87 | /// The thing we're trying to verify, e.g. a user's email or phone number 88 | target String 89 | 90 | /// The secret key used to generate the otp 91 | secret String 92 | 93 | /// The algorithm used to generate the otp 94 | algorithm String 95 | 96 | /// The number of digits in the otp 97 | digits Int 98 | 99 | /// The number of seconds the otp is valid for 100 | period Int 101 | 102 | /// When it's safe to delete this verification 103 | expiresAt DateTime? 104 | 105 | @@unique([target, type]) 106 | } 107 | 108 | model Session { 109 | id String @id @default(cuid()) 110 | createdAt DateTime @default(now()) 111 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 112 | userId String 113 | expirationDate DateTime 114 | } 115 | 116 | model Note { 117 | id String @id @unique @default(cuid()) 118 | title String 119 | content String 120 | priority String 121 | createdAt DateTime @default(now()) 122 | updatedAt DateTime @updatedAt 123 | 124 | owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade, onUpdate: Cascade) 125 | ownerId String 126 | } 127 | -------------------------------------------------------------------------------- /app/utils/timing.server.ts: -------------------------------------------------------------------------------- 1 | import { type CreateReporter } from 'cachified' 2 | 3 | export type Timings = Record< 4 | string, 5 | Array< 6 | { desc?: string } & ( 7 | | { time: number; start?: never } 8 | | { time?: never; start: number } 9 | ) 10 | > 11 | > 12 | 13 | export function makeTimings(type: string, desc?: string) { 14 | const timings: Timings = { 15 | [type]: [{ desc, start: performance.now() }], 16 | } 17 | Object.defineProperty(timings, 'toString', { 18 | value: function () { 19 | return getServerTimeHeader(timings) 20 | }, 21 | enumerable: false, 22 | }) 23 | return timings 24 | } 25 | 26 | function createTimer(type: string, desc?: string) { 27 | const start = performance.now() 28 | return { 29 | end(timings: Timings) { 30 | let timingType = timings[type] 31 | 32 | if (!timingType) { 33 | // eslint-disable-next-line no-multi-assign 34 | timingType = timings[type] = [] 35 | } 36 | timingType.push({ desc, time: performance.now() - start }) 37 | }, 38 | } 39 | } 40 | 41 | export async function time( 42 | fn: Promise | (() => ReturnType | Promise), 43 | { 44 | type, 45 | desc, 46 | timings, 47 | }: { 48 | type: string 49 | desc?: string 50 | timings?: Timings 51 | }, 52 | ): Promise { 53 | const timer = createTimer(type, desc) 54 | const promise = typeof fn === 'function' ? fn() : fn 55 | if (!timings) return promise 56 | 57 | const result = await promise 58 | 59 | timer.end(timings) 60 | return result 61 | } 62 | 63 | export function getServerTimeHeader(timings?: Timings) { 64 | if (!timings) return '' 65 | return Object.entries(timings) 66 | .map(([key, timingInfos]) => { 67 | const dur = timingInfos 68 | .reduce((acc, timingInfo) => { 69 | const time = timingInfo.time ?? performance.now() - timingInfo.start 70 | return acc + time 71 | }, 0) 72 | .toFixed(1) 73 | const desc = timingInfos 74 | .map(t => t.desc) 75 | .filter(Boolean) 76 | .join(' & ') 77 | return [ 78 | key.replaceAll(/(:| |@|=|;|,|\/|\\)/g, '_'), 79 | desc ? `desc=${JSON.stringify(desc)}` : null, 80 | `dur=${dur}`, 81 | ] 82 | .filter(Boolean) 83 | .join(';') 84 | }) 85 | .join(',') 86 | } 87 | 88 | export function combineServerTimings(headers1: Headers, headers2: Headers) { 89 | const newHeaders = new Headers(headers1) 90 | newHeaders.append('Server-Timing', headers2.get('Server-Timing') ?? '') 91 | return newHeaders.get('Server-Timing') ?? '' 92 | } 93 | 94 | export function cachifiedTimingReporter( 95 | timings?: Timings, 96 | ): undefined | CreateReporter { 97 | if (!timings) return 98 | 99 | return ({ key }) => { 100 | const cacheRetrievalTimer = createTimer( 101 | `cache:${key}`, 102 | `${key} cache retrieval`, 103 | ) 104 | let getFreshValueTimer: ReturnType | undefined 105 | return event => { 106 | switch (event.name) { 107 | case 'getFreshValueStart': 108 | getFreshValueTimer = createTimer( 109 | `getFreshValue:${key}`, 110 | `request forced to wait for a fresh ${key} value`, 111 | ) 112 | break 113 | case 'getFreshValueSuccess': 114 | getFreshValueTimer?.end(timings) 115 | break 116 | case 'done': 117 | cacheRetrievalTimer.end(timings) 118 | break 119 | } 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /app/routes/settings+/profile.two-factor.index.tsx: -------------------------------------------------------------------------------- 1 | import { json, redirect, type DataFunctionArgs } from '@remix-run/node' 2 | import { useFetcher, useLoaderData } from '@remix-run/react' 3 | import { requireUserId } from '~/utils/auth.server.ts' 4 | import { prisma } from '~/utils/db.server.ts' 5 | import { Button } from '~/utils/forms.tsx' 6 | import { generateTOTP } from '~/utils/totp.server.ts' 7 | import { verificationType as verifyVerificationType } from './profile.two-factor.verify.tsx' 8 | import { twoFAVerificationType } from './profile.two-factor.tsx' 9 | 10 | export async function loader({ request }: DataFunctionArgs) { 11 | const userId = await requireUserId(request) 12 | const verification = await prisma.verification.findFirst({ 13 | where: { type: twoFAVerificationType, target: userId }, 14 | select: { id: true }, 15 | }) 16 | return json({ is2FAEnabled: Boolean(verification) }) 17 | } 18 | 19 | export async function action({ request }: DataFunctionArgs) { 20 | const form = await request.formData() 21 | const userId = await requireUserId(request) 22 | const intent = form.get('intent') 23 | switch (intent) { 24 | case 'enable': { 25 | const { otp: _otp, ...config } = generateTOTP() 26 | // delete any existing entries 27 | await prisma.verification.deleteMany({ 28 | where: { type: verifyVerificationType, target: userId }, 29 | }) 30 | await prisma.verification.create({ 31 | data: { ...config, type: verifyVerificationType, target: userId }, 32 | }) 33 | return redirect('/settings/profile/two-factor/verify') 34 | } 35 | case 'disable': { 36 | await prisma.verification.deleteMany({ 37 | where: { type: twoFAVerificationType, target: userId }, 38 | }) 39 | break 40 | } 41 | default: { 42 | return json({ status: 'error', message: 'Invalid intent' } as const) 43 | } 44 | } 45 | return json({ status: 'success' } as const) 46 | } 47 | 48 | export default function TwoFactorRoute() { 49 | const data = useLoaderData() 50 | const toggle2FAFetcher = useFetcher() 51 | 52 | return ( 53 |
54 | {data.is2FAEnabled ? ( 55 | <> 56 |

You have enabled two-factor authentication.

57 | 58 | 69 | 70 | 71 | ) : ( 72 | <> 73 |

You have not enabled two-factor authentication yet.

74 |

75 | Two factor authentication adds an extra layer of security to your 76 | account. You will need to enter a code from an authenticator app 77 | like 1Password to log in. 78 |

79 | 80 | 91 | 92 | 93 | )} 94 |
95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /app/routes/users+/$username.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | json, 3 | type DataFunctionArgs, 4 | type V2_MetaFunction, 5 | } from '@remix-run/node' 6 | import { useLoaderData } from '@remix-run/react' 7 | import invariant from 'tiny-invariant' 8 | import { GeneralErrorBoundary } from '~/components/error-boundary.tsx' 9 | import { Spacer } from '~/components/spacer.tsx' 10 | import { prisma } from '~/utils/db.server.ts' 11 | import { ButtonLink } from '~/utils/forms.tsx' 12 | import { getUserImgSrc } from '~/utils/misc.ts' 13 | import { useOptionalUser } from '~/utils/user.ts' 14 | 15 | export async function loader({ params }: DataFunctionArgs) { 16 | invariant(params.username, 'Missing username') 17 | const user = await prisma.user.findUnique({ 18 | where: { username: params.username }, 19 | select: { 20 | id: true, 21 | username: true, 22 | name: true, 23 | imageId: true, 24 | createdAt: true, 25 | }, 26 | }) 27 | if (!user) { 28 | throw new Response('not found', { status: 404 }) 29 | } 30 | return json({ user, userJoinedDisplay: user.createdAt.toLocaleDateString() }) 31 | } 32 | 33 | export default function UsernameIndex() { 34 | const data = useLoaderData() 35 | const user = data.user 36 | const userDisplayName = user.name ?? user.username 37 | const loggedInUser = useOptionalUser() 38 | const isLoggedInUser = data.user.id === loggedInUser?.id 39 | 40 | return ( 41 |
42 | 43 | 44 |
45 |
46 |
47 |
48 | {userDisplayName} 53 |
54 |
55 |
56 | 57 | 58 | 59 |
60 |
61 |

{userDisplayName}

62 |
63 |

64 | Joined {data.userJoinedDisplay} 65 |

66 |
67 | {isLoggedInUser ? ( 68 | <> 69 | 75 | My notes 76 | 77 | 83 | Edit profile 84 | 85 | 86 | ) : ( 87 | 93 | {userDisplayName}'s notes 94 | 95 | )} 96 |
97 |
98 |
99 |
100 | ) 101 | } 102 | 103 | export function ErrorBoundary() { 104 | return ( 105 | ( 108 |

No user with the username "{params.username}" exists

109 | ), 110 | }} 111 | /> 112 | ) 113 | } 114 | 115 | export const meta: V2_MetaFunction = ({ data, params }) => { 116 | const displayName = data?.user.name ?? params.username 117 | return [ 118 | { title: `${displayName} | Epic Notes` }, 119 | { 120 | name: 'description', 121 | content: `${displayName} on Epic Notes is not a host or renter yet.`, 122 | }, 123 | ] 124 | } 125 | -------------------------------------------------------------------------------- /app/routes/users+/$username_+/notes.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | json, 3 | type DataFunctionArgs, 4 | type HeadersFunction, 5 | } from '@remix-run/node' 6 | import { Link, NavLink, Outlet, useLoaderData } from '@remix-run/react' 7 | import { twMerge } from 'tailwind-merge' 8 | import { GeneralErrorBoundary } from '~/components/error-boundary.tsx' 9 | import { prisma } from '~/utils/db.server.ts' 10 | import { getUserImgSrc } from '~/utils/misc.ts' 11 | import { 12 | combineServerTimings, 13 | makeTimings, 14 | time, 15 | } from '~/utils/timing.server.ts' 16 | 17 | export async function loader({ params }: DataFunctionArgs) { 18 | const timings = makeTimings('notes loader') 19 | const owner = await time( 20 | () => 21 | prisma.user.findUnique({ 22 | where: { 23 | username: params.username, 24 | }, 25 | select: { 26 | id: true, 27 | username: true, 28 | name: true, 29 | imageId: true, 30 | }, 31 | }), 32 | { timings, type: 'find user' }, 33 | ) 34 | if (!owner) { 35 | throw new Response('Not found', { status: 404 }) 36 | } 37 | const notes = await time( 38 | () => 39 | prisma.note.findMany({ 40 | where: { 41 | ownerId: owner.id, 42 | }, 43 | select: { 44 | id: true, 45 | title: true, 46 | priority: true, 47 | }, 48 | }), 49 | { timings, type: 'find notes' }, 50 | ) 51 | return json( 52 | { owner, notes }, 53 | { headers: { 'Server-Timing': timings.toString() } }, 54 | ) 55 | } 56 | 57 | export const headers: HeadersFunction = ({ loaderHeaders, parentHeaders }) => { 58 | return { 59 | 'Server-Timing': combineServerTimings(parentHeaders, loaderHeaders), 60 | } 61 | } 62 | 63 | export default function NotesRoute() { 64 | const data = useLoaderData() 65 | const ownerDisplayName = data.owner.name ?? data.owner.username 66 | const navLinkDefaultClassName = 67 | 'line-clamp-2 block rounded-l-full py-2 pl-8 pr-6 text-base lg:text-xl' 68 | return ( 69 |
70 |
71 |
72 | 76 | {ownerDisplayName} 81 |

82 | {ownerDisplayName}'s Notes 83 |

84 | 85 |
    86 |
  • 87 | 90 | twMerge(navLinkDefaultClassName, isActive && 'bg-night-400') 91 | } 92 | > 93 | + New Note 94 | 95 |
  • 96 | {data.notes.map(note => ( 97 |
  • 98 | 101 | twMerge(navLinkDefaultClassName, isActive && 'bg-night-400') 102 | } 103 | > 104 | {note.title} 105 | 106 |
  • 107 | ))} 108 |
109 |
110 |
111 | 112 |
113 |
114 |
115 | ) 116 | } 117 | 118 | export function ErrorBoundary() { 119 | return ( 120 | ( 123 |

No user with the username "{params.username}" exists

124 | ), 125 | }} 126 | /> 127 | ) 128 | } 129 | -------------------------------------------------------------------------------- /app/routes/resources+/theme.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from '@conform-to/react' 2 | import { parse } from '@conform-to/zod' 3 | import { json, redirect, type DataFunctionArgs } from '@remix-run/node' 4 | import { useFetcher } from '@remix-run/react' 5 | import * as React from 'react' 6 | import { z } from 'zod' 7 | import { useHints } from '~/utils/client-hints.tsx' 8 | import { ErrorList } from '~/utils/forms.tsx' 9 | import { safeRedirect } from '~/utils/misc.ts' 10 | import { useRequestInfo } from '~/utils/request-info.ts' 11 | import { 12 | commitSession, 13 | deleteTheme, 14 | getSession, 15 | setTheme, 16 | } from '~/utils/session.server.ts' 17 | 18 | const ROUTE_PATH = '/resources/theme' 19 | 20 | const ThemeFormSchema = z.object({ 21 | redirectTo: z.string().optional(), 22 | theme: z.enum(['system', 'light', 'dark']), 23 | }) 24 | 25 | export async function action({ request }: DataFunctionArgs) { 26 | const formData = await request.formData() 27 | const submission = parse(formData, { 28 | schema: ThemeFormSchema, 29 | acceptMultipleErrors: () => true, 30 | }) 31 | if (!submission.value) { 32 | return json( 33 | { 34 | status: 'error', 35 | submission, 36 | } as const, 37 | { status: 400 }, 38 | ) 39 | } 40 | if (submission.intent !== 'submit') { 41 | return json({ status: 'success', submission } as const) 42 | } 43 | const session = await getSession(request.headers.get('cookie')) 44 | const { redirectTo, theme } = submission.value 45 | if (theme === 'system') { 46 | deleteTheme(session) 47 | } else { 48 | setTheme(session, theme) 49 | } 50 | 51 | const responseInit = { 52 | headers: { 'Set-Cookie': await commitSession(session) }, 53 | } 54 | if (redirectTo) { 55 | return redirect(safeRedirect(redirectTo), responseInit) 56 | } else { 57 | return json({ success: true }, responseInit) 58 | } 59 | } 60 | 61 | export function ThemeSwitch({ 62 | userPreference, 63 | }: { 64 | userPreference: 'light' | 'dark' | null 65 | }) { 66 | const requestInfo = useRequestInfo() 67 | const fetcher = useFetcher() 68 | const [isHydrated, setIsHydrated] = React.useState(false) 69 | 70 | React.useEffect(() => { 71 | setIsHydrated(true) 72 | }, []) 73 | 74 | const [form] = useForm({ 75 | id: 'onboarding', 76 | lastSubmission: fetcher.data?.submission, 77 | onValidate({ formData }) { 78 | return parse(formData, { schema: ThemeFormSchema }) 79 | }, 80 | }) 81 | 82 | const mode = userPreference ?? 'system' 83 | const nextMode = 84 | mode === 'system' ? 'light' : mode === 'light' ? 'dark' : 'system' 85 | const modeLabel = { 86 | light: ( 87 | <> 88 | 🔆 Light 89 | 90 | ), 91 | dark: ( 92 | <> 93 | 🌕 Dark 94 | 95 | ), 96 | system: ( 97 | <> 98 | 💻 System 99 | 100 | ), 101 | } 102 | 103 | return ( 104 | 105 |
106 | {/* 107 | this is for progressive enhancement so we redirect them to the page 108 | they are on if the JavaScript hasn't had a chance to hydrate yet. 109 | */} 110 | {isHydrated ? null : ( 111 | 112 | )} 113 | 114 | 117 |
118 | 119 |
120 | ) 121 | } 122 | 123 | /** 124 | * @returns the user's theme preference, or the client hint theme if the user 125 | * has not set a preference. 126 | */ 127 | export function useTheme() { 128 | const hints = useHints() 129 | const requestInfo = useRequestInfo() 130 | return requestInfo.session.theme ?? hints.theme 131 | } 132 | -------------------------------------------------------------------------------- /app/utils/client-hints.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains utilities for using client hints for user preference which 3 | * are needed by the server, but are only known by the browser. 4 | */ 5 | import * as React from 'react' 6 | import { useRequestInfo } from './request-info.ts' 7 | import { useRevalidator } from '@remix-run/react' 8 | 9 | export const clientHints = { 10 | theme: { 11 | cookieName: 'CH-prefers-color-scheme', 12 | getValueCode: `window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'`, 13 | fallback: 'light', 14 | transform(value: string | null) { 15 | return value === 'dark' ? 'dark' : 'light' 16 | }, 17 | }, 18 | // add other hints here 19 | } 20 | 21 | type ClientHintNames = keyof typeof clientHints 22 | 23 | function getCookieValue(cookieString: string, name: ClientHintNames) { 24 | const hint = clientHints[name] 25 | if (!hint) { 26 | throw new Error(`Unknown client hint: ${name}`) 27 | } 28 | const value = cookieString 29 | .split(';') 30 | .map(c => c.trim()) 31 | .find(c => c.startsWith(hint.cookieName + '=')) 32 | ?.split('=')[1] 33 | 34 | return value ?? null 35 | } 36 | 37 | /** 38 | * 39 | * @param request {Request} - optional request object (only used on server) 40 | * @returns an object with the client hints and their values 41 | */ 42 | export function getHints(request?: Request) { 43 | const cookieString = 44 | typeof document !== 'undefined' 45 | ? document.cookie 46 | : typeof request !== 'undefined' 47 | ? request.headers.get('Cookie') ?? '' 48 | : '' 49 | 50 | return Object.entries(clientHints).reduce( 51 | (acc, [name, hint]) => { 52 | const hintName = name as ClientHintNames 53 | // using ignore because it's not an issue with only one hint, but will 54 | // be with more than one... 55 | // @ts-ignore PR to improve these types is welcome 56 | acc[hintName] = hint.transform(getCookieValue(cookieString, hintName)) 57 | return acc 58 | }, 59 | {} as { 60 | [name in ClientHintNames]: ReturnType< 61 | (typeof clientHints)[name]['transform'] 62 | > 63 | }, 64 | ) 65 | } 66 | 67 | /** 68 | * @returns an object with the client hints and their values 69 | */ 70 | export function useHints() { 71 | const requestInfo = useRequestInfo() 72 | return requestInfo.hints 73 | } 74 | 75 | /** 76 | * @returns inline script element that checks for client hints and sets cookies 77 | * if they are not set then reloads the page if any cookie was set to an 78 | * inaccurate value. 79 | */ 80 | export function ClientHintCheck({ nonce }: { nonce: string }) { 81 | const { revalidate } = useRevalidator() 82 | React.useEffect(() => { 83 | const themeQuery = window.matchMedia('(prefers-color-scheme: dark)') 84 | function handleThemeChange() { 85 | document.cookie = `${clientHints.theme.cookieName}=${ 86 | themeQuery.matches ? 'dark' : 'light' 87 | }` 88 | revalidate() 89 | } 90 | themeQuery.addEventListener('change', handleThemeChange) 91 | return () => { 92 | themeQuery.removeEventListener('change', handleThemeChange) 93 | } 94 | }, [revalidate]) 95 | 96 | return ( 97 |