diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..d117323
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,14 @@
+LITEFS_DIR="/litefs/data"
+DATABASE_PATH="./prisma/data.db"
+DATABASE_URL="file:./data.db?connection_limit=1"
+CACHE_DATABASE_PATH="./other/cache.db"
+SESSION_SECRET="super-duper-s3cret"
+INTERNAL_COMMAND_TOKEN="some-made-up-token"
+RESEND_API_KEY="re_blAh_blaHBlaHblahBLAhBlAh"
+SENTRY_DSN="your-dsn"
+
+# the mocks and some code rely on these two being prefixed with "MOCK_"
+# if they aren't then the real github api will be attempted
+GITHUB_CLIENT_ID="MOCK_GITHUB_CLIENT_ID"
+GITHUB_CLIENT_SECRET="MOCK_GITHUB_CLIENT_SECRET"
+GITHUB_TOKEN="MOCK_GITHUB_TOKEN"
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
new file mode 100644
index 0000000..5dae40c
--- /dev/null
+++ b/.eslintrc.cjs
@@ -0,0 +1,85 @@
+const vitestFiles = ['app/**/__tests__/**/*', 'app/**/*.{spec,test}.*']
+const testFiles = ['**/tests/**', ...vitestFiles]
+const appFiles = ['app/**']
+
+/** @type {import('@types/eslint').Linter.BaseConfig} */
+module.exports = {
+ extends: [
+ '@remix-run/eslint-config',
+ '@remix-run/eslint-config/node',
+ 'prettier',
+ ],
+ rules: {
+ // playwright requires destructuring in fixtures even if you don't use anything đ¤ˇââď¸
+ 'no-empty-pattern': 'off',
+ '@typescript-eslint/consistent-type-imports': [
+ 'warn',
+ {
+ prefer: 'type-imports',
+ disallowTypeAnnotations: true,
+ fixStyle: 'inline-type-imports',
+ },
+ ],
+ 'import/no-duplicates': ['warn', { 'prefer-inline': true }],
+ 'import/consistent-type-specifier-style': ['warn', 'prefer-inline'],
+ 'import/order': [
+ 'warn',
+ {
+ alphabetize: { order: 'asc', caseInsensitive: true },
+ groups: [
+ 'builtin',
+ 'external',
+ 'internal',
+ 'parent',
+ 'sibling',
+ 'index',
+ ],
+ },
+ ],
+ },
+ overrides: [
+ {
+ plugins: ['remix-react-routes'],
+ files: appFiles,
+ excludedFiles: testFiles,
+ rules: {
+ 'remix-react-routes/use-link-for-routes': 'error',
+ 'remix-react-routes/require-valid-paths': 'error',
+ // disable this one because it doesn't appear to work with our
+ // route convention. Someone should dig deeper into this...
+ 'remix-react-routes/no-relative-paths': [
+ 'off',
+ { allowLinksToSelf: true },
+ ],
+ 'remix-react-routes/no-urls': 'error',
+ 'no-restricted-imports': [
+ 'error',
+ {
+ patterns: [
+ {
+ group: testFiles,
+ message: 'Do not import test files in app files',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ {
+ extends: ['@remix-run/eslint-config/jest-testing-library'],
+ files: vitestFiles,
+ rules: {
+ 'testing-library/no-await-sync-events': 'off',
+ 'jest-dom/prefer-in-document': 'off',
+ },
+ // we're using vitest which has a very similar API to jest
+ // (so the linting plugins work nicely), but it means we have to explicitly
+ // set the jest version.
+ settings: {
+ jest: {
+ version: 28,
+ },
+ },
+ },
+ ],
+}
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..84a2084
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,15 @@
+
+
+## Test Plan
+
+
+
+## Checklist
+
+- [ ] Tests updated
+- [ ] Docs updated
+
+## Screenshots
+
+
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..7ab7ae7
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,181 @@
+name: đ Deploy
+on:
+ push:
+ branches:
+ - main
+ - dev
+ pull_request: {}
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ actions: write
+ contents: read
+
+jobs:
+ lint:
+ name: ⏣ ESLint
+ runs-on: ubuntu-latest
+ steps:
+ - name: âŹď¸ Checkout repo
+ uses: actions/checkout@v3
+
+ - name: â Setup node
+ uses: actions/setup-node@v3
+ with:
+ node-version: 18
+
+ - name: đĽ Download deps
+ uses: bahmutov/npm-install@v1
+
+ - name: đź Build icons
+ run: npm run build:icons
+
+ - name: đŹ Lint
+ run: npm run lint
+
+ typecheck:
+ name: ĘŚ TypeScript
+ runs-on: ubuntu-latest
+ steps:
+ - name: âŹď¸ Checkout repo
+ uses: actions/checkout@v3
+
+ - name: â Setup node
+ uses: actions/setup-node@v3
+ with:
+ node-version: 18
+
+ - name: đĽ Download deps
+ uses: bahmutov/npm-install@v1
+
+ - name: đź Build icons
+ run: npm run build:icons
+
+ - name: đ Type check
+ run: npm run typecheck --if-present
+
+ vitest:
+ name: ⥠Vitest
+ runs-on: ubuntu-latest
+ steps:
+ - name: âŹď¸ Checkout repo
+ uses: actions/checkout@v3
+
+ - name: â Setup node
+ uses: actions/setup-node@v3
+ with:
+ node-version: 18
+
+ - name: đĽ Download deps
+ uses: bahmutov/npm-install@v1
+
+ - name: đ Copy test env vars
+ run: cp .env.example .env
+
+ - name: đź Build icons
+ run: npm run build:icons
+
+ - name: ⥠Run vitest
+ run: npm run test -- --coverage
+
+ playwright:
+ name: đ Playwright
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ steps:
+ - name: âŹď¸ Checkout repo
+ uses: actions/checkout@v3
+
+ - name: đ Copy test env vars
+ run: cp .env.example .env
+
+ - name: â Setup node
+ uses: actions/setup-node@v3
+ with:
+ node-version: 18
+
+ - name: đĽ Download deps
+ uses: bahmutov/npm-install@v1
+
+ - name: đĽ Install Playwright Browsers
+ run: npm run test:e2e:install
+
+ - name: đ Setup Database
+ run: npx prisma migrate deploy
+
+ - name: đŚ Cache Database
+ id: db-cache
+ uses: actions/cache@v3
+ with:
+ path: prisma/data.db
+ key:
+ db-cache-schema_${{ hashFiles('./prisma/schema.prisma')
+ }}-migrations_${{ hashFiles('./prisma/migrations/*/migration.sql')
+ }}
+
+ - name: đą Seed Database
+ if: steps.db-cache.outputs.cache-hit != 'true'
+ run: npx prisma db seed
+ env:
+ MINIMAL_SEED: true
+
+ - name: đ Build
+ run: npm run build
+
+ - name: đ Playwright tests
+ run: npx playwright test
+
+ - name: đ Upload report
+ uses: actions/upload-artifact@v3
+ if: always()
+ with:
+ name: playwright-report
+ path: playwright-report/
+ retention-days: 30
+
+ # deploy:
+ # name: đ Deploy
+ # runs-on: ubuntu-latest
+ # needs: [lint, typecheck, vitest, playwright]
+ # # only build/deploy main branch on pushes
+ # if:
+ # ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') &&
+ # github.event_name == 'push' }}
+
+ # steps:
+ # - name: âŹď¸ Checkout repo
+ # uses: actions/checkout@v3
+
+ # - name: đ Read app name
+ # uses: SebRollen/toml-action@v1.0.2
+ # id: app_name
+ # with:
+ # file: 'fly.toml'
+ # field: 'app'
+
+ # # move Dockerfile to root
+ # - name: đ Move Dockerfile
+ # run: |
+ # mv ./other/Dockerfile ./Dockerfile
+ # mv ./other/.dockerignore ./.dockerignore
+
+ # - name: đ Setup Fly
+ # uses: superfly/flyctl-actions/setup-flyctl@v1.4
+
+ # - name: đ Deploy Staging
+ # if: ${{ github.ref == 'refs/heads/dev' }}
+ # run:
+ # flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }}
+ # --app ${{ steps.app_name.outputs.value }}-staging
+ # env:
+ # FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
+
+ # - name: đ Deploy Production
+ # if: ${{ github.ref == 'refs/heads/main' }}
+ # run:
+ # flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }}
+ # env:
+ # FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f9d5ad8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,22 @@
+node_modules
+.DS_store
+
+/build
+/public/build
+/server-build
+.env
+
+/prisma/data.db
+/prisma/data.db-journal
+/tests/prisma
+
+/test-results/
+/playwright-report/
+/playwright/.cache/
+/tests/fixtures/email/
+/coverage
+
+/other/cache.db
+
+# Easy way to create temporary files/folders that won't accidentally be added to git
+*.local.*
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..668efa1
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+legacy-peer-deps=true
+registry=https://registry.npmjs.org/
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..f022d02
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,15 @@
+node_modules
+
+/build
+/public/build
+/server-build
+.env
+
+/test-results/
+/playwright-report/
+/playwright/.cache/
+/tests/fixtures/email/*.json
+/coverage
+/prisma/migrations
+
+package-lock.json
diff --git a/.prettierrc.js b/.prettierrc.js
new file mode 100644
index 0000000..b0ffa1c
--- /dev/null
+++ b/.prettierrc.js
@@ -0,0 +1,30 @@
+/** @type {import("prettier").Options} */
+export default {
+ arrowParens: 'avoid',
+ bracketSameLine: false,
+ bracketSpacing: true,
+ embeddedLanguageFormatting: 'auto',
+ endOfLine: 'lf',
+ htmlWhitespaceSensitivity: 'css',
+ insertPragma: false,
+ jsxSingleQuote: false,
+ printWidth: 80,
+ proseWrap: 'always',
+ quoteProps: 'as-needed',
+ requirePragma: false,
+ semi: false,
+ singleAttributePerLine: false,
+ singleQuote: true,
+ tabWidth: 2,
+ trailingComma: 'all',
+ useTabs: true,
+ overrides: [
+ {
+ files: ['**/*.json'],
+ options: {
+ useTabs: false,
+ },
+ },
+ ],
+ plugins: ['prettier-plugin-tailwindcss'],
+}
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..7619ac2
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,11 @@
+{
+ "recommendations": [
+ "bradlc.vscode-tailwindcss",
+ "dbaeumer.vscode-eslint",
+ "esbenp.prettier-vscode",
+ "prisma.prisma",
+ "qwtel.sqlite-viewer",
+ "yoavbls.pretty-ts-errors",
+ "github.vscode-github-actions"
+ ]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..374cf10
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,19 @@
+{
+ "typescript.preferences.autoImportFileExcludePatterns": [
+ "@remix-run/server-runtime",
+ "@remix-run/router",
+ "express",
+ "@radix-ui/**",
+ "@react-email/**",
+ "react-router-dom",
+ "react-router",
+ "stream/consumers",
+ "node:stream/consumers",
+ "node:test",
+ "console",
+ "node:console"
+ ],
+ "workbench.editorAssociations": {
+ "*.db": "sqlite-viewer.view"
+ }
+}
diff --git a/COMMUNITY.md b/COMMUNITY.md
new file mode 100644
index 0000000..269d108
--- /dev/null
+++ b/COMMUNITY.md
@@ -0,0 +1,12 @@
+# Community
+
+Here you can find useful learning resources and tools built and maintained by
+the community, such as libraries, examples, articles, and videos.
+
+## Learning resources
+
+### Videos
+
+- **Dark Mode Toggling using Client-preference cookies** by
+ [@rajeshdavidbabu](https://github.com/rajeshdavidbabu) - Youtube
+ [link](https://www.youtube.com/watch?v=UND-kib_iw4)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b554c2b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,54 @@
+
+
+
+ Ditch analysis paralysis and start shipping Epic Web apps.
+
+
+ This is an opinionated project starter and reference that allows teams to
+ ship their ideas to production faster and on a more stable foundation based
+ on the experience of Kent C. Dodds and
+ contributors .
+
+
+
+```sh
+npx create-remix@latest --typescript --install --template epicweb-dev/epic-stack
+```
+
+[![The Epic Stack](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/246885449-1b00286c-aa3d-44b2-9ef2-04f694eb3592.png)](https://www.epicweb.dev/epic-stack)
+
+[The Epic Stack](https://www.epicweb.dev/epic-stack)
+
+
+
+## Watch Kent's Introduction to The Epic Stack
+
+[![screenshot of a YouTube video](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/242088051-6beafa78-41c6-47e1-b999-08d3d3e5cb57.png)](https://www.youtube.com/watch?v=yMK5SVRASxM)
+
+["The Epic Stack" by Kent C. Dodds at #RemixConf 2023 đż](https://www.youtube.com/watch?v=yMK5SVRASxM)
+
+## Docs
+
+[Read the docs](https://github.com/epicweb-dev/epic-stack/blob/main/docs)
+(please đ).
+
+## Support
+
+- đ Join the
+ [discussion on GitHub](https://github.com/epicweb-dev/epic-stack/discussions)
+ and the [KCD Community on Discord](https://kcd.im/discord).
+- đĄ Create an
+ [idea discussion](https://github.com/epicweb-dev/epic-stack/discussions/new?category=ideas)
+ for suggestions.
+- đ Open a [GitHub issue](https://github.com/epicweb-dev/epic-stack/issues) to
+ report a bug.
+
+## Branding
+
+Want to talk about the Epic Stack in a blog post or talk? Great! Here are some
+assets you can use in your material:
+[EpicWeb.dev/brand](https://epicweb.dev/brand)
+
+## Thanks
+
+You rock đި
diff --git a/app/components/confetti.tsx b/app/components/confetti.tsx
new file mode 100644
index 0000000..69fbece
--- /dev/null
+++ b/app/components/confetti.tsx
@@ -0,0 +1,21 @@
+import { Index as ConfettiShower } from 'confetti-react'
+import { ClientOnly } from 'remix-utils'
+
+export function Confetti({ id }: { id?: string | null }) {
+ if (!id) return null
+
+ return (
+
+ {() => (
+
+ )}
+
+ )
+}
diff --git a/app/components/error-boundary.tsx b/app/components/error-boundary.tsx
new file mode 100644
index 0000000..e0f7f43
--- /dev/null
+++ b/app/components/error-boundary.tsx
@@ -0,0 +1,45 @@
+import {
+ isRouteErrorResponse,
+ useParams,
+ useRouteError,
+} from '@remix-run/react'
+import { type ErrorResponse } from '@remix-run/router'
+import { getErrorMessage } from '#app/utils/misc.tsx'
+
+type StatusHandler = (info: {
+ error: ErrorResponse
+ params: Record
+}) => JSX.Element | null
+
+export function GeneralErrorBoundary({
+ defaultStatusHandler = ({ error }) => (
+
+ {error.status} {error.data}
+
+ ),
+ statusHandlers,
+ unexpectedErrorHandler = error => {getErrorMessage(error)}
,
+}: {
+ defaultStatusHandler?: StatusHandler
+ statusHandlers?: Record
+ unexpectedErrorHandler?: (error: unknown) => JSX.Element | null
+}) {
+ const error = useRouteError()
+ const params = useParams()
+
+ if (typeof document !== 'undefined') {
+ console.error(error)
+ }
+
+ return (
+
+ {isRouteErrorResponse(error)
+ ? (statusHandlers?.[error.status] ?? defaultStatusHandler)({
+ // @ts-expect-error, pretty sure this is a bug in Remix
+ error,
+ params,
+ })
+ : unexpectedErrorHandler(error)}
+
+ )
+}
diff --git a/app/components/floating-toolbar.tsx b/app/components/floating-toolbar.tsx
new file mode 100644
index 0000000..41b5be0
--- /dev/null
+++ b/app/components/floating-toolbar.tsx
@@ -0,0 +1,2 @@
+export const floatingToolbarClassName =
+ 'absolute bottom-3 left-3 right-3 flex items-center gap-2 rounded-lg bg-muted/80 p-4 pl-5 shadow-xl shadow-accent backdrop-blur-sm md:gap-4 md:pl-7 justify-end'
diff --git a/app/components/forms.tsx b/app/components/forms.tsx
new file mode 100644
index 0000000..0d362fc
--- /dev/null
+++ b/app/components/forms.tsx
@@ -0,0 +1,148 @@
+import { useInputEvent } from '@conform-to/react'
+import React, { useId, useRef } from 'react'
+import { Checkbox, type CheckboxProps } from './ui/checkbox.tsx'
+import { Input } from './ui/input.tsx'
+import { Label } from './ui/label.tsx'
+import { Textarea } from './ui/textarea.tsx'
+
+export type ListOfErrors = Array | null | undefined
+
+export function ErrorList({
+ id,
+ errors,
+}: {
+ errors?: ListOfErrors
+ id?: string
+}) {
+ const errorsToRender = errors?.filter(Boolean)
+ if (!errorsToRender?.length) return null
+ return (
+
+ {errorsToRender.map(e => (
+
+ {e}
+
+ ))}
+
+ )
+}
+
+export function Field({
+ labelProps,
+ inputProps,
+ errors,
+ className,
+}: {
+ labelProps: React.LabelHTMLAttributes
+ inputProps: React.InputHTMLAttributes
+ errors?: ListOfErrors
+ className?: string
+}) {
+ const fallbackId = useId()
+ const id = inputProps.id ?? fallbackId
+ const errorId = errors?.length ? `${id}-error` : undefined
+ return (
+
+
+
+
+ {errorId ? : null}
+
+
+ )
+}
+
+export function TextareaField({
+ labelProps,
+ textareaProps,
+ errors,
+ className,
+}: {
+ labelProps: React.LabelHTMLAttributes
+ textareaProps: React.InputHTMLAttributes
+ errors?: ListOfErrors
+ className?: string
+}) {
+ const fallbackId = useId()
+ const id = textareaProps.id ?? textareaProps.name ?? fallbackId
+ const errorId = errors?.length ? `${id}-error` : undefined
+ return (
+
+
+
+
+ {errorId ? : null}
+
+
+ )
+}
+
+export function CheckboxField({
+ labelProps,
+ buttonProps,
+ errors,
+ className,
+}: {
+ labelProps: JSX.IntrinsicElements['label']
+ buttonProps: CheckboxProps
+ errors?: ListOfErrors
+ className?: string
+}) {
+ const fallbackId = useId()
+ const buttonRef = useRef(null)
+ // To emulate native events that Conform listen to:
+ // See https://conform.guide/integrations
+ const control = useInputEvent({
+ // Retrieve the checkbox element by name instead as Radix does not expose the internal checkbox element
+ // See https://github.com/radix-ui/primitives/discussions/874
+ ref: () =>
+ buttonRef.current?.form?.elements.namedItem(buttonProps.name ?? ''),
+ onFocus: () => buttonRef.current?.focus(),
+ })
+ const id = buttonProps.id ?? buttonProps.name ?? fallbackId
+ const errorId = errors?.length ? `${id}-error` : undefined
+ return (
+
+
+ {
+ control.change(Boolean(state.valueOf()))
+ buttonProps.onCheckedChange?.(state)
+ }}
+ onFocus={event => {
+ control.focus()
+ buttonProps.onFocus?.(event)
+ }}
+ onBlur={event => {
+ control.blur()
+ buttonProps.onBlur?.(event)
+ }}
+ type="button"
+ />
+
+
+
+ {errorId ? : null}
+
+
+ )
+}
diff --git a/app/components/search-bar.tsx b/app/components/search-bar.tsx
new file mode 100644
index 0000000..859c52d
--- /dev/null
+++ b/app/components/search-bar.tsx
@@ -0,0 +1,62 @@
+import { Form, useSearchParams, useSubmit } from '@remix-run/react'
+import { useDebounce, useIsPending } from '#app/utils/misc.tsx'
+import { Icon } from './ui/icon.tsx'
+import { Input } from './ui/input.tsx'
+import { Label } from './ui/label.tsx'
+import { StatusButton } from './ui/status-button.tsx'
+
+export function SearchBar({
+ status,
+ autoFocus = false,
+ autoSubmit = false,
+}: {
+ status: 'idle' | 'pending' | 'success' | 'error'
+ autoFocus?: boolean
+ autoSubmit?: boolean
+}) {
+ const [searchParams] = useSearchParams()
+ const submit = useSubmit()
+ const isSubmitting = useIsPending({
+ formMethod: 'GET',
+ formAction: '/users',
+ })
+
+ const handleFormChange = useDebounce((form: HTMLFormElement) => {
+ submit(form)
+ }, 400)
+
+ return (
+
+ )
+}
diff --git a/app/components/spacer.tsx b/app/components/spacer.tsx
new file mode 100644
index 0000000..8a8e537
--- /dev/null
+++ b/app/components/spacer.tsx
@@ -0,0 +1,57 @@
+export function Spacer({
+ size,
+}: {
+ /**
+ * The size of the space
+ *
+ * 4xs: h-4 (16px)
+ *
+ * 3xs: h-8 (32px)
+ *
+ * 2xs: h-12 (48px)
+ *
+ * xs: h-16 (64px)
+ *
+ * sm: h-20 (80px)
+ *
+ * md: h-24 (96px)
+ *
+ * lg: h-28 (112px)
+ *
+ * xl: h-32 (128px)
+ *
+ * 2xl: h-36 (144px)
+ *
+ * 3xl: h-40 (160px)
+ *
+ * 4xl: h-44 (176px)
+ */
+ size:
+ | '4xs'
+ | '3xs'
+ | '2xs'
+ | 'xs'
+ | 'sm'
+ | 'md'
+ | 'lg'
+ | 'xl'
+ | '2xl'
+ | '3xl'
+ | '4xl'
+}) {
+ const options: Record = {
+ '4xs': 'h-4',
+ '3xs': 'h-8',
+ '2xs': 'h-12',
+ xs: 'h-16',
+ sm: 'h-20',
+ md: 'h-24',
+ lg: 'h-28',
+ xl: 'h-32',
+ '2xl': 'h-36',
+ '3xl': 'h-40',
+ '4xl': 'h-44',
+ }
+ const className = options[size]
+ return
+}
diff --git a/app/components/spinner.tsx b/app/components/spinner.tsx
new file mode 100644
index 0000000..c8b0d7b
--- /dev/null
+++ b/app/components/spinner.tsx
@@ -0,0 +1,33 @@
+export function Spinner({ showSpinner }: { showSpinner: boolean }) {
+ return (
+
+ )
+}
diff --git a/app/components/toaster.tsx b/app/components/toaster.tsx
new file mode 100644
index 0000000..6212530
--- /dev/null
+++ b/app/components/toaster.tsx
@@ -0,0 +1,22 @@
+import { useEffect } from 'react'
+import { Toaster, toast as showToast } from 'sonner'
+import { type Toast } from '#app/utils/toast.server.ts'
+
+export function EpicToaster({ toast }: { toast?: Toast | null }) {
+ return (
+ <>
+
+ {toast ? : null}
+ >
+ )
+}
+
+function ShowToast({ toast }: { toast: Toast }) {
+ const { id, type, title, description } = toast
+ useEffect(() => {
+ setTimeout(() => {
+ showToast[type](title, { id, description })
+ }, 0)
+ }, [description, id, title, type])
+ return null
+}
diff --git a/app/components/ui/README.md b/app/components/ui/README.md
new file mode 100644
index 0000000..433847d
--- /dev/null
+++ b/app/components/ui/README.md
@@ -0,0 +1,7 @@
+# shadcn/ui
+
+Some components in this directory are downloaded via the
+[shadcn/ui](https://ui.shadcn.com) [CLI](https://ui.shadcn.com/docs/cli). Feel
+free to customize them to your needs. It's important to know that shadcn/ui is
+not a library of components you install, but instead it's a registry of prebuilt
+components which you can download and customize.
diff --git a/app/components/ui/button.tsx b/app/components/ui/button.tsx
new file mode 100644
index 0000000..87f29af
--- /dev/null
+++ b/app/components/ui/button.tsx
@@ -0,0 +1,58 @@
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+import * as React from 'react'
+
+import { cn } from '#app/utils/misc.tsx'
+
+const buttonVariants = cva(
+ 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ destructive:
+ 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
+ outline:
+ 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
+ secondary:
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ ghost: 'hover:bg-accent hover:text-accent-foreground',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-10 px-4 py-2',
+ wide: 'px-24 py-5',
+ sm: 'h-9 rounded-md px-3',
+ lg: 'h-11 rounded-md px-8',
+ pill: 'px-12 py-3 leading-3',
+ icon: 'h-10 w-10',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : 'button'
+ return (
+
+ )
+ },
+)
+Button.displayName = 'Button'
+
+export { Button, buttonVariants }
diff --git a/app/components/ui/checkbox.tsx b/app/components/ui/checkbox.tsx
new file mode 100644
index 0000000..637a7fd
--- /dev/null
+++ b/app/components/ui/checkbox.tsx
@@ -0,0 +1,41 @@
+import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
+import * as React from 'react'
+
+import { cn } from '#app/utils/misc.tsx'
+
+export type CheckboxProps = Omit<
+ React.ComponentPropsWithoutRef,
+ 'type'
+> & {
+ type?: string
+}
+
+const Checkbox = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+
+
+))
+Checkbox.displayName = CheckboxPrimitive.Root.displayName
+
+export { Checkbox }
diff --git a/app/components/ui/dropdown-menu.tsx b/app/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..3bb4fe3
--- /dev/null
+++ b/app/components/ui/dropdown-menu.tsx
@@ -0,0 +1,206 @@
+import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
+import * as React from 'react'
+
+import { cn } from '#app/utils/misc.tsx'
+
+const DropdownMenu = DropdownMenuPrimitive.Root
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+ âśď¸
+
+))
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ âŞ
+
+
+ {children}
+
+))
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+}
diff --git a/app/components/ui/icon.tsx b/app/components/ui/icon.tsx
new file mode 100644
index 0000000..fb0108f
--- /dev/null
+++ b/app/components/ui/icon.tsx
@@ -0,0 +1,65 @@
+import { type SVGProps } from 'react'
+import { cn } from '#app/utils/misc.tsx'
+import { type IconName } from '@/icon-name'
+import href from './icons/sprite.svg'
+
+export { href }
+export { IconName }
+
+const sizeClassName = {
+ font: 'w-[1em] h-[1em]',
+ xs: 'w-3 h-3',
+ sm: 'w-4 h-4',
+ md: 'w-5 h-5',
+ lg: 'w-6 h-6',
+ xl: 'w-7 h-7',
+} as const
+
+type Size = keyof typeof sizeClassName
+
+const childrenSizeClassName = {
+ font: 'gap-1.5',
+ xs: 'gap-1.5',
+ sm: 'gap-1.5',
+ md: 'gap-2',
+ lg: 'gap-2',
+ xl: 'gap-3',
+} satisfies Record
+
+/**
+ * Renders an SVG icon. The icon defaults to the size of the font. To make it
+ * align vertically with neighboring text, you can pass the text as a child of
+ * the icon and it will be automatically aligned.
+ * Alternatively, if you're not ok with the icon being to the left of the text,
+ * you need to wrap the icon and text in a common parent and set the parent to
+ * display "flex" (or "inline-flex") with "items-center" and a reasonable gap.
+ */
+export function Icon({
+ name,
+ size = 'font',
+ className,
+ children,
+ ...props
+}: SVGProps & {
+ name: IconName
+ size?: Size
+}) {
+ if (children) {
+ return (
+
+
+ {children}
+
+ )
+ }
+ return (
+
+
+
+ )
+}
diff --git a/app/components/ui/icons/README.md b/app/components/ui/icons/README.md
new file mode 100644
index 0000000..4047a10
--- /dev/null
+++ b/app/components/ui/icons/README.md
@@ -0,0 +1,5 @@
+# Icons
+
+This directory contains SVG icons that are used by the app.
+
+Everything in this directory is generated by `npm run build:icons`.
diff --git a/app/components/ui/icons/name.d.ts b/app/components/ui/icons/name.d.ts
new file mode 100644
index 0000000..6830243
--- /dev/null
+++ b/app/components/ui/icons/name.d.ts
@@ -0,0 +1,30 @@
+// This file is generated by npm run build:icons
+
+export type IconName =
+ | 'arrow-left'
+ | 'arrow-right'
+ | 'avatar'
+ | 'camera'
+ | 'check'
+ | 'clock'
+ | 'cross-1'
+ | 'dots-horizontal'
+ | 'download'
+ | 'envelope-closed'
+ | 'exit'
+ | 'file-text'
+ | 'github-logo'
+ | 'laptop'
+ | 'link-2'
+ | 'lock-closed'
+ | 'lock-open-1'
+ | 'magnifying-glass'
+ | 'moon'
+ | 'pencil-1'
+ | 'pencil-2'
+ | 'plus'
+ | 'question-mark-circled'
+ | 'reset'
+ | 'sun'
+ | 'trash'
+ | 'update'
diff --git a/app/components/ui/icons/sprite.svg b/app/components/ui/icons/sprite.svg
new file mode 100644
index 0000000..81f92f7
--- /dev/null
+++ b/app/components/ui/icons/sprite.svg
@@ -0,0 +1,195 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/ui/input.tsx b/app/components/ui/input.tsx
new file mode 100644
index 0000000..18801dc
--- /dev/null
+++ b/app/components/ui/input.tsx
@@ -0,0 +1,25 @@
+import * as React from 'react'
+
+import { cn } from '#app/utils/misc.tsx'
+
+export interface InputProps
+ extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ },
+)
+Input.displayName = 'Input'
+
+export { Input }
diff --git a/app/components/ui/label.tsx b/app/components/ui/label.tsx
new file mode 100644
index 0000000..ec453ee
--- /dev/null
+++ b/app/components/ui/label.tsx
@@ -0,0 +1,24 @@
+import * as LabelPrimitive from '@radix-ui/react-label'
+import { cva, type VariantProps } from 'class-variance-authority'
+import * as React from 'react'
+
+import { cn } from '#app/utils/misc.tsx'
+
+const labelVariants = cva(
+ 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
+)
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/app/components/ui/status-button.tsx b/app/components/ui/status-button.tsx
new file mode 100644
index 0000000..1bfdb61
--- /dev/null
+++ b/app/components/ui/status-button.tsx
@@ -0,0 +1,65 @@
+import * as React from 'react'
+import { useSpinDelay } from 'spin-delay'
+import { cn } from '#app/utils/misc.tsx'
+import { Button, type ButtonProps } from './button.tsx'
+import { Icon } from './icon.tsx'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from './tooltip.tsx'
+
+export const StatusButton = React.forwardRef<
+ HTMLButtonElement,
+ ButtonProps & {
+ status: 'pending' | 'success' | 'error' | 'idle'
+ message?: string | null
+ spinDelay?: Parameters[1]
+ }
+>(({ message, status, className, children, spinDelay, ...props }, ref) => {
+ const delayedPending = useSpinDelay(status === 'pending', {
+ delay: 400,
+ minDuration: 300,
+ ...spinDelay,
+ })
+ const companion = {
+ pending: delayedPending ? (
+
+
+
+ ) : null,
+ success: (
+
+
+
+ ),
+ error: (
+
+
+
+ ),
+ idle: null,
+ }[status]
+
+ return (
+
+ {children}
+ {message ? (
+
+
+ {companion}
+ {message}
+
+
+ ) : (
+ companion
+ )}
+
+ )
+})
+StatusButton.displayName = 'Button'
diff --git a/app/components/ui/textarea.tsx b/app/components/ui/textarea.tsx
new file mode 100644
index 0000000..f7719e8
--- /dev/null
+++ b/app/components/ui/textarea.tsx
@@ -0,0 +1,24 @@
+import * as React from 'react'
+
+import { cn } from '#app/utils/misc.tsx'
+
+export interface TextareaProps
+ extends React.TextareaHTMLAttributes {}
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ )
+ },
+)
+Textarea.displayName = 'Textarea'
+
+export { Textarea }
diff --git a/app/components/ui/tooltip.tsx b/app/components/ui/tooltip.tsx
new file mode 100644
index 0000000..5017f3e
--- /dev/null
+++ b/app/components/ui/tooltip.tsx
@@ -0,0 +1,28 @@
+import * as TooltipPrimitive from '@radix-ui/react-tooltip'
+import * as React from 'react'
+
+import { cn } from '#app/utils/misc.tsx'
+
+const TooltipProvider = TooltipPrimitive.Provider
+
+const Tooltip = TooltipPrimitive.Root
+
+const TooltipTrigger = TooltipPrimitive.Trigger
+
+const TooltipContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+))
+TooltipContent.displayName = TooltipPrimitive.Content.displayName
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/app/entry.client.tsx b/app/entry.client.tsx
new file mode 100644
index 0000000..cfea558
--- /dev/null
+++ b/app/entry.client.tsx
@@ -0,0 +1,11 @@
+import { RemixBrowser } from '@remix-run/react'
+import { startTransition } from 'react'
+import { hydrateRoot } from 'react-dom/client'
+
+if (ENV.MODE === 'production' && ENV.SENTRY_DSN) {
+ import('./utils/monitoring.client.tsx').then(({ init }) => init())
+}
+
+startTransition(() => {
+ hydrateRoot(document, )
+})
diff --git a/app/entry.server.tsx b/app/entry.server.tsx
new file mode 100644
index 0000000..6706e88
--- /dev/null
+++ b/app/entry.server.tsx
@@ -0,0 +1,87 @@
+import { PassThrough } from 'stream'
+import { Response, type HandleDocumentRequestFunction } from '@remix-run/node'
+import { RemixServer } from '@remix-run/react'
+import isbot from 'isbot'
+import { getInstanceInfo } from 'litefs-js'
+import { renderToPipeableStream } from 'react-dom/server'
+import { getEnv, init } from './utils/env.server.ts'
+import { NonceProvider } from './utils/nonce-provider.ts'
+import { makeTimings } from './utils/timing.server.ts'
+
+const ABORT_DELAY = 5000
+
+init()
+global.ENV = getEnv()
+
+if (ENV.MODE === 'production' && ENV.SENTRY_DSN) {
+ import('./utils/monitoring.server.ts').then(({ init }) => init())
+}
+
+type DocRequestArgs = Parameters
+
+export default async function handleRequest(...args: DocRequestArgs) {
+ const [
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext,
+ loadContext,
+ ] = args
+ const { currentInstance, primaryInstance } = await getInstanceInfo()
+ responseHeaders.set('fly-region', process.env.FLY_REGION ?? 'unknown')
+ responseHeaders.set('fly-app', process.env.FLY_APP_NAME ?? 'unknown')
+ responseHeaders.set('fly-primary-instance', primaryInstance)
+ responseHeaders.set('fly-instance', currentInstance)
+
+ const callbackName = isbot(request.headers.get('user-agent'))
+ ? 'onAllReady'
+ : 'onShellReady'
+
+ const nonce = String(loadContext.cspNonce) ?? undefined
+ return new Promise(async (resolve, reject) => {
+ let didError = false
+ // NOTE: this timing will only include things that are rendered in the shell
+ // and will not include suspended components and deferred loaders
+ const timings = makeTimings('render', 'renderToPipeableStream')
+
+ const { pipe, abort } = renderToPipeableStream(
+
+
+ ,
+ {
+ [callbackName]: () => {
+ const body = new PassThrough()
+ responseHeaders.set('Content-Type', 'text/html')
+ responseHeaders.append('Server-Timing', timings.toString())
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ }),
+ )
+ pipe(body)
+ },
+ onShellError: (err: unknown) => {
+ reject(err)
+ },
+ onError: (error: unknown) => {
+ didError = true
+
+ console.error(error)
+ },
+ },
+ )
+
+ setTimeout(abort, ABORT_DELAY)
+ })
+}
+
+export async function handleDataRequest(response: Response) {
+ const { currentInstance, primaryInstance } = await getInstanceInfo()
+ response.headers.set('fly-region', process.env.FLY_REGION ?? 'unknown')
+ response.headers.set('fly-app', process.env.FLY_APP_NAME ?? 'unknown')
+ response.headers.set('fly-primary-instance', primaryInstance)
+ response.headers.set('fly-instance', currentInstance)
+
+ return response
+}
diff --git a/app/root.tsx b/app/root.tsx
new file mode 100644
index 0000000..5522ba3
--- /dev/null
+++ b/app/root.tsx
@@ -0,0 +1,449 @@
+import { useForm } from '@conform-to/react'
+import { parse } from '@conform-to/zod'
+import { cssBundleHref } from '@remix-run/css-bundle'
+import {
+ json,
+ type DataFunctionArgs,
+ type HeadersFunction,
+ type LinksFunction,
+ type V2_MetaFunction,
+} from '@remix-run/node'
+import {
+ Form,
+ Link,
+ Links,
+ LiveReload,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+ useFetcher,
+ useFetchers,
+ useLoaderData,
+ useMatches,
+ useSubmit,
+} from '@remix-run/react'
+import { withSentry } from '@sentry/remix'
+import { useRef } from 'react'
+import { z } from 'zod'
+import { Confetti } from './components/confetti.tsx'
+import { GeneralErrorBoundary } from './components/error-boundary.tsx'
+import { ErrorList } from './components/forms.tsx'
+import { SearchBar } from './components/search-bar.tsx'
+import { EpicToaster } from './components/toaster.tsx'
+import { Button } from './components/ui/button.tsx'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+} from './components/ui/dropdown-menu.tsx'
+import { Icon, href as iconsHref } from './components/ui/icon.tsx'
+import fontStylestylesheetUrl from './styles/font.css'
+import tailwindStylesheetUrl from './styles/tailwind.css'
+import { authenticator, getUserId } from './utils/auth.server.ts'
+import { ClientHintCheck, getHints, useHints } from './utils/client-hints.tsx'
+import { getConfetti } from './utils/confetti.server.ts'
+import { prisma } from './utils/db.server.ts'
+import { getEnv } from './utils/env.server.ts'
+import {
+ combineHeaders,
+ getDomainUrl,
+ getUserImgSrc,
+ invariantResponse,
+} from './utils/misc.tsx'
+import { useNonce } from './utils/nonce-provider.ts'
+import { useRequestInfo } from './utils/request-info.ts'
+import { type Theme, setTheme, getTheme } from './utils/theme.server.ts'
+import { makeTimings, time } from './utils/timing.server.ts'
+import { getToast } from './utils/toast.server.ts'
+import { useOptionalUser, useUser } from './utils/user.ts'
+
+export const links: LinksFunction = () => {
+ return [
+ // Preload svg sprite as a resource to avoid render blocking
+ { rel: 'preload', href: iconsHref, as: 'image' },
+ // Preload CSS as a resource to avoid render blocking
+ { rel: 'preload', href: fontStylestylesheetUrl, as: 'style' },
+ { rel: 'preload', href: tailwindStylesheetUrl, as: 'style' },
+ cssBundleHref ? { rel: 'preload', href: cssBundleHref, as: 'style' } : null,
+ { rel: 'mask-icon', href: '/favicons/mask-icon.svg' },
+ {
+ rel: 'alternate icon',
+ type: 'image/png',
+ href: '/favicons/favicon-32x32.png',
+ },
+ { rel: 'apple-touch-icon', href: '/favicons/apple-touch-icon.png' },
+ {
+ rel: 'manifest',
+ href: '/site.webmanifest',
+ crossOrigin: 'use-credentials',
+ } as const, // necessary to make typescript happy
+ //These should match the css preloads above to avoid css as render blocking resource
+ { rel: 'icon', type: 'image/svg+xml', href: '/favicons/favicon.svg' },
+ { rel: 'stylesheet', href: fontStylestylesheetUrl },
+ { rel: 'stylesheet', href: tailwindStylesheetUrl },
+ cssBundleHref ? { rel: 'stylesheet', href: cssBundleHref } : null,
+ ].filter(Boolean)
+}
+
+export const meta: V2_MetaFunction = ({ data }) => {
+ return [
+ { title: data ? 'Epic Notes' : 'Error | Epic Notes' },
+ { name: 'description', content: `Your own captain's log` },
+ ]
+}
+
+export async function loader({ request }: DataFunctionArgs) {
+ const timings = makeTimings('root loader')
+ const userId = await time(() => getUserId(request), {
+ timings,
+ type: 'getUserId',
+ desc: 'getUserId in root',
+ })
+
+ const user = userId
+ ? await time(
+ () =>
+ prisma.user.findUniqueOrThrow({
+ select: {
+ id: true,
+ name: true,
+ username: true,
+ image: { select: { id: true } },
+ roles: {
+ select: {
+ name: true,
+ permissions: {
+ select: { entity: true, action: true, access: true },
+ },
+ },
+ },
+ },
+ where: { id: userId },
+ }),
+ { timings, type: 'find user', desc: 'find user in root' },
+ )
+ : null
+ if (userId && !user) {
+ console.info('something weird happened')
+ // something weird happened... The user is authenticated but we can't find
+ // them in the database. Maybe they were deleted? Let's log them out.
+ await authenticator.logout(request, { redirectTo: '/' })
+ }
+ const { toast, headers: toastHeaders } = await getToast(request)
+ const { confettiId, headers: confettiHeaders } = getConfetti(request)
+
+ return json(
+ {
+ user,
+ requestInfo: {
+ hints: getHints(request),
+ origin: getDomainUrl(request),
+ path: new URL(request.url).pathname,
+ userPrefs: {
+ theme: getTheme(request),
+ },
+ },
+ ENV: getEnv(),
+ toast,
+ confettiId,
+ },
+ {
+ headers: combineHeaders(
+ { 'Server-Timing': timings.toString() },
+ toastHeaders,
+ confettiHeaders,
+ ),
+ },
+ )
+}
+
+export const headers: HeadersFunction = ({ loaderHeaders }) => {
+ const headers = {
+ 'Server-Timing': loaderHeaders.get('Server-Timing') ?? '',
+ }
+ return headers
+}
+
+const ThemeFormSchema = z.object({
+ theme: z.enum(['system', 'light', 'dark']),
+})
+
+export async function action({ request }: DataFunctionArgs) {
+ const formData = await request.formData()
+ invariantResponse(
+ formData.get('intent') === 'update-theme',
+ 'Invalid intent',
+ { status: 400 },
+ )
+ const submission = parse(formData, {
+ schema: ThemeFormSchema,
+ })
+ if (submission.intent !== 'submit') {
+ return json({ status: 'success', submission } as const)
+ }
+ if (!submission.value) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+ const { theme } = submission.value
+
+ const responseInit = {
+ headers: { 'set-cookie': setTheme(theme) },
+ }
+ return json({ success: true, submission }, responseInit)
+}
+
+function Document({
+ children,
+ nonce,
+ theme = 'light',
+ env = {},
+}: {
+ children: React.ReactNode
+ nonce: string
+ theme?: Theme
+ env?: Record
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+ )
+}
+
+function App() {
+ const data = useLoaderData()
+ const nonce = useNonce()
+ const user = useOptionalUser()
+ const theme = useTheme()
+ const matches = useMatches()
+ const isOnSearchPage = matches.find(m => m.id === 'routes/users+/index')
+
+ return (
+
+
+
+
+
+ )
+}
+export default withSentry(App)
+
+function UserDropdown() {
+ const user = useUser()
+ const submit = useSubmit()
+ const formRef = useRef(null)
+ return (
+
+
+
+ e.preventDefault()}
+ className="flex items-center gap-2"
+ >
+
+
+ {user.name ?? user.username}
+
+
+
+
+
+
+
+
+
+ Profile
+
+
+
+
+
+
+ Notes
+
+
+
+ {
+ event.preventDefault()
+ submit(formRef.current)
+ }}
+ >
+
+
+
+
+
+ )
+}
+
+/**
+ * @returns the user's theme preference, or the client hint theme if the user
+ * has not set a preference.
+ */
+export function useTheme() {
+ const hints = useHints()
+ const requestInfo = useRequestInfo()
+ const optimisticMode = useOptimisticThemeMode()
+ if (optimisticMode) {
+ return optimisticMode === 'system' ? hints.theme : optimisticMode
+ }
+ return requestInfo.userPrefs.theme ?? hints.theme
+}
+
+/**
+ * If the user's changing their theme mode preference, this will return the
+ * value it's being changed to.
+ */
+export function useOptimisticThemeMode() {
+ const fetchers = useFetchers()
+
+ const themeFetcher = fetchers.find(
+ f => f.formData?.get('intent') === 'update-theme',
+ )
+
+ if (themeFetcher && themeFetcher.formData) {
+ const submission = parse(themeFetcher.formData, {
+ schema: ThemeFormSchema,
+ })
+ return submission.value?.theme
+ }
+}
+
+function ThemeSwitch({ userPreference }: { userPreference?: Theme | null }) {
+ const fetcher = useFetcher()
+
+ const [form] = useForm({
+ id: 'theme-switch',
+ lastSubmission: fetcher.data?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: ThemeFormSchema })
+ },
+ })
+
+ const optimisticMode = useOptimisticThemeMode()
+ const mode = optimisticMode ?? userPreference ?? 'system'
+ const nextMode =
+ mode === 'system' ? 'light' : mode === 'light' ? 'dark' : 'system'
+ const modeLabel = {
+ light: (
+
+ Light
+
+ ),
+ dark: (
+
+ Dark
+
+ ),
+ system: (
+
+ System
+
+ ),
+ }
+
+ return (
+
+
+
+
+ {modeLabel[mode]}
+
+
+
+
+ )
+}
+
+export function ErrorBoundary() {
+ // the nonce doesn't rely on the loader so we can access that
+ const nonce = useNonce()
+
+ // NOTE: you cannot use useLoaderData in an ErrorBoundary because the loader
+ // likely failed to run so we have to do the best we can.
+ // We could probably do better than this (it's possible the loader did run).
+ // This would require a change in Remix.
+
+ // Just make sure your root route never errors out and you'll always be able
+ // to give the user a better UX.
+
+ return (
+
+
+
+ )
+}
diff --git a/app/routes/$.tsx b/app/routes/$.tsx
new file mode 100644
index 0000000..b26d52a
--- /dev/null
+++ b/app/routes/$.tsx
@@ -0,0 +1,43 @@
+// This is called a "splat route" and as it's in the root `/app/routes/`
+// directory, it's a catchall. If no other routes match, this one will and we
+// can know that the user is hitting a URL that doesn't exist. By throwing a
+// 404 from the loader, we can force the error boundary to render which will
+// ensure the user gets the right status code and we can display a nicer error
+// message for them than the Remix and/or browser default.
+
+import { Link, useLocation } from '@remix-run/react'
+import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
+import { Icon } from '#app/components/ui/icon.tsx'
+
+export async function loader() {
+ throw new Response('Not found', { status: 404 })
+}
+
+export default function NotFound() {
+ // due to the loader, this component will never be rendered, but we'll return
+ // the error boundary just in case.
+ return
+}
+
+export function ErrorBoundary() {
+ const location = useLocation()
+ return (
+ (
+
+
+
We can't find this page:
+
+ {location.pathname}
+
+
+
+
Back to home
+
+
+ ),
+ }}
+ />
+ )
+}
diff --git a/app/routes/_auth+/auth.$provider.callback.test.ts b/app/routes/_auth+/auth.$provider.callback.test.ts
new file mode 100644
index 0000000..a5fece6
--- /dev/null
+++ b/app/routes/_auth+/auth.$provider.callback.test.ts
@@ -0,0 +1,259 @@
+import { generateTOTP } from '@epic-web/totp'
+import { faker } from '@faker-js/faker'
+import { http } from 'msw'
+import { afterEach, expect, test } from 'vitest'
+import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.tsx'
+import { getSessionExpirationDate, sessionKey } from '#app/utils/auth.server.ts'
+import { connectionSessionStorage } from '#app/utils/connections.server.ts'
+import { GITHUB_PROVIDER_NAME } from '#app/utils/connections.tsx'
+import { prisma } from '#app/utils/db.server.ts'
+import { invariant } from '#app/utils/misc.tsx'
+import { sessionStorage } from '#app/utils/session.server.ts'
+import { createUser } from '#tests/db-utils.ts'
+import { insertGitHubUser, deleteGitHubUsers } from '#tests/mocks/github.ts'
+import { server } from '#tests/mocks/index.ts'
+import { consoleError } from '#tests/setup/setup-test-env.ts'
+import { BASE_URL, convertSetCookieToCookie } from '#tests/utils.ts'
+import { loader } from './auth.$provider.callback.ts'
+
+const ROUTE_PATH = '/auth/github/callback'
+const PARAMS = { provider: 'github' }
+
+afterEach(async () => {
+ await deleteGitHubUsers()
+})
+
+test('a new user goes to onboarding', async () => {
+ const request = await setupRequest()
+ const response = await loader({ request, params: PARAMS, context: {} }).catch(
+ e => e,
+ )
+ expect(response).toHaveRedirect('/onboarding/github')
+})
+
+test('when auth fails, send the user to login with a toast', async () => {
+ consoleError.mockImplementation(() => {})
+ server.use(
+ http.post('https://github.com/login/oauth/access_token', async () => {
+ return new Response('error', { status: 400 })
+ }),
+ )
+ const request = await setupRequest()
+ const response = await loader({ request, params: PARAMS, context: {} }).catch(
+ e => e,
+ )
+ invariant(response instanceof Response, 'response should be a Response')
+ expect(response).toHaveRedirect('/login')
+ await expect(response).toSendToast(
+ expect.objectContaining({
+ title: 'Auth Failed',
+ type: 'error',
+ }),
+ )
+ expect(consoleError).toHaveBeenCalledTimes(1)
+})
+
+test('when a user is logged in, it creates the connection', async () => {
+ const githubUser = await insertGitHubUser()
+ const session = await setupUser()
+ const request = await setupRequest({
+ sessionId: session.id,
+ code: githubUser.code,
+ })
+ const response = await loader({ request, params: PARAMS, context: {} })
+ expect(response).toHaveRedirect('/settings/profile/connections')
+ await expect(response).toSendToast(
+ expect.objectContaining({
+ title: 'Connected',
+ type: 'success',
+ description: expect.stringContaining(githubUser.profile.login),
+ }),
+ )
+ const connection = await prisma.connection.findFirst({
+ select: { id: true },
+ where: {
+ userId: session.userId,
+ providerId: githubUser.profile.id.toString(),
+ },
+ })
+ expect(
+ connection,
+ 'the connection was not created in the database',
+ ).toBeTruthy()
+})
+
+test(`when a user is logged in and has already connected, it doesn't do anything and just redirects the user back to the connections page`, async () => {
+ const session = await setupUser()
+ const githubUser = await insertGitHubUser()
+ await prisma.connection.create({
+ data: {
+ providerName: GITHUB_PROVIDER_NAME,
+ userId: session.userId,
+ providerId: githubUser.profile.id.toString(),
+ },
+ })
+ const request = await setupRequest({
+ sessionId: session.id,
+ code: githubUser.code,
+ })
+ const response = await loader({ request, params: PARAMS, context: {} })
+ expect(response).toHaveRedirect('/settings/profile/connections')
+ expect(response).toSendToast(
+ expect.objectContaining({
+ title: 'Already Connected',
+ description: expect.stringContaining(githubUser.profile.login),
+ }),
+ )
+})
+
+test('when a user exists with the same email, create connection and make session', async () => {
+ const githubUser = await insertGitHubUser()
+ const email = githubUser.primaryEmail.toLowerCase()
+ const { userId } = await setupUser({ ...createUser(), email })
+ const request = await setupRequest({ code: githubUser.code })
+ const response = await loader({ request, params: PARAMS, context: {} })
+
+ expect(response).toHaveRedirect('/')
+
+ await expect(response).toSendToast(
+ expect.objectContaining({
+ type: 'message',
+ description: expect.stringContaining(githubUser.profile.login),
+ }),
+ )
+
+ const connection = await prisma.connection.findFirst({
+ select: { id: true },
+ where: {
+ userId: userId,
+ providerId: githubUser.profile.id.toString(),
+ },
+ })
+ expect(
+ connection,
+ 'the connection was not created in the database',
+ ).toBeTruthy()
+
+ await expect(response).toHaveSessionForUser(userId)
+})
+
+test('gives an error if the account is already connected to another user', async () => {
+ const githubUser = await insertGitHubUser()
+ await prisma.user.create({
+ data: {
+ ...createUser(),
+ connections: {
+ create: {
+ providerName: GITHUB_PROVIDER_NAME,
+ providerId: githubUser.profile.id.toString(),
+ },
+ },
+ },
+ })
+ const session = await setupUser()
+ const request = await setupRequest({
+ sessionId: session.id,
+ code: githubUser.code,
+ })
+ const response = await loader({ request, params: PARAMS, context: {} })
+ expect(response).toHaveRedirect('/settings/profile/connections')
+ await expect(response).toSendToast(
+ expect.objectContaining({
+ title: 'Already Connected',
+ description: expect.stringContaining(
+ 'already connected to another account',
+ ),
+ }),
+ )
+})
+
+test('if a user is not logged in, but the connection exists, make a session', async () => {
+ const githubUser = await insertGitHubUser()
+ const { userId } = await setupUser()
+ await prisma.connection.create({
+ data: {
+ providerName: GITHUB_PROVIDER_NAME,
+ providerId: githubUser.profile.id.toString(),
+ userId,
+ },
+ })
+ const request = await setupRequest({ code: githubUser.code })
+ const response = await loader({ request, params: PARAMS, context: {} })
+ expect(response).toHaveRedirect('/')
+ await expect(response).toHaveSessionForUser(userId)
+})
+
+test('if a user is not logged in, but the connection exists and they have enabled 2FA, send them to verify their 2FA and do not make a session', async () => {
+ const githubUser = await insertGitHubUser()
+ const { userId } = await setupUser()
+ await prisma.connection.create({
+ data: {
+ providerName: GITHUB_PROVIDER_NAME,
+ providerId: githubUser.profile.id.toString(),
+ userId,
+ },
+ })
+ const { otp: _otp, ...config } = generateTOTP()
+ await prisma.verification.create({
+ data: {
+ type: twoFAVerificationType,
+ target: userId,
+ ...config,
+ },
+ })
+ const request = await setupRequest({ code: githubUser.code })
+ const response = await loader({ request, params: PARAMS, context: {} })
+ const searchParams = new URLSearchParams({
+ type: twoFAVerificationType,
+ target: userId,
+ redirectTo: '/',
+ })
+ expect(response).toHaveRedirect(`/verify?${searchParams}`)
+})
+
+async function setupRequest({
+ sessionId,
+ code = faker.string.uuid(),
+}: { sessionId?: string; code?: string } = {}) {
+ const url = new URL(ROUTE_PATH, BASE_URL)
+ const state = faker.string.uuid()
+ url.searchParams.set('state', state)
+ url.searchParams.set('code', code)
+ const connectionSession = await connectionSessionStorage.getSession()
+ connectionSession.set('oauth2:state', state)
+ const cookieSession = await sessionStorage.getSession()
+ if (sessionId) cookieSession.set(sessionKey, sessionId)
+ const setSessionCookieHeader =
+ await sessionStorage.commitSession(cookieSession)
+ const setConnectionSessionCookieHeader =
+ await connectionSessionStorage.commitSession(connectionSession)
+ const request = new Request(url.toString(), {
+ method: 'GET',
+ headers: {
+ cookie: [
+ convertSetCookieToCookie(setConnectionSessionCookieHeader),
+ convertSetCookieToCookie(setSessionCookieHeader),
+ ].join('; '),
+ },
+ })
+ return request
+}
+
+async function setupUser(userData = createUser()) {
+ const session = await prisma.session.create({
+ data: {
+ expirationDate: getSessionExpirationDate(),
+ user: {
+ create: {
+ ...userData,
+ },
+ },
+ },
+ select: {
+ id: true,
+ userId: true,
+ },
+ })
+
+ return session
+}
diff --git a/app/routes/_auth+/auth.$provider.callback.ts b/app/routes/_auth+/auth.$provider.callback.ts
new file mode 100644
index 0000000..5dc3902
--- /dev/null
+++ b/app/routes/_auth+/auth.$provider.callback.ts
@@ -0,0 +1,179 @@
+import { redirect, type DataFunctionArgs } from '@remix-run/node'
+import {
+ authenticator,
+ getSessionExpirationDate,
+ getUserId,
+} from '#app/utils/auth.server.ts'
+import { ProviderNameSchema, providerLabels } from '#app/utils/connections.tsx'
+import { prisma } from '#app/utils/db.server.ts'
+import { combineHeaders } from '#app/utils/misc.tsx'
+import {
+ destroyRedirectToHeader,
+ getRedirectCookieValue,
+} from '#app/utils/redirect-cookie.server.ts'
+import {
+ createToastHeaders,
+ redirectWithToast,
+} from '#app/utils/toast.server.ts'
+import { verifySessionStorage } from '#app/utils/verification.server.ts'
+import { handleNewSession } from './login.tsx'
+import {
+ onboardingEmailSessionKey,
+ prefilledProfileKey,
+ providerIdKey,
+} from './onboarding_.$provider.tsx'
+
+const destroyRedirectTo = { 'set-cookie': destroyRedirectToHeader }
+
+export async function loader({ request, params }: DataFunctionArgs) {
+ const providerName = ProviderNameSchema.parse(params.provider)
+ const redirectTo = getRedirectCookieValue(request)
+ const label = providerLabels[providerName]
+
+ const authResult = await authenticator
+ .authenticate(providerName, request, { throwOnError: true })
+ .then(
+ data => ({ success: true, data }) as const,
+ error => ({ success: false, error }) as const,
+ )
+
+ if (!authResult.success) {
+ console.error(authResult.error)
+ throw await redirectWithToast(
+ '/login',
+ {
+ title: 'Auth Failed',
+ description: `There was an error authenticating with ${label}.`,
+ type: 'error',
+ },
+ { headers: destroyRedirectTo },
+ )
+ }
+
+ const { data: profile } = authResult
+
+ const existingConnection = await prisma.connection.findUnique({
+ select: { userId: true },
+ where: {
+ providerName_providerId: { providerName, providerId: profile.id },
+ },
+ })
+
+ const userId = await getUserId(request)
+
+ if (existingConnection && userId) {
+ if (existingConnection.userId === userId) {
+ return redirectWithToast(
+ '/settings/profile/connections',
+ {
+ title: 'Already Connected',
+ description: `Your "${profile.username}" ${label} account is already connected.`,
+ },
+ { headers: destroyRedirectTo },
+ )
+ } else {
+ return redirectWithToast(
+ '/settings/profile/connections',
+ {
+ title: 'Already Connected',
+ description: `The "${profile.username}" ${label} account is already connected to another account.`,
+ },
+ { headers: destroyRedirectTo },
+ )
+ }
+ }
+
+ // If we're already logged in, then link the account
+ if (userId) {
+ await prisma.connection.create({
+ data: {
+ providerName,
+ providerId: profile.id,
+ userId,
+ },
+ })
+ return redirectWithToast(
+ '/settings/profile/connections',
+ {
+ title: 'Connected',
+ type: 'success',
+ description: `Your "${profile.username}" ${label} account has been connected.`,
+ },
+ { headers: destroyRedirectTo },
+ )
+ }
+
+ // Connection exists already? Make a new session
+ if (existingConnection) {
+ return makeSession({ request, userId: existingConnection.userId })
+ }
+
+ // if the email matches a user in the db, then link the account and
+ // make a new session
+ const user = await prisma.user.findUnique({
+ select: { id: true },
+ where: { email: profile.email.toLowerCase() },
+ })
+ if (user) {
+ await prisma.connection.create({
+ data: {
+ providerName,
+ providerId: profile.id,
+ userId: user.id,
+ },
+ })
+ return makeSession(
+ { request, userId: user.id },
+ {
+ headers: await createToastHeaders({
+ title: 'Connected',
+ description: `Your "${profile.username}" ${label} account has been connected.`,
+ }),
+ },
+ )
+ }
+
+ // this is a new user, so let's get them onboarded
+ const verifySession = await verifySessionStorage.getSession()
+ verifySession.set(onboardingEmailSessionKey, profile.email)
+ verifySession.set(prefilledProfileKey, {
+ ...profile,
+ email: profile.email.toLowerCase(),
+ username: profile.username?.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase(),
+ })
+ verifySession.set(providerIdKey, profile.id)
+ const onboardingRedirect = [
+ `/onboarding/${providerName}`,
+ redirectTo ? new URLSearchParams({ redirectTo }) : null,
+ ]
+ .filter(Boolean)
+ .join('?')
+ return redirect(onboardingRedirect, {
+ headers: combineHeaders(
+ { 'set-cookie': await verifySessionStorage.commitSession(verifySession) },
+ destroyRedirectTo,
+ ),
+ })
+}
+
+async function makeSession(
+ {
+ request,
+ userId,
+ redirectTo,
+ }: { request: Request; userId: string; redirectTo?: string | null },
+ responseInit?: ResponseInit,
+) {
+ redirectTo ??= '/'
+ const session = await prisma.session.create({
+ select: { id: true, expirationDate: true, userId: true },
+ data: {
+ expirationDate: getSessionExpirationDate(),
+ userId,
+ },
+ })
+ return handleNewSession(
+ { request, session, redirectTo, remember: true },
+ { headers: combineHeaders(responseInit?.headers, destroyRedirectTo) },
+ )
+}
diff --git a/app/routes/_auth+/auth.$provider.ts b/app/routes/_auth+/auth.$provider.ts
new file mode 100644
index 0000000..a9af842
--- /dev/null
+++ b/app/routes/_auth+/auth.$provider.ts
@@ -0,0 +1,33 @@
+import { redirect, type DataFunctionArgs } from '@remix-run/node'
+import { authenticator } from '#app/utils/auth.server.ts'
+import { handleMockAction } from '#app/utils/connections.server.ts'
+import { ProviderNameSchema } from '#app/utils/connections.tsx'
+import { getReferrerRoute } from '#app/utils/misc.tsx'
+import { getRedirectCookieHeader } from '#app/utils/redirect-cookie.server.ts'
+
+export async function loader() {
+ return redirect('/login')
+}
+
+export async function action({ request, params }: DataFunctionArgs) {
+ const providerName = ProviderNameSchema.parse(params.provider)
+
+ try {
+ await handleMockAction(providerName, request)
+ return await authenticator.authenticate(providerName, request)
+ } catch (error: unknown) {
+ if (error instanceof Response) {
+ const formData = await request.formData()
+ const rawRedirectTo = formData.get('redirectTo')
+ const redirectTo =
+ typeof rawRedirectTo === 'string'
+ ? rawRedirectTo
+ : getReferrerRoute(request)
+ const redirectToCookie = getRedirectCookieHeader(redirectTo)
+ if (redirectToCookie) {
+ error.headers.append('set-cookie', redirectToCookie)
+ }
+ }
+ throw error
+ }
+}
diff --git a/app/routes/_auth+/forgot-password.tsx b/app/routes/_auth+/forgot-password.tsx
new file mode 100644
index 0000000..6e2a67d
--- /dev/null
+++ b/app/routes/_auth+/forgot-password.tsx
@@ -0,0 +1,183 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import * as E from '@react-email/components'
+import {
+ json,
+ redirect,
+ type DataFunctionArgs,
+ type V2_MetaFunction,
+} from '@remix-run/node'
+import { Link, useFetcher } from '@remix-run/react'
+import { z } from 'zod'
+import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
+import { ErrorList, Field } from '#app/components/forms.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import { prisma } from '#app/utils/db.server.ts'
+import { sendEmail } from '#app/utils/email.server.ts'
+import { EmailSchema, UsernameSchema } from '#app/utils/user-validation.ts'
+import { prepareVerification } from './verify.tsx'
+
+const ForgotPasswordSchema = z.object({
+ usernameOrEmail: z.union([EmailSchema, UsernameSchema]),
+})
+
+export async function action({ request }: DataFunctionArgs) {
+ const formData = await request.formData()
+ const submission = await parse(formData, {
+ schema: ForgotPasswordSchema.superRefine(async (data, ctx) => {
+ const user = await prisma.user.findFirst({
+ where: {
+ OR: [
+ { email: data.usernameOrEmail },
+ { username: data.usernameOrEmail },
+ ],
+ },
+ select: { id: true },
+ })
+ if (!user) {
+ ctx.addIssue({
+ path: ['usernameOrEmail'],
+ code: z.ZodIssueCode.custom,
+ message: 'No user exists with this username or email',
+ })
+ return
+ }
+ }),
+ async: true,
+ })
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+ const { usernameOrEmail } = submission.value
+
+ const user = await prisma.user.findFirstOrThrow({
+ where: { OR: [{ email: usernameOrEmail }, { username: usernameOrEmail }] },
+ select: { email: true, username: true },
+ })
+
+ const { verifyUrl, redirectTo, otp } = await prepareVerification({
+ period: 10 * 60,
+ request,
+ type: 'reset-password',
+ target: usernameOrEmail,
+ })
+
+ const response = await sendEmail({
+ to: user.email,
+ subject: `Epic Notes Password Reset`,
+ react: (
+
+ ),
+ })
+
+ if (response.status === 'success') {
+ return redirect(redirectTo.toString())
+ } else {
+ submission.error[''] = [response.error.message]
+ return json({ status: 'error', submission } as const, { status: 500 })
+ }
+}
+
+function ForgotPasswordEmail({
+ onboardingUrl,
+ otp,
+}: {
+ onboardingUrl: string
+ otp: string
+}) {
+ return (
+
+
+
+ Epic Notes Password Reset
+
+
+
+ Here's your verification code: {otp}
+
+
+
+ Or click the link:
+
+ {onboardingUrl}
+
+
+ )
+}
+
+export const meta: V2_MetaFunction = () => {
+ return [{ title: 'Password Recovery for Epic Notes' }]
+}
+
+export default function ForgotPasswordRoute() {
+ const forgotPassword = useFetcher()
+
+ const [form, fields] = useForm({
+ id: 'forgot-password-form',
+ constraint: getFieldsetConstraint(ForgotPasswordSchema),
+ lastSubmission: forgotPassword.data?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: ForgotPasswordSchema })
+ },
+ shouldRevalidate: 'onBlur',
+ })
+
+ return (
+
+
+
+
Forgot Password
+
+ No worries, we'll send you reset instructions.
+
+
+
+
+
+
+
+
+
+
+
+ Recover password
+
+
+
+
+ Back to Login
+
+
+
+
+ )
+}
+
+export function ErrorBoundary() {
+ return
+}
diff --git a/app/routes/_auth+/login.tsx b/app/routes/_auth+/login.tsx
new file mode 100644
index 0000000..af05acb
--- /dev/null
+++ b/app/routes/_auth+/login.tsx
@@ -0,0 +1,359 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import {
+ json,
+ redirect,
+ type DataFunctionArgs,
+ type V2_MetaFunction,
+} from '@remix-run/node'
+import { Form, Link, useActionData, useSearchParams } from '@remix-run/react'
+import { safeRedirect } from 'remix-utils'
+import { z } from 'zod'
+import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
+import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx'
+import { Spacer } from '#app/components/spacer.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.tsx'
+import {
+ getUserId,
+ login,
+ requireAnonymous,
+ sessionKey,
+} from '#app/utils/auth.server.ts'
+import {
+ ProviderConnectionForm,
+ providerNames,
+} from '#app/utils/connections.tsx'
+import { prisma } from '#app/utils/db.server.ts'
+import {
+ combineResponseInits,
+ invariant,
+ useIsPending,
+} from '#app/utils/misc.tsx'
+import { sessionStorage } from '#app/utils/session.server.ts'
+import { redirectWithToast } from '#app/utils/toast.server.ts'
+import { PasswordSchema, UsernameSchema } from '#app/utils/user-validation.ts'
+import { verifySessionStorage } from '#app/utils/verification.server.ts'
+import { getRedirectToUrl, type VerifyFunctionArgs } from './verify.tsx'
+
+const verifiedTimeKey = 'verified-time'
+const unverifiedSessionIdKey = 'unverified-session-id'
+const rememberKey = 'remember'
+
+export async function handleNewSession(
+ {
+ request,
+ session,
+ redirectTo,
+ remember,
+ }: {
+ request: Request
+ session: { userId: string; id: string; expirationDate: Date }
+ redirectTo?: string
+ remember: boolean
+ },
+ responseInit?: ResponseInit,
+) {
+ const verification = await prisma.verification.findUnique({
+ select: { id: true },
+ where: {
+ target_type: { target: session.userId, type: twoFAVerificationType },
+ },
+ })
+ const userHasTwoFactor = Boolean(verification)
+
+ if (userHasTwoFactor) {
+ const verifySession = await verifySessionStorage.getSession()
+ verifySession.set(unverifiedSessionIdKey, session.id)
+ verifySession.set(rememberKey, remember)
+ const redirectUrl = getRedirectToUrl({
+ request,
+ type: twoFAVerificationType,
+ target: session.userId,
+ redirectTo,
+ })
+ return redirect(
+ `${redirectUrl.pathname}?${redirectUrl.searchParams}`,
+ combineResponseInits(
+ {
+ headers: {
+ 'set-cookie':
+ await verifySessionStorage.commitSession(verifySession),
+ },
+ },
+ responseInit,
+ ),
+ )
+ } else {
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ cookieSession.set(sessionKey, session.id)
+
+ return redirect(
+ safeRedirect(redirectTo),
+ combineResponseInits(
+ {
+ headers: {
+ 'set-cookie': await sessionStorage.commitSession(cookieSession, {
+ expires: remember ? session.expirationDate : undefined,
+ }),
+ },
+ },
+ responseInit,
+ ),
+ )
+ }
+}
+
+export async function handleVerification({
+ request,
+ submission,
+}: VerifyFunctionArgs) {
+ invariant(submission.value, 'Submission should have a value by this point')
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+
+ const remember = verifySession.get(rememberKey)
+ const { redirectTo } = submission.value
+ const headers = new Headers()
+ cookieSession.set(verifiedTimeKey, Date.now())
+
+ const unverifiedSessionId = verifySession.get(unverifiedSessionIdKey)
+ if (unverifiedSessionId) {
+ const session = await prisma.session.findUnique({
+ select: { expirationDate: true },
+ where: { id: unverifiedSessionId },
+ })
+ if (!session) {
+ throw await redirectWithToast('/login', {
+ type: 'error',
+ title: 'Invalid session',
+ description: 'Could not find session to verify. Please try again.',
+ })
+ }
+ cookieSession.set(sessionKey, unverifiedSessionId)
+
+ headers.append(
+ 'set-cookie',
+ await sessionStorage.commitSession(cookieSession, {
+ expires: remember ? session.expirationDate : undefined,
+ }),
+ )
+ } else {
+ headers.append(
+ 'set-cookie',
+ await sessionStorage.commitSession(cookieSession),
+ )
+ }
+
+ headers.append(
+ 'set-cookie',
+ await verifySessionStorage.destroySession(verifySession),
+ )
+
+ return redirect(safeRedirect(redirectTo), { headers })
+}
+
+export async function shouldRequestTwoFA(request: Request) {
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ if (verifySession.has(unverifiedSessionIdKey)) return true
+ const userId = await getUserId(request)
+ if (!userId) return false
+ // if it's over two hours since they last verified, we should request 2FA again
+ const userHasTwoFA = await prisma.verification.findUnique({
+ select: { id: true },
+ where: { target_type: { target: userId, type: twoFAVerificationType } },
+ })
+ if (!userHasTwoFA) return false
+ const verifiedTime = cookieSession.get(verifiedTimeKey) ?? new Date(0)
+ const twoHours = 1000 * 60 * 2
+ return Date.now() - verifiedTime > twoHours
+}
+
+const LoginFormSchema = z.object({
+ username: UsernameSchema,
+ password: PasswordSchema,
+ redirectTo: z.string().optional(),
+ remember: z.boolean().optional(),
+})
+
+export async function loader({ request }: DataFunctionArgs) {
+ await requireAnonymous(request)
+ return json({})
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ await requireAnonymous(request)
+ const formData = await request.formData()
+ const submission = await parse(formData, {
+ schema: intent =>
+ LoginFormSchema.transform(async (data, ctx) => {
+ if (intent !== 'submit') return { ...data, session: null }
+
+ const session = await login(data)
+ if (!session) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Invalid username or password',
+ })
+ return z.NEVER
+ }
+
+ return { ...data, session }
+ }),
+ async: true,
+ })
+ // get the password off the payload that's sent back
+ delete submission.payload.password
+
+ if (submission.intent !== 'submit') {
+ // @ts-expect-error - conform should probably have support for doing this
+ delete submission.value?.password
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value?.session) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ const { session, remember, redirectTo } = submission.value
+
+ return handleNewSession({
+ request,
+ session,
+ remember: remember ?? false,
+ redirectTo,
+ })
+}
+
+export default function LoginPage() {
+ const actionData = useActionData()
+ const isPending = useIsPending()
+ const [searchParams] = useSearchParams()
+ const redirectTo = searchParams.get('redirectTo')
+
+ const [form, fields] = useForm({
+ id: 'login-form',
+ constraint: getFieldsetConstraint(LoginFormSchema),
+ defaultValue: { redirectTo },
+ lastSubmission: actionData?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: LoginFormSchema })
+ },
+ shouldRevalidate: 'onBlur',
+ })
+
+ return (
+
+
+
+
Welcome back!
+
+ Please enter your details.
+
+
+
+
+
+
+
+
+ {providerNames.map(providerName => (
+
+ ))}
+
+
+ New here?
+
+ Create an account
+
+
+
+
+
+
+ )
+}
+
+export const meta: V2_MetaFunction = () => {
+ return [{ title: 'Login to Epic Notes' }]
+}
+
+export function ErrorBoundary() {
+ return
+}
diff --git a/app/routes/_auth+/logout.tsx b/app/routes/_auth+/logout.tsx
new file mode 100644
index 0000000..5f2b2b5
--- /dev/null
+++ b/app/routes/_auth+/logout.tsx
@@ -0,0 +1,10 @@
+import { redirect, type DataFunctionArgs } from '@remix-run/node'
+import { logout } from '#app/utils/auth.server.ts'
+
+export async function loader() {
+ return redirect('/')
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ return logout({ request })
+}
diff --git a/app/routes/_auth+/onboarding.tsx b/app/routes/_auth+/onboarding.tsx
new file mode 100644
index 0000000..963fd03
--- /dev/null
+++ b/app/routes/_auth+/onboarding.tsx
@@ -0,0 +1,251 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import {
+ json,
+ redirect,
+ type DataFunctionArgs,
+ type V2_MetaFunction,
+} from '@remix-run/node'
+import {
+ Form,
+ useActionData,
+ useLoaderData,
+ useSearchParams,
+} from '@remix-run/react'
+import { safeRedirect } from 'remix-utils'
+import { z } from 'zod'
+import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx'
+import { Spacer } from '#app/components/spacer.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import { requireAnonymous, sessionKey, signup } from '#app/utils/auth.server.ts'
+import { redirectWithConfetti } from '#app/utils/confetti.server.ts'
+import { prisma } from '#app/utils/db.server.ts'
+import { invariant, useIsPending } from '#app/utils/misc.tsx'
+import { sessionStorage } from '#app/utils/session.server.ts'
+import {
+ NameSchema,
+ PasswordSchema,
+ UsernameSchema,
+} from '#app/utils/user-validation.ts'
+import { verifySessionStorage } from '#app/utils/verification.server.ts'
+import { type VerifyFunctionArgs } from './verify.tsx'
+
+const onboardingEmailSessionKey = 'onboardingEmail'
+
+const SignupFormSchema = z
+ .object({
+ username: UsernameSchema,
+ name: NameSchema,
+ password: PasswordSchema,
+ confirmPassword: PasswordSchema,
+ agreeToTermsOfServiceAndPrivacyPolicy: z.boolean({
+ required_error:
+ 'You must agree to the terms of service and privacy policy',
+ }),
+ remember: z.boolean().optional(),
+ redirectTo: z.string().optional(),
+ })
+ .superRefine(({ confirmPassword, password }, ctx) => {
+ if (confirmPassword !== password) {
+ ctx.addIssue({
+ path: ['confirmPassword'],
+ code: 'custom',
+ message: 'The passwords must match',
+ })
+ }
+ })
+
+async function requireOnboardingEmail(request: Request) {
+ await requireAnonymous(request)
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const email = verifySession.get(onboardingEmailSessionKey)
+ if (typeof email !== 'string' || !email) {
+ throw redirect('/signup')
+ }
+ return email
+}
+export async function loader({ request }: DataFunctionArgs) {
+ const email = await requireOnboardingEmail(request)
+ return json({ email })
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const email = await requireOnboardingEmail(request)
+ const formData = await request.formData()
+ const submission = await parse(formData, {
+ schema: SignupFormSchema.superRefine(async (data, ctx) => {
+ const existingUser = await prisma.user.findUnique({
+ where: { username: data.username },
+ select: { id: true },
+ })
+ if (existingUser) {
+ ctx.addIssue({
+ path: ['username'],
+ code: z.ZodIssueCode.custom,
+ message: 'A user already exists with this username',
+ })
+ return
+ }
+ }).transform(async data => {
+ const session = await signup({ ...data, email })
+ return { ...data, session }
+ }),
+ async: true,
+ })
+
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value?.session) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ const { session, remember, redirectTo } = submission.value
+
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ cookieSession.set(sessionKey, session.id)
+ const verifySession = await verifySessionStorage.getSession()
+ const headers = new Headers()
+ headers.append(
+ 'set-cookie',
+ await sessionStorage.commitSession(cookieSession, {
+ expires: remember ? session.expirationDate : undefined,
+ }),
+ )
+ headers.append(
+ 'set-cookie',
+ await verifySessionStorage.destroySession(verifySession),
+ )
+
+ return redirectWithConfetti(safeRedirect(redirectTo), { headers })
+}
+
+export async function handleVerification({ submission }: VerifyFunctionArgs) {
+ invariant(submission.value, 'submission.value should be defined by now')
+ const verifySession = await verifySessionStorage.getSession()
+ verifySession.set(onboardingEmailSessionKey, submission.value.target)
+ return redirect('/onboarding', {
+ headers: {
+ 'set-cookie': await verifySessionStorage.commitSession(verifySession),
+ },
+ })
+}
+
+export const meta: V2_MetaFunction = () => {
+ return [{ title: 'Setup Epic Notes Account' }]
+}
+
+export default function SignupRoute() {
+ const data = useLoaderData()
+ const actionData = useActionData()
+ const isPending = useIsPending()
+ const [searchParams] = useSearchParams()
+ const redirectTo = searchParams.get('redirectTo')
+
+ const [form, fields] = useForm({
+ id: 'onboarding-form',
+ constraint: getFieldsetConstraint(SignupFormSchema),
+ defaultValue: { redirectTo },
+ lastSubmission: actionData?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: SignupFormSchema })
+ },
+ shouldRevalidate: 'onBlur',
+ })
+
+ return (
+
+
+
+
Welcome aboard {data.email}!
+
+ Please enter your details.
+
+
+
+
+
+
+ )
+}
diff --git a/app/routes/_auth+/onboarding_.$provider.tsx b/app/routes/_auth+/onboarding_.$provider.tsx
new file mode 100644
index 0000000..75d2be2
--- /dev/null
+++ b/app/routes/_auth+/onboarding_.$provider.tsx
@@ -0,0 +1,288 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import {
+ json,
+ redirect,
+ type DataFunctionArgs,
+ type V2_MetaFunction,
+} from '@remix-run/node'
+import {
+ Form,
+ useActionData,
+ useLoaderData,
+ useSearchParams,
+ type Params,
+} from '@remix-run/react'
+import { safeRedirect } from 'remix-utils'
+import { z } from 'zod'
+import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx'
+import { Spacer } from '#app/components/spacer.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import {
+ authenticator,
+ requireAnonymous,
+ sessionKey,
+ signupWithConnection,
+} from '#app/utils/auth.server.ts'
+import { redirectWithConfetti } from '#app/utils/confetti.server.ts'
+import { ProviderNameSchema } from '#app/utils/connections.tsx'
+import { prisma } from '#app/utils/db.server.ts'
+import { invariant, useIsPending } from '#app/utils/misc.tsx'
+import { sessionStorage } from '#app/utils/session.server.ts'
+import { NameSchema, UsernameSchema } from '#app/utils/user-validation.ts'
+import { verifySessionStorage } from '#app/utils/verification.server.ts'
+import { type VerifyFunctionArgs } from './verify.tsx'
+
+export const onboardingEmailSessionKey = 'onboardingEmail'
+export const providerIdKey = 'providerId'
+export const prefilledProfileKey = 'prefilledProfile'
+
+const SignupFormSchema = z.object({
+ imageUrl: z.string().optional(),
+ username: UsernameSchema,
+ name: NameSchema,
+ agreeToTermsOfServiceAndPrivacyPolicy: z.boolean({
+ required_error: 'You must agree to the terms of service and privacy policy',
+ }),
+ remember: z.boolean().optional(),
+ redirectTo: z.string().optional(),
+})
+
+async function requireData({
+ request,
+ params,
+}: {
+ request: Request
+ params: Params
+}) {
+ await requireAnonymous(request)
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const email = verifySession.get(onboardingEmailSessionKey)
+ const providerId = verifySession.get(providerIdKey)
+ const result = z
+ .object({
+ email: z.string(),
+ providerName: ProviderNameSchema,
+ providerId: z.string(),
+ })
+ .safeParse({ email, providerName: params.provider, providerId })
+ if (result.success) {
+ return result.data
+ } else {
+ console.error(result.error)
+ throw redirect('/signup')
+ }
+}
+
+export async function loader({ request, params }: DataFunctionArgs) {
+ const { email } = await requireData({ request, params })
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const prefilledProfile = verifySession.get(prefilledProfileKey)
+
+ const formError = cookieSession.get(authenticator.sessionErrorKey)
+
+ return json({
+ email,
+ formError: typeof formError === 'string' ? formError : null,
+ status: 'idle',
+ submission: {
+ intent: '',
+ payload: (prefilledProfile ?? {}) as {},
+ error: {
+ '': typeof formError === 'string' ? [formError] : [],
+ },
+ },
+ })
+}
+
+export async function action({ request, params }: DataFunctionArgs) {
+ const { email, providerId, providerName } = await requireData({
+ request,
+ params,
+ })
+ const formData = await request.formData()
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+
+ const submission = await parse(formData, {
+ schema: SignupFormSchema.superRefine(async (data, ctx) => {
+ const existingUser = await prisma.user.findUnique({
+ where: { username: data.username },
+ select: { id: true },
+ })
+ if (existingUser) {
+ ctx.addIssue({
+ path: ['username'],
+ code: z.ZodIssueCode.custom,
+ message: 'A user already exists with this username',
+ })
+ return
+ }
+ }).transform(async data => {
+ const session = await signupWithConnection({
+ ...data,
+ email,
+ providerId,
+ providerName,
+ })
+ return { ...data, session }
+ }),
+ async: true,
+ })
+
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value?.session) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ const { session, remember, redirectTo } = submission.value
+
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ cookieSession.set(sessionKey, session.id)
+ const headers = new Headers()
+ headers.append(
+ 'set-cookie',
+ await sessionStorage.commitSession(cookieSession, {
+ expires: remember ? session.expirationDate : undefined,
+ }),
+ )
+ headers.append(
+ 'set-cookie',
+ await verifySessionStorage.destroySession(verifySession),
+ )
+
+ return redirectWithConfetti(safeRedirect(redirectTo), { headers })
+}
+
+export async function handleVerification({ submission }: VerifyFunctionArgs) {
+ invariant(submission.value, 'submission.value should be defined by now')
+ const verifySession = await verifySessionStorage.getSession()
+ verifySession.set(onboardingEmailSessionKey, submission.value.target)
+ return redirect('/onboarding', {
+ headers: {
+ 'set-cookie': await verifySessionStorage.commitSession(verifySession),
+ },
+ })
+}
+
+export const meta: V2_MetaFunction = () => {
+ return [{ title: 'Setup Epic Notes Account' }]
+}
+
+export default function SignupRoute() {
+ const data = useLoaderData()
+ const actionData = useActionData()
+ const isPending = useIsPending()
+ const [searchParams] = useSearchParams()
+ const redirectTo = searchParams.get('redirectTo')
+
+ const [form, fields] = useForm({
+ id: 'onboarding-provider-form',
+ constraint: getFieldsetConstraint(SignupFormSchema),
+ lastSubmission: actionData?.submission ?? data.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: SignupFormSchema })
+ },
+ shouldRevalidate: 'onBlur',
+ })
+
+ return (
+
+
+
+
Welcome aboard {data.email}!
+
+ Please enter your details.
+
+
+
+
+
+
+ )
+}
diff --git a/app/routes/_auth+/reset-password.tsx b/app/routes/_auth+/reset-password.tsx
new file mode 100644
index 0000000..a29f868
--- /dev/null
+++ b/app/routes/_auth+/reset-password.tsx
@@ -0,0 +1,172 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import {
+ json,
+ redirect,
+ type DataFunctionArgs,
+ type V2_MetaFunction,
+} from '@remix-run/node'
+import { Form, useActionData, useLoaderData } from '@remix-run/react'
+import { z } from 'zod'
+import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
+import { ErrorList, Field } from '#app/components/forms.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import { requireAnonymous, resetUserPassword } from '#app/utils/auth.server.ts'
+import { prisma } from '#app/utils/db.server.ts'
+import { invariant, useIsPending } from '#app/utils/misc.tsx'
+import { PasswordSchema } from '#app/utils/user-validation.ts'
+import { verifySessionStorage } from '#app/utils/verification.server.ts'
+import { type VerifyFunctionArgs } from './verify.tsx'
+
+const resetPasswordUsernameSessionKey = 'resetPasswordUsername'
+
+export async function handleVerification({
+ request,
+ submission,
+}: VerifyFunctionArgs) {
+ invariant(submission.value, 'submission.value should be defined by now')
+ const target = submission.value.target
+ const user = await prisma.user.findFirst({
+ where: { OR: [{ email: target }, { username: target }] },
+ select: { email: true, username: true },
+ })
+ // we don't want to say the user is not found if the email is not found
+ // because that would allow an attacker to check if an email is registered
+ if (!user) {
+ submission.error.code = ['Invalid code']
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ const verifySession = await verifySessionStorage.getSession()
+ verifySession.set(resetPasswordUsernameSessionKey, user.username)
+ return redirect('/reset-password', {
+ headers: {
+ 'set-cookie': await verifySessionStorage.commitSession(verifySession),
+ },
+ })
+}
+
+const ResetPasswordSchema = z
+ .object({
+ password: PasswordSchema,
+ confirmPassword: PasswordSchema,
+ })
+ .refine(({ confirmPassword, password }) => password === confirmPassword, {
+ message: 'The passwords did not match',
+ path: ['confirmPassword'],
+ })
+
+async function requireResetPasswordUsername(request: Request) {
+ await requireAnonymous(request)
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const resetPasswordUsername = verifySession.get(
+ resetPasswordUsernameSessionKey,
+ )
+ if (typeof resetPasswordUsername !== 'string' || !resetPasswordUsername) {
+ throw redirect('/login')
+ }
+ return resetPasswordUsername
+}
+
+export async function loader({ request }: DataFunctionArgs) {
+ const resetPasswordUsername = await requireResetPasswordUsername(request)
+ return json({ resetPasswordUsername })
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const resetPasswordUsername = await requireResetPasswordUsername(request)
+ const formData = await request.formData()
+ const submission = parse(formData, {
+ schema: ResetPasswordSchema,
+ })
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value?.password) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+ const { password } = submission.value
+
+ await resetUserPassword({ username: resetPasswordUsername, password })
+ const verifySession = await verifySessionStorage.getSession()
+ return redirect('/login', {
+ headers: {
+ 'set-cookie': await verifySessionStorage.destroySession(verifySession),
+ },
+ })
+}
+
+export const meta: V2_MetaFunction = () => {
+ return [{ title: 'Reset Password | Epic Notes' }]
+}
+
+export default function ResetPasswordPage() {
+ const data = useLoaderData()
+ const actionData = useActionData()
+ const isPending = useIsPending()
+
+ const [form, fields] = useForm({
+ id: 'reset-password',
+ constraint: getFieldsetConstraint(ResetPasswordSchema),
+ lastSubmission: actionData?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: ResetPasswordSchema })
+ },
+ shouldRevalidate: 'onBlur',
+ })
+
+ return (
+
+
+
Password Reset
+
+ Hi, {data.resetPasswordUsername}. No worries. It happens all the time.
+
+
+
+
+
+
+ )
+}
+
+export function ErrorBoundary() {
+ return
+}
diff --git a/app/routes/_auth+/signup.tsx b/app/routes/_auth+/signup.tsx
new file mode 100644
index 0000000..de8fa41
--- /dev/null
+++ b/app/routes/_auth+/signup.tsx
@@ -0,0 +1,169 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import * as E from '@react-email/components'
+import {
+ json,
+ redirect,
+ type DataFunctionArgs,
+ type V2_MetaFunction,
+} from '@remix-run/node'
+import { Form, useActionData, useSearchParams } from '@remix-run/react'
+import { z } from 'zod'
+import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
+import { ErrorList, Field } from '#app/components/forms.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import {
+ ProviderConnectionForm,
+ providerNames,
+} from '#app/utils/connections.tsx'
+import { prisma } from '#app/utils/db.server.ts'
+import { sendEmail } from '#app/utils/email.server.ts'
+import { useIsPending } from '#app/utils/misc.tsx'
+import { EmailSchema } from '#app/utils/user-validation.ts'
+import { prepareVerification } from './verify.tsx'
+
+const SignupSchema = z.object({
+ email: EmailSchema,
+})
+
+export async function action({ request }: DataFunctionArgs) {
+ const formData = await request.formData()
+ const submission = await parse(formData, {
+ schema: SignupSchema.superRefine(async (data, ctx) => {
+ const existingUser = await prisma.user.findUnique({
+ where: { email: data.email },
+ select: { id: true },
+ })
+ if (existingUser) {
+ ctx.addIssue({
+ path: ['email'],
+ code: z.ZodIssueCode.custom,
+ message: 'A user already exists with this email',
+ })
+ return
+ }
+ }),
+ async: true,
+ })
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+ const { email } = submission.value
+ const { verifyUrl, redirectTo, otp } = await prepareVerification({
+ period: 10 * 60,
+ request,
+ type: 'onboarding',
+ target: email,
+ })
+
+ const response = await sendEmail({
+ to: email,
+ subject: `Welcome to Epic Notes!`,
+ react: ,
+ })
+
+ if (response.status === 'success') {
+ return redirect(redirectTo.toString())
+ } else {
+ submission.error[''] = [response.error.message]
+ return json({ status: 'error', submission } as const, { status: 500 })
+ }
+}
+
+export function SignupEmail({
+ onboardingUrl,
+ otp,
+}: {
+ onboardingUrl: string
+ otp: string
+}) {
+ return (
+
+
+
+ Welcome to Epic Notes!
+
+
+
+ Here's your verification code: {otp}
+
+
+
+ Or click the link to get started:
+
+ {onboardingUrl}
+
+
+ )
+}
+
+export const meta: V2_MetaFunction = () => {
+ return [{ title: 'Sign Up | Epic Notes' }]
+}
+
+export default function SignupRoute() {
+ const actionData = useActionData()
+ const isPending = useIsPending()
+ const [searchParams] = useSearchParams()
+ const redirectTo = searchParams.get('redirectTo')
+
+ const [form, fields] = useForm({
+ id: 'signup-form',
+ constraint: getFieldsetConstraint(SignupSchema),
+ lastSubmission: actionData?.submission,
+ onValidate({ formData }) {
+ const result = parse(formData, { schema: SignupSchema })
+ return result
+ },
+ shouldRevalidate: 'onBlur',
+ })
+
+ return (
+
+
+
Let's start your journey!
+
+ Please enter your email.
+
+
+
+
+
+ {providerNames.map(providerName => (
+
+ ))}
+
+
+
+ )
+}
+
+export function ErrorBoundary() {
+ return
+}
diff --git a/app/routes/_auth+/verify.tsx b/app/routes/_auth+/verify.tsx
new file mode 100644
index 0000000..5ffc4c3
--- /dev/null
+++ b/app/routes/_auth+/verify.tsx
@@ -0,0 +1,330 @@
+import { conform, useForm, type Submission } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import { generateTOTP, verifyTOTP } from '@epic-web/totp'
+import { json, type DataFunctionArgs } from '@remix-run/node'
+import {
+ Form,
+ useActionData,
+ useLoaderData,
+ useSearchParams,
+} from '@remix-run/react'
+import { z } from 'zod'
+import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
+import { ErrorList, Field } from '#app/components/forms.tsx'
+import { Spacer } from '#app/components/spacer.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import { handleVerification as handleChangeEmailVerification } from '#app/routes/settings+/profile.change-email.tsx'
+import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.tsx'
+import { type twoFAVerifyVerificationType } from '#app/routes/settings+/profile.two-factor.verify.tsx'
+import { requireUserId } from '#app/utils/auth.server.ts'
+import { prisma } from '#app/utils/db.server.ts'
+import { getDomainUrl, useIsPending } from '#app/utils/misc.tsx'
+import { redirectWithToast } from '#app/utils/toast.server.ts'
+import {
+ handleVerification as handleLoginTwoFactorVerification,
+ shouldRequestTwoFA,
+} from './login.tsx'
+import { handleVerification as handleOnboardingVerification } from './onboarding.tsx'
+import { handleVerification as handleResetPasswordVerification } from './reset-password.tsx'
+
+export const codeQueryParam = 'code'
+export const targetQueryParam = 'target'
+export const typeQueryParam = 'type'
+export const redirectToQueryParam = 'redirectTo'
+const types = ['onboarding', 'reset-password', 'change-email', '2fa'] as const
+const VerificationTypeSchema = z.enum(types)
+export type VerificationTypes = z.infer
+
+const VerifySchema = z.object({
+ [codeQueryParam]: z.string().min(6).max(6),
+ [typeQueryParam]: VerificationTypeSchema,
+ [targetQueryParam]: z.string(),
+ [redirectToQueryParam]: z.string().optional(),
+})
+
+export async function loader({ request }: DataFunctionArgs) {
+ const params = new URL(request.url).searchParams
+ if (!params.has(codeQueryParam)) {
+ // we don't want to show an error message on page load if the otp hasn't be
+ // prefilled in yet, so we'll send a response with an empty submission.
+ return json({
+ status: 'idle',
+ submission: {
+ intent: '',
+ payload: Object.fromEntries(params),
+ error: {},
+ },
+ } as const)
+ }
+ return validateRequest(request, params)
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ return validateRequest(request, await request.formData())
+}
+
+export function getRedirectToUrl({
+ request,
+ type,
+ target,
+ redirectTo,
+}: {
+ request: Request
+ type: VerificationTypes
+ target: string
+ redirectTo?: string
+}) {
+ const redirectToUrl = new URL(`${getDomainUrl(request)}/verify`)
+ redirectToUrl.searchParams.set(typeQueryParam, type)
+ redirectToUrl.searchParams.set(targetQueryParam, target)
+ if (redirectTo) {
+ redirectToUrl.searchParams.set(redirectToQueryParam, redirectTo)
+ }
+ return redirectToUrl
+}
+
+export async function requireRecentVerification(request: Request) {
+ const userId = await requireUserId(request)
+ const shouldReverify = await shouldRequestTwoFA(request)
+ if (shouldReverify) {
+ const reqUrl = new URL(request.url)
+ const redirectUrl = getRedirectToUrl({
+ request,
+ target: userId,
+ type: twoFAVerificationType,
+ redirectTo: reqUrl.pathname + reqUrl.search,
+ })
+ throw await redirectWithToast(redirectUrl.toString(), {
+ title: 'Please Reverify',
+ description: 'Please reverify your account before proceeding',
+ })
+ }
+}
+
+export async function prepareVerification({
+ period,
+ request,
+ type,
+ target,
+}: {
+ period: number
+ request: Request
+ type: VerificationTypes
+ target: string
+}) {
+ const verifyUrl = getRedirectToUrl({ request, type, target })
+ const redirectTo = new URL(verifyUrl.toString())
+
+ const { otp, ...verificationConfig } = generateTOTP({
+ algorithm: 'SHA256',
+ // Leaving off 0 and O on purpose to avoid confusing users.
+ charSet: 'ABCDEFGHIJKLMNPQRSTUVWXYZ123456789',
+ period,
+ })
+ const verificationData = {
+ type,
+ target,
+ ...verificationConfig,
+ expiresAt: new Date(Date.now() + verificationConfig.period * 1000),
+ }
+ await prisma.verification.upsert({
+ where: { target_type: { target, type } },
+ create: verificationData,
+ update: verificationData,
+ })
+
+ // add the otp to the url we'll email the user.
+ verifyUrl.searchParams.set(codeQueryParam, otp)
+
+ return { otp, redirectTo, verifyUrl }
+}
+
+export type VerifyFunctionArgs = {
+ request: Request
+ submission: Submission>
+ body: FormData | URLSearchParams
+}
+
+export async function isCodeValid({
+ code,
+ type,
+ target,
+}: {
+ code: string
+ type: VerificationTypes | typeof twoFAVerifyVerificationType
+ target: string
+}) {
+ const verification = await prisma.verification.findUnique({
+ where: {
+ target_type: { target, type },
+ OR: [{ expiresAt: { gt: new Date() } }, { expiresAt: null }],
+ },
+ select: { algorithm: true, secret: true, period: true, charSet: true },
+ })
+ if (!verification) return false
+ const result = verifyTOTP({
+ otp: code,
+ ...verification,
+ })
+ if (!result) return false
+
+ return true
+}
+
+async function validateRequest(
+ request: Request,
+ body: URLSearchParams | FormData,
+) {
+ const submission = await parse(body, {
+ schema: () =>
+ VerifySchema.superRefine(async (data, ctx) => {
+ const codeIsValid = await isCodeValid({
+ code: data[codeQueryParam],
+ type: data[typeQueryParam],
+ target: data[targetQueryParam],
+ })
+ if (!codeIsValid) {
+ ctx.addIssue({
+ path: ['code'],
+ code: z.ZodIssueCode.custom,
+ message: `Invalid code`,
+ })
+ return
+ }
+ }),
+ async: true,
+ })
+
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ const { value: submissionValue } = submission
+
+ async function deleteVerification() {
+ await prisma.verification.delete({
+ where: {
+ target_type: {
+ type: submissionValue[typeQueryParam],
+ target: submissionValue[targetQueryParam],
+ },
+ },
+ })
+ }
+
+ switch (submissionValue[typeQueryParam]) {
+ case 'reset-password': {
+ await deleteVerification()
+ return handleResetPasswordVerification({ request, body, submission })
+ }
+ case 'onboarding': {
+ await deleteVerification()
+ return handleOnboardingVerification({ request, body, submission })
+ }
+ case 'change-email': {
+ await deleteVerification()
+ return handleChangeEmailVerification({ request, body, submission })
+ }
+ case '2fa': {
+ return handleLoginTwoFactorVerification({ request, body, submission })
+ }
+ }
+}
+
+export default function VerifyRoute() {
+ const data = useLoaderData()
+ const [searchParams] = useSearchParams()
+ const isPending = useIsPending()
+ const actionData = useActionData()
+ const type = VerificationTypeSchema.parse(searchParams.get(typeQueryParam))
+
+ const checkEmail = (
+ <>
+ Check your email
+
+ We've sent you a code to verify your email address.
+
+ >
+ )
+
+ const headings: Record = {
+ onboarding: checkEmail,
+ 'reset-password': checkEmail,
+ 'change-email': checkEmail,
+ '2fa': (
+ <>
+ Check your 2FA app
+
+ Please enter your 2FA code to verify your identity.
+
+ >
+ ),
+ }
+
+ const [form, fields] = useForm({
+ id: 'verify-form',
+ constraint: getFieldsetConstraint(VerifySchema),
+ lastSubmission: actionData?.submission ?? data.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: VerifySchema })
+ },
+ defaultValue: {
+ code: searchParams.get(codeQueryParam) ?? '',
+ type,
+ target: searchParams.get(targetQueryParam) ?? '',
+ redirectTo: searchParams.get(redirectToQueryParam) ?? '',
+ },
+ })
+
+ return (
+
+
{headings[type]}
+
+
+
+
+
+ )
+}
+
+export function ErrorBoundary() {
+ return
+}
diff --git a/app/routes/_marketing+/about.tsx b/app/routes/_marketing+/about.tsx
new file mode 100644
index 0000000..55ef96d
--- /dev/null
+++ b/app/routes/_marketing+/about.tsx
@@ -0,0 +1,3 @@
+export default function AboutRoute() {
+ return About page
+}
diff --git a/app/routes/_marketing+/index.tsx b/app/routes/_marketing+/index.tsx
new file mode 100644
index 0000000..cd34a96
--- /dev/null
+++ b/app/routes/_marketing+/index.tsx
@@ -0,0 +1,84 @@
+import { type V2_MetaFunction } from '@remix-run/node'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '#app/components/ui/tooltip.tsx'
+import { logos, stars } from './logos/logos.ts'
+
+export const meta: V2_MetaFunction = () => [{ title: 'Epic Notes' }]
+
+export default function Index() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Check the{' '}
+
+ Getting Started
+ {' '}
+ guide file for how to get your project off the ground!
+
+
+
+
+
+
+
+
+ {logos.map(img => (
+
+
+
+
+
+
+ {img.alt}
+
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/app/routes/_marketing+/logos/docker.png b/app/routes/_marketing+/logos/docker.png
new file mode 100644
index 0000000..1db1286
Binary files /dev/null and b/app/routes/_marketing+/logos/docker.png differ
diff --git a/app/routes/_marketing+/logos/eslint.svg b/app/routes/_marketing+/logos/eslint.svg
new file mode 100644
index 0000000..dd535a8
--- /dev/null
+++ b/app/routes/_marketing+/logos/eslint.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/routes/_marketing+/logos/faker.svg b/app/routes/_marketing+/logos/faker.svg
new file mode 100644
index 0000000..e290a83
--- /dev/null
+++ b/app/routes/_marketing+/logos/faker.svg
@@ -0,0 +1,736 @@
+
+
+
+ Layer 1
+
+ Group
+
+ Group
+
+ Shape 272
+
+
+
+
+
+
+
+
+
+ Shape 273
+
+
+
+
+
+
+
+
+
+ Shape 274
+
+
+
+
+
+
+
+
+
+ Shape 275
+
+
+
+
+
+
+
+
+
+ Shape 276
+
+
+
+
+
+
+
+
+
+ Shape 277
+
+
+
+
+
+
+
+
+
+ Shape 278
+
+
+
+
+
+
+
+
+
+ Shape 279
+
+
+
+
+
+
+
+
+
+ Shape 280
+
+
+
+
+
+
+
+
+
+ Shape 281
+
+
+
+
+
+
+
+
+
+ Shape 282
+
+
+
+
+
+
+
+
+
+ Shape 283
+
+
+
+
+
+
+
+
+
+ Shape 284
+
+
+
+
+
+
+
+
+
+ Shape 285
+
+
+
+
+
+
+
+
+
+ Shape 286
+
+
+
+
+
+
+
+
+
+ Shape 287
+
+
+
+
+
+
+
+
+
+ Shape 288
+
+
+
+
+
+
+
+
+
+ Shape 289
+
+
+
+
+
+
+
+
+
+ Shape 290
+
+
+
+
+
+
+
+
+
+ Shape 291
+
+
+
+
+
+
+
+
+
+ Shape 292
+
+
+
+
+
+
+
+
+
+ Shape 293
+
+
+
+
+
+
+
+
+
+ Shape 294
+
+
+
+
+
+
+
+
+
+ Shape 295
+
+
+
+
+
+
+
+
+
+ Shape 296
+
+
+
+
+
+
+
+
+
+ Shape 297
+
+
+
+
+
+
+
+
+
+ Shape 298
+
+
+
+
+
+
+
+
+
+ Shape 299
+
+
+
+
+
+
+
+
+
+ Shape 300
+
+
+
+
+
+
+
+
+
+ Shape 301
+
+
+
+
+
+
+
+
+
+ Shape 302
+
+
+
+
+
+
+
+
+
+ Shape 303
+
+
+
+
+
+
+
+
+
+ Shape 304
+
+
+
+
+
+
+
+
+
+ Shape 305
+
+
+
+
+
+
+
+
+
+ Shape 306
+
+
+
+
+
+
+
+
+
+ Shape 307
+
+
+
+
+
+
+
+
+
+ Shape 308
+
+
+
+
+
+
+
+
+
+ Shape 309
+
+
+
+
+
+
+
+
+
+ Shape 310
+
+
+
+
+
+
+
+
+
+ Shape 311
+
+
+
+
+
+
+
+
+
+ Shape 312
+
+
+
+
+
+
+
+
+
+ Shape 313
+
+
+
+
+
+
+
+
+
+ Shape 314
+
+
+
+
+
+
+
+
+
+ Shape 315
+
+
+
+
+
+
+
+
+
+ Shape 316
+
+
+
+
+
+
+
+
+
+ Shape 317
+
+
+
+
+
+
+
+
+
+ Shape 318
+
+
+
+
+
+
+
+
+
+ Shape 319
+
+
+
+
+
+
+
+
+
+ Shape 320
+
+
+
+
+
+
+
+
+
+ Shape 321
+
+
+
+
+
+
+
+
+
+ Shape 322
+
+
+
+
+
+
+
+
+
+ Shape 323
+
+
+
+
+
+
+
+
+
+ Shape 324
+
+
+
+
+
+
+
+
+
+ Shape 325
+
+
+
+
+
+
+
+
+
+ Shape 326
+
+
+
+
+
+
+
+
+
+ Shape 327
+
+
+
+
+
+
+
+
+
+ Shape 328
+
+
+
+
+
+
+
+
+
+ Shape 329
+
+
+
+
+
+
+
+
+
+ Shape 330
+
+
+
+
+
+
+
+
+
+ Shape 331
+
+
+
+
+
+
+
+
+
+ Shape 332
+
+
+
+
+
+
+
+
+
+ Shape 333
+
+
+
+
+
+
+
+
+
+ Shape 334
+
+
+
+
+
+
+
+
+
+ Shape 335
+
+
+
+
+
+
+
+
+
+ Shape 336
+
+
+
+
+
+
+
+
+
+ Shape 337
+
+
+
+
+
+
+
+
+
+ Shape 338
+
+
+
+
+
+
+
+
+
+
+ Shape 339
+
+
+
+
+
+
+
+
+
+
+ Shape 340
+
+
+
+
+
+
+
+
+
+
+ Shape 341
+
+
+
+
+
+
+
+
+
+
+ Shape 342
+
+
+
+
+
+
+
+
+
+ Shape 343
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/routes/_marketing+/logos/fly.svg b/app/routes/_marketing+/logos/fly.svg
new file mode 100644
index 0000000..9dbf3c7
--- /dev/null
+++ b/app/routes/_marketing+/logos/fly.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/routes/_marketing+/logos/github.svg b/app/routes/_marketing+/logos/github.svg
new file mode 100644
index 0000000..37fa923
--- /dev/null
+++ b/app/routes/_marketing+/logos/github.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/routes/_marketing+/logos/logos.ts b/app/routes/_marketing+/logos/logos.ts
new file mode 100644
index 0000000..182cf44
--- /dev/null
+++ b/app/routes/_marketing+/logos/logos.ts
@@ -0,0 +1,131 @@
+import docker from './docker.png'
+import eslint from './eslint.svg'
+import fakerJS from './faker.svg'
+import fly from './fly.svg'
+import github from './github.svg'
+import msw from './msw.svg'
+import playwright from './playwright.svg'
+import prettier from './prettier.svg'
+import prisma from './prisma.svg'
+import radixUI from './radix.svg'
+import reactEmail from './react-email.svg'
+import remix from './remix.png'
+import resend from './resend.svg'
+import sentry from './sentry.svg'
+import shadcnUI from './shadcn-ui.svg'
+import sqlite from './sqlite.svg'
+import tailwind from './tailwind.svg'
+import testingLibrary from './testing-library.png'
+import typescript from './typescript.svg'
+import vitest from './vitest.svg'
+import zod from './zod.svg'
+
+export { default as stars } from './stars.jpg'
+
+export const logos = [
+ {
+ src: remix,
+ alt: 'Remix',
+ href: 'https://remix.run',
+ },
+ {
+ src: fly,
+ alt: 'Fly.io',
+ href: 'https://fly.io',
+ },
+ {
+ src: sqlite,
+ alt: 'SQLite',
+ href: 'https://sqlite.org',
+ },
+ {
+ src: prisma,
+ alt: 'Prisma',
+ href: 'https://prisma.io',
+ },
+ {
+ src: zod,
+ alt: 'Zod',
+ href: 'https://zod.dev/',
+ },
+ {
+ src: github,
+ alt: 'GitHub',
+ href: 'https://github.com',
+ },
+ {
+ src: resend,
+ alt: 'Resend',
+ href: 'https://resend.com',
+ },
+ {
+ src: reactEmail,
+ alt: 'React Email',
+ href: 'https://react.email',
+ },
+ {
+ src: tailwind,
+ alt: 'Tailwind',
+ href: 'https://tailwindcss.com',
+ },
+ {
+ src: radixUI,
+ alt: 'Radix UI',
+ href: 'https://www.radix-ui.com/',
+ },
+ {
+ src: shadcnUI,
+ alt: 'shadcn/ui',
+ href: 'https://ui.shadcn.com/',
+ },
+ {
+ src: playwright,
+ alt: 'Playwright',
+ href: 'https://playwright.dev/',
+ },
+ {
+ src: msw,
+ alt: 'MSW',
+ href: 'https://mswjs.io',
+ },
+ {
+ src: fakerJS,
+ alt: 'Faker.js',
+ href: 'https://fakerjs.dev/',
+ },
+ {
+ src: vitest,
+ alt: 'Vitest',
+ href: 'https://vitest.dev',
+ },
+ {
+ src: testingLibrary,
+ alt: 'Testing Library',
+ href: 'https://testing-library.com',
+ },
+ {
+ src: docker,
+ alt: 'Docker',
+ href: 'https://www.docker.com',
+ },
+ {
+ src: typescript,
+ alt: 'TypeScript',
+ href: 'https://typescriptlang.org',
+ },
+ {
+ src: prettier,
+ alt: 'Prettier',
+ href: 'https://prettier.io',
+ },
+ {
+ src: eslint,
+ alt: 'ESLint',
+ href: 'https://eslint.org',
+ },
+ {
+ src: sentry,
+ alt: 'Sentry',
+ href: 'https://sentry.io',
+ },
+]
diff --git a/app/routes/_marketing+/logos/msw.svg b/app/routes/_marketing+/logos/msw.svg
new file mode 100644
index 0000000..f5de6fb
--- /dev/null
+++ b/app/routes/_marketing+/logos/msw.svg
@@ -0,0 +1,13 @@
+
+
+ LOGO
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/routes/_marketing+/logos/playwright.svg b/app/routes/_marketing+/logos/playwright.svg
new file mode 100644
index 0000000..7b3ca7d
--- /dev/null
+++ b/app/routes/_marketing+/logos/playwright.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/routes/_marketing+/logos/prettier.svg b/app/routes/_marketing+/logos/prettier.svg
new file mode 100644
index 0000000..f4d0b66
--- /dev/null
+++ b/app/routes/_marketing+/logos/prettier.svg
@@ -0,0 +1,76 @@
+
+
+
+ prettier-icon-dark
+ Created with sketchtool.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/routes/_marketing+/logos/prisma.svg b/app/routes/_marketing+/logos/prisma.svg
new file mode 100644
index 0000000..17a3886
--- /dev/null
+++ b/app/routes/_marketing+/logos/prisma.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/routes/_marketing+/logos/radix.svg b/app/routes/_marketing+/logos/radix.svg
new file mode 100644
index 0000000..a748299
--- /dev/null
+++ b/app/routes/_marketing+/logos/radix.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/routes/_marketing+/logos/react-email.svg b/app/routes/_marketing+/logos/react-email.svg
new file mode 100644
index 0000000..51a2698
--- /dev/null
+++ b/app/routes/_marketing+/logos/react-email.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/routes/_marketing+/logos/remix.png b/app/routes/_marketing+/logos/remix.png
new file mode 100644
index 0000000..2dd93e6
Binary files /dev/null and b/app/routes/_marketing+/logos/remix.png differ
diff --git a/app/routes/_marketing+/logos/resend.svg b/app/routes/_marketing+/logos/resend.svg
new file mode 100644
index 0000000..1227dc5
--- /dev/null
+++ b/app/routes/_marketing+/logos/resend.svg
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/routes/_marketing+/logos/sentry.svg b/app/routes/_marketing+/logos/sentry.svg
new file mode 100644
index 0000000..3ebb3bb
--- /dev/null
+++ b/app/routes/_marketing+/logos/sentry.svg
@@ -0,0 +1,6 @@
+
+
+
\ No newline at end of file
diff --git a/app/routes/_marketing+/logos/shadcn-ui.svg b/app/routes/_marketing+/logos/shadcn-ui.svg
new file mode 100644
index 0000000..2780f68
--- /dev/null
+++ b/app/routes/_marketing+/logos/shadcn-ui.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/routes/_marketing+/logos/sqlite.svg b/app/routes/_marketing+/logos/sqlite.svg
new file mode 100644
index 0000000..424e0ab
--- /dev/null
+++ b/app/routes/_marketing+/logos/sqlite.svg
@@ -0,0 +1,67 @@
+
+
+
+SQLite image/svg+xml SQLite
\ No newline at end of file
diff --git a/app/routes/_marketing+/logos/stars.jpg b/app/routes/_marketing+/logos/stars.jpg
new file mode 100644
index 0000000..c725b8a
Binary files /dev/null and b/app/routes/_marketing+/logos/stars.jpg differ
diff --git a/app/routes/_marketing+/logos/tailwind.svg b/app/routes/_marketing+/logos/tailwind.svg
new file mode 100644
index 0000000..ef8cdad
--- /dev/null
+++ b/app/routes/_marketing+/logos/tailwind.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/routes/_marketing+/logos/testing-library.png b/app/routes/_marketing+/logos/testing-library.png
new file mode 100644
index 0000000..6febb2a
Binary files /dev/null and b/app/routes/_marketing+/logos/testing-library.png differ
diff --git a/app/routes/_marketing+/logos/typescript.svg b/app/routes/_marketing+/logos/typescript.svg
new file mode 100644
index 0000000..339da0b
--- /dev/null
+++ b/app/routes/_marketing+/logos/typescript.svg
@@ -0,0 +1,6 @@
+
+
+TypeScript logo
+
+
+
diff --git a/app/routes/_marketing+/logos/vitest.svg b/app/routes/_marketing+/logos/vitest.svg
new file mode 100644
index 0000000..fd9daaf
--- /dev/null
+++ b/app/routes/_marketing+/logos/vitest.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/routes/_marketing+/logos/zod.svg b/app/routes/_marketing+/logos/zod.svg
new file mode 100644
index 0000000..0595f51
--- /dev/null
+++ b/app/routes/_marketing+/logos/zod.svg
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/routes/_marketing+/privacy.tsx b/app/routes/_marketing+/privacy.tsx
new file mode 100644
index 0000000..b6d6530
--- /dev/null
+++ b/app/routes/_marketing+/privacy.tsx
@@ -0,0 +1,3 @@
+export default function PrivacyRoute() {
+ return Privacy
+}
diff --git a/app/routes/_marketing+/support.tsx b/app/routes/_marketing+/support.tsx
new file mode 100644
index 0000000..ec9a242
--- /dev/null
+++ b/app/routes/_marketing+/support.tsx
@@ -0,0 +1,3 @@
+export default function SupportRoute() {
+ return Support
+}
diff --git a/app/routes/_marketing+/tos.tsx b/app/routes/_marketing+/tos.tsx
new file mode 100644
index 0000000..998f867
--- /dev/null
+++ b/app/routes/_marketing+/tos.tsx
@@ -0,0 +1,3 @@
+export default function TermsOfServiceRoute() {
+ return Terms of service
+}
diff --git a/app/routes/admin+/cache.tsx b/app/routes/admin+/cache.tsx
new file mode 100644
index 0000000..403dc63
--- /dev/null
+++ b/app/routes/admin+/cache.tsx
@@ -0,0 +1,235 @@
+import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
+import {
+ Form,
+ Link,
+ useFetcher,
+ useLoaderData,
+ useSearchParams,
+ useSubmit,
+} from '@remix-run/react'
+import { Field } from '#app/components/forms.tsx'
+import { Spacer } from '#app/components/spacer.tsx'
+import { Button } from '#app/components/ui/button.tsx'
+import {
+ cache,
+ getAllCacheKeys,
+ lruCache,
+ searchCacheKeys,
+} from '#app/utils/cache.server.ts'
+import {
+ ensureInstance,
+ getAllInstances,
+ getInstanceInfo,
+} from '#app/utils/litefs.server.ts'
+import {
+ invariantResponse,
+ useDebounce,
+ useDoubleCheck,
+} from '#app/utils/misc.tsx'
+import { requireUserWithRole } from '#app/utils/permissions.ts'
+
+export async function loader({ request }: DataFunctionArgs) {
+ await requireUserWithRole(request, 'admin')
+ const searchParams = new URL(request.url).searchParams
+ const query = searchParams.get('query')
+ if (query === '') {
+ searchParams.delete('query')
+ return redirect(`/admin/cache?${searchParams.toString()}`)
+ }
+ const limit = Number(searchParams.get('limit') ?? 100)
+
+ const currentInstanceInfo = await getInstanceInfo()
+ const instance =
+ searchParams.get('instance') ?? currentInstanceInfo.currentInstance
+ const instances = await getAllInstances()
+ await ensureInstance(instance)
+
+ let cacheKeys: { sqlite: Array; lru: Array }
+ if (typeof query === 'string') {
+ cacheKeys = await searchCacheKeys(query, limit)
+ } else {
+ cacheKeys = await getAllCacheKeys(limit)
+ }
+ return json({ cacheKeys, instance, instances, currentInstanceInfo })
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ await requireUserWithRole(request, 'admin')
+ const formData = await request.formData()
+ const key = formData.get('cacheKey')
+ const { currentInstance } = await getInstanceInfo()
+ const instance = formData.get('instance') ?? currentInstance
+ const type = formData.get('type')
+
+ invariantResponse(typeof key === 'string', 'cacheKey must be a string')
+ invariantResponse(typeof type === 'string', 'type must be a string')
+ invariantResponse(typeof instance === 'string', 'instance must be a string')
+ await ensureInstance(instance)
+
+ switch (type) {
+ case 'sqlite': {
+ await cache.delete(key)
+ break
+ }
+ case 'lru': {
+ lruCache.delete(key)
+ break
+ }
+ default: {
+ throw new Error(`Unknown cache type: ${type}`)
+ }
+ }
+ return json({ success: true })
+}
+
+export default function CacheAdminRoute() {
+ const data = useLoaderData()
+ const [searchParams] = useSearchParams()
+ const submit = useSubmit()
+ const query = searchParams.get('query') ?? ''
+ const limit = searchParams.get('limit') ?? '100'
+ const instance = searchParams.get('instance') ?? data.instance
+
+ const handleFormChange = useDebounce((form: HTMLFormElement) => {
+ submit(form)
+ }, 400)
+
+ return (
+
+
Cache Admin
+
+
+
+
+
LRU Cache:
+ {data.cacheKeys.lru.map(key => (
+
+ ))}
+
+
+
+
SQLite Cache:
+ {data.cacheKeys.sqlite.map(key => (
+
+ ))}
+
+
+ )
+}
+
+function CacheKeyRow({
+ cacheKey,
+ instance,
+ type,
+}: {
+ cacheKey: string
+ instance?: string
+ type: 'sqlite' | 'lru'
+}) {
+ const fetcher = useFetcher()
+ const dc = useDoubleCheck()
+ const encodedKey = encodeURIComponent(cacheKey)
+ const valuePage = `/admin/cache/${type}/${encodedKey}?instance=${instance}`
+ return (
+
+
+
+
+
+
+ {fetcher.state === 'idle'
+ ? dc.doubleCheck
+ ? 'You sure?'
+ : 'Delete'
+ : 'Deleting...'}
+
+
+
+ {cacheKey}
+
+
+ )
+}
+
+export function ErrorBoundary({ error }: { error: Error }) {
+ console.error(error)
+
+ return An unexpected error occurred: {error.message}
+}
diff --git a/app/routes/admin+/cache_.lru.$cacheKey.ts b/app/routes/admin+/cache_.lru.$cacheKey.ts
new file mode 100644
index 0000000..fa08ebc
--- /dev/null
+++ b/app/routes/admin+/cache_.lru.$cacheKey.ts
@@ -0,0 +1,28 @@
+import { json, type DataFunctionArgs } from '@remix-run/node'
+import { getAllInstances, getInstanceInfo } from 'litefs-js'
+import { ensureInstance } from 'litefs-js/remix.js'
+import { lruCache } from '#app/utils/cache.server.ts'
+import { invariantResponse } from '#app/utils/misc.tsx'
+import { requireUserWithRole } from '#app/utils/permissions.ts'
+
+export async function loader({ request, params }: DataFunctionArgs) {
+ await requireUserWithRole(request, 'admin')
+ const searchParams = new URL(request.url).searchParams
+ const currentInstanceInfo = await getInstanceInfo()
+ const allInstances = await getAllInstances()
+ const instance =
+ searchParams.get('instance') ?? currentInstanceInfo.currentInstance
+ await ensureInstance(instance)
+
+ const { cacheKey } = params
+ invariantResponse(cacheKey, 'cacheKey is required')
+ return json({
+ instance: {
+ hostname: instance,
+ region: allInstances[instance],
+ isPrimary: currentInstanceInfo.primaryInstance === instance,
+ },
+ cacheKey,
+ value: lruCache.get(cacheKey),
+ })
+}
diff --git a/app/routes/admin+/cache_.sqlite.$cacheKey.ts b/app/routes/admin+/cache_.sqlite.$cacheKey.ts
new file mode 100644
index 0000000..aa185e6
--- /dev/null
+++ b/app/routes/admin+/cache_.sqlite.$cacheKey.ts
@@ -0,0 +1,28 @@
+import { json, type DataFunctionArgs } from '@remix-run/node'
+import { getAllInstances, getInstanceInfo } from 'litefs-js'
+import { ensureInstance } from 'litefs-js/remix.js'
+import { cache } from '#app/utils/cache.server.ts'
+import { invariantResponse } from '#app/utils/misc.tsx'
+import { requireUserWithRole } from '#app/utils/permissions.ts'
+
+export async function loader({ request, params }: DataFunctionArgs) {
+ await requireUserWithRole(request, 'admin')
+ const searchParams = new URL(request.url).searchParams
+ const currentInstanceInfo = await getInstanceInfo()
+ const allInstances = await getAllInstances()
+ const instance =
+ searchParams.get('instance') ?? currentInstanceInfo.currentInstance
+ await ensureInstance(instance)
+
+ const { cacheKey } = params
+ invariantResponse(cacheKey, 'cacheKey is required')
+ return json({
+ instance: {
+ hostname: instance,
+ region: allInstances[instance],
+ isPrimary: currentInstanceInfo.primaryInstance === instance,
+ },
+ cacheKey,
+ value: cache.get(cacheKey),
+ })
+}
diff --git a/app/routes/admin+/cache_.sqlite.tsx b/app/routes/admin+/cache_.sqlite.tsx
new file mode 100644
index 0000000..df5ce02
--- /dev/null
+++ b/app/routes/admin+/cache_.sqlite.tsx
@@ -0,0 +1,55 @@
+import { type DataFunctionArgs, json, redirect } from '@remix-run/node'
+import { getInstanceInfo, getInternalInstanceDomain } from 'litefs-js'
+import { z } from 'zod'
+import { cache } from '#app/utils/cache.server.ts'
+
+export async function action({ request }: DataFunctionArgs) {
+ const { currentIsPrimary, primaryInstance } = await getInstanceInfo()
+ if (!currentIsPrimary) {
+ throw new Error(
+ `${request.url} should only be called on the primary instance (${primaryInstance})}`,
+ )
+ }
+ const token = process.env.INTERNAL_COMMAND_TOKEN
+ const isAuthorized =
+ request.headers.get('Authorization') === `Bearer ${token}`
+ if (!isAuthorized) {
+ // nah, you can't be here...
+ return redirect('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
+ }
+ const { key, cacheValue } = z
+ .object({ key: z.string(), cacheValue: z.unknown().optional() })
+ .parse(await request.json())
+ if (cacheValue === undefined) {
+ await cache.delete(key)
+ } else {
+ // @ts-expect-error - we don't reliably know the type of cacheValue
+ await cache.set(key, cacheValue)
+ }
+ return json({ success: true })
+}
+
+export async function updatePrimaryCacheValue({
+ key,
+ cacheValue,
+}: {
+ key: string
+ cacheValue: any
+}) {
+ const { currentIsPrimary, primaryInstance } = await getInstanceInfo()
+ if (currentIsPrimary) {
+ throw new Error(
+ `updatePrimaryCacheValue should not be called on the primary instance (${primaryInstance})}`,
+ )
+ }
+ const domain = getInternalInstanceDomain(primaryInstance)
+ const token = process.env.INTERNAL_COMMAND_TOKEN
+ return fetch(`${domain}/admin/cache/sqlite`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ key, cacheValue }),
+ })
+}
diff --git a/app/routes/me.tsx b/app/routes/me.tsx
new file mode 100644
index 0000000..7324293
--- /dev/null
+++ b/app/routes/me.tsx
@@ -0,0 +1,18 @@
+import { redirect, type DataFunctionArgs } from '@remix-run/node'
+import { authenticator, requireUserId } from '#app/utils/auth.server.ts'
+import { prisma } from '#app/utils/db.server.ts'
+
+export async function loader({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const user = await prisma.user.findUnique({ where: { id: userId } })
+ if (!user) {
+ const requestUrl = new URL(request.url)
+ const loginParams = new URLSearchParams([
+ ['redirectTo', `${requestUrl.pathname}${requestUrl.search}`],
+ ])
+ const redirectTo = `/login?${loginParams}`
+ await authenticator.logout(request, { redirectTo })
+ return redirect(redirectTo)
+ }
+ return redirect(`/users/${user.username}`)
+}
diff --git a/app/routes/resources+/download-user-data.tsx b/app/routes/resources+/download-user-data.tsx
new file mode 100644
index 0000000..3f86b67
--- /dev/null
+++ b/app/routes/resources+/download-user-data.tsx
@@ -0,0 +1,62 @@
+import { json, type DataFunctionArgs } from '@remix-run/node'
+import { requireUserId } from '#app/utils/auth.server.ts'
+import { prisma } from '#app/utils/db.server.ts'
+import { getDomainUrl } from '#app/utils/misc.tsx'
+
+export async function loader({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const user = await prisma.user.findUniqueOrThrow({
+ where: { id: userId },
+ // this is one of the *few* instances where you can use "include" because
+ // the goal is to literally get *everything*. Normally you should be
+ // explicit with "select". We're suing select for images because we don't
+ // want to send back the entire blob of the image. We'll send a URL they can
+ // use to download it instead.
+ include: {
+ image: {
+ select: {
+ id: true,
+ createdAt: true,
+ updatedAt: true,
+ contentType: true,
+ },
+ },
+ notes: {
+ include: {
+ images: {
+ select: {
+ id: true,
+ createdAt: true,
+ updatedAt: true,
+ contentType: true,
+ },
+ },
+ },
+ },
+ password: false, // <-- intentionally omit password
+ sessions: true,
+ roles: true,
+ },
+ })
+
+ const domain = getDomainUrl(request)
+
+ return json({
+ user: {
+ ...user,
+ image: user.image
+ ? {
+ ...user.image,
+ url: `${domain}/resources/user-images/${user.image.id}`,
+ }
+ : null,
+ notes: user.notes.map(note => ({
+ ...note,
+ images: note.images.map(image => ({
+ ...image,
+ url: `${domain}/resources/note-images/${image.id}`,
+ })),
+ })),
+ },
+ })
+}
diff --git a/app/routes/resources+/healthcheck.tsx b/app/routes/resources+/healthcheck.tsx
new file mode 100644
index 0000000..4e80ed1
--- /dev/null
+++ b/app/routes/resources+/healthcheck.tsx
@@ -0,0 +1,3 @@
+export async function loader() {
+ return new Response('OK')
+}
diff --git a/app/routes/resources+/note-images.$imageId.tsx b/app/routes/resources+/note-images.$imageId.tsx
new file mode 100644
index 0000000..869396b
--- /dev/null
+++ b/app/routes/resources+/note-images.$imageId.tsx
@@ -0,0 +1,22 @@
+import { type DataFunctionArgs } from '@remix-run/node'
+import { prisma } from '#app/utils/db.server.ts'
+import { invariantResponse } from '#app/utils/misc.tsx'
+
+export async function loader({ params }: DataFunctionArgs) {
+ invariantResponse(params.imageId, 'Image ID is required', { status: 400 })
+ const image = await prisma.noteImage.findUnique({
+ where: { id: params.imageId },
+ select: { contentType: true, blob: true },
+ })
+
+ invariantResponse(image, 'Not found', { status: 404 })
+
+ return new Response(image.blob, {
+ headers: {
+ 'Content-Type': image.contentType,
+ 'Content-Length': Buffer.byteLength(image.blob).toString(),
+ 'Content-Disposition': `inline; filename="${params.imageId}"`,
+ 'Cache-Control': 'public, max-age=31536000, immutable',
+ },
+ })
+}
diff --git a/app/routes/resources+/user-images.$imageId.tsx b/app/routes/resources+/user-images.$imageId.tsx
new file mode 100644
index 0000000..da6af1d
--- /dev/null
+++ b/app/routes/resources+/user-images.$imageId.tsx
@@ -0,0 +1,22 @@
+import { type DataFunctionArgs } from '@remix-run/node'
+import { prisma } from '#app/utils/db.server.ts'
+import { invariantResponse } from '#app/utils/misc.tsx'
+
+export async function loader({ params }: DataFunctionArgs) {
+ invariantResponse(params.imageId, 'Image ID is required', { status: 400 })
+ const image = await prisma.userImage.findUnique({
+ where: { id: params.imageId },
+ select: { contentType: true, blob: true },
+ })
+
+ invariantResponse(image, 'Not found', { status: 404 })
+
+ return new Response(image.blob, {
+ headers: {
+ 'Content-Type': image.contentType,
+ 'Content-Length': Buffer.byteLength(image.blob).toString(),
+ 'Content-Disposition': `inline; filename="${params.imageId}"`,
+ 'Cache-Control': 'public, max-age=31536000, immutable',
+ },
+ })
+}
diff --git a/app/routes/settings+/profile.change-email.tsx b/app/routes/settings+/profile.change-email.tsx
new file mode 100644
index 0000000..2a94ff9
--- /dev/null
+++ b/app/routes/settings+/profile.change-email.tsx
@@ -0,0 +1,242 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import * as E from '@react-email/components'
+import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
+import { Form, useActionData, useLoaderData } from '@remix-run/react'
+import { z } from 'zod'
+import { ErrorList, Field } from '#app/components/forms.tsx'
+import { Icon } from '#app/components/ui/icon.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import {
+ prepareVerification,
+ requireRecentVerification,
+ type VerifyFunctionArgs,
+} from '#app/routes/_auth+/verify.tsx'
+import { requireUserId } from '#app/utils/auth.server.ts'
+import { prisma } from '#app/utils/db.server.ts'
+import { sendEmail } from '#app/utils/email.server.ts'
+import { invariant, useIsPending } from '#app/utils/misc.tsx'
+import { redirectWithToast } from '#app/utils/toast.server.ts'
+import { EmailSchema } from '#app/utils/user-validation.ts'
+import { verifySessionStorage } from '#app/utils/verification.server.ts'
+
+export const handle = {
+ breadcrumb: Change Email ,
+}
+
+const newEmailAddressSessionKey = 'new-email-address'
+
+export async function handleVerification({
+ request,
+ submission,
+}: VerifyFunctionArgs) {
+ await requireRecentVerification(request)
+ invariant(submission.value, 'submission.value should be defined by now')
+
+ const verifySession = await verifySessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const newEmail = verifySession.get(newEmailAddressSessionKey)
+ if (!newEmail) {
+ submission.error[''] = [
+ 'You must submit the code on the same device that requested the email change.',
+ ]
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+ const preUpdateUser = await prisma.user.findFirstOrThrow({
+ select: { email: true },
+ where: { id: submission.value.target },
+ })
+ const user = await prisma.user.update({
+ where: { id: submission.value.target },
+ select: { id: true, email: true, username: true },
+ data: { email: newEmail },
+ })
+
+ void sendEmail({
+ to: preUpdateUser.email,
+ subject: 'Epic Stack email changed',
+ react: ,
+ })
+
+ return redirectWithToast(
+ '/settings/profile',
+ {
+ title: 'Email Changed',
+ type: 'success',
+ description: `Your email has been changed to ${user.email}`,
+ },
+ {
+ headers: {
+ 'set-cookie': await verifySessionStorage.destroySession(verifySession),
+ },
+ },
+ )
+}
+
+const ChangeEmailSchema = z.object({
+ email: EmailSchema,
+})
+
+export async function loader({ request }: DataFunctionArgs) {
+ await requireRecentVerification(request)
+ const userId = await requireUserId(request)
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { email: true },
+ })
+ if (!user) {
+ const params = new URLSearchParams({ redirectTo: request.url })
+ throw redirect(`/login?${params}`)
+ }
+ return json({ user })
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const formData = await request.formData()
+ const submission = await parse(formData, {
+ schema: ChangeEmailSchema.superRefine(async (data, ctx) => {
+ const existingUser = await prisma.user.findUnique({
+ where: { email: data.email },
+ })
+ if (existingUser) {
+ ctx.addIssue({
+ path: ['email'],
+ code: 'custom',
+ message: 'This email is already in use.',
+ })
+ }
+ }),
+ async: true,
+ })
+
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+ const { otp, redirectTo, verifyUrl } = await prepareVerification({
+ period: 10 * 60,
+ request,
+ target: userId,
+ type: 'change-email',
+ })
+
+ const response = await sendEmail({
+ to: submission.value.email,
+ subject: `Epic Notes Email Change Verification`,
+ react: ,
+ })
+
+ if (response.status === 'success') {
+ const verifySession = await verifySessionStorage.getSession()
+ verifySession.set(newEmailAddressSessionKey, submission.value.email)
+ return redirect(redirectTo.toString(), {
+ headers: {
+ 'set-cookie': await verifySessionStorage.commitSession(verifySession),
+ },
+ })
+ } else {
+ submission.error[''] = [response.error.message]
+ return json({ status: 'error', submission } as const, { status: 500 })
+ }
+}
+
+export function EmailChangeEmail({
+ verifyUrl,
+ otp,
+}: {
+ verifyUrl: string
+ otp: string
+}) {
+ return (
+
+
+
+ Epic Notes Email Change
+
+
+
+ Here's your verification code: {otp}
+
+
+
+ Or click the link:
+
+ {verifyUrl}
+
+
+ )
+}
+
+export function EmailChangeNoticeEmail({ userId }: { userId: string }) {
+ return (
+
+
+
+ Your Epic Notes email has been changed
+
+
+
+ We're writing to let you know that your Epic Notes email has been
+ changed.
+
+
+
+
+ If you changed your email address, then you can safely ignore this.
+ But if you did not change your email address, then please contact
+ support immediately.
+
+
+
+ Your Account ID: {userId}
+
+
+
+ )
+}
+
+export default function ChangeEmailIndex() {
+ const data = useLoaderData()
+ const actionData = useActionData()
+
+ const [form, fields] = useForm({
+ id: 'change-email-form',
+ constraint: getFieldsetConstraint(ChangeEmailSchema),
+ lastSubmission: actionData?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: ChangeEmailSchema })
+ },
+ })
+
+ const isPending = useIsPending()
+ return (
+
+
Change Email
+
You will receive an email at the new email address to confirm.
+
+ An email notice will also be sent to your old address {data.user.email}.
+
+
+
+ )
+}
diff --git a/app/routes/settings+/profile.connections.tsx b/app/routes/settings+/profile.connections.tsx
new file mode 100644
index 0000000..f2fc766
--- /dev/null
+++ b/app/routes/settings+/profile.connections.tsx
@@ -0,0 +1,206 @@
+import {
+ json,
+ type DataFunctionArgs,
+ type SerializeFrom,
+} from '@remix-run/node'
+import { Form, useFetcher, useLoaderData } from '@remix-run/react'
+import { useState } from 'react'
+import { Icon } from '#app/components/ui/icon.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '#app/components/ui/tooltip.tsx'
+import { requireUserId } from '#app/utils/auth.server.ts'
+import { resolveConnectionData } from '#app/utils/connections.server.ts'
+import { ProviderNameSchema } from '#app/utils/connections.tsx'
+import { prisma } from '#app/utils/db.server.ts'
+import { invariantResponse, useIsPending } from '#app/utils/misc.tsx'
+import { createToastHeaders } from '#app/utils/toast.server.ts'
+
+export const handle = {
+ breadcrumb: Connections ,
+}
+
+async function userCanDeleteConnections(userId: string) {
+ const user = await prisma.user.findUnique({
+ select: {
+ password: { select: { userId: true } },
+ _count: { select: { connections: true } },
+ },
+ where: { id: userId },
+ })
+ // user can delete their connections if they have a password
+ if (user?.password) return true
+ // users have to have more than one remaining connection to delete one
+ return Boolean(user?._count.connections && user?._count.connections > 1)
+}
+
+export async function loader({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const rawConnections = await prisma.connection.findMany({
+ select: { id: true, providerName: true, providerId: true, createdAt: true },
+ where: { userId },
+ })
+ const connections: Array<{
+ id: string
+ displayName: string
+ link?: string | null
+ createdAtFormatted: string
+ }> = []
+ for (const connection of rawConnections) {
+ const r = ProviderNameSchema.safeParse(connection.providerName)
+ if (!r.success) continue
+ const connectionData = await resolveConnectionData(
+ r.data,
+ connection.providerId,
+ )
+ if (connectionData) {
+ connections.push({
+ ...connectionData,
+ id: connection.id,
+ createdAtFormatted: connection.createdAt.toLocaleString(),
+ })
+ } else {
+ connections.push({
+ id: connection.id,
+ displayName: 'Unknown',
+ createdAtFormatted: connection.createdAt.toLocaleString(),
+ })
+ }
+ }
+
+ return json({
+ connections,
+ canDeleteConnections: await userCanDeleteConnections(userId),
+ })
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const formData = await request.formData()
+ invariantResponse(
+ formData.get('intent') === 'delete-connection',
+ 'Invalid intent',
+ )
+ invariantResponse(
+ await userCanDeleteConnections(userId),
+ 'You cannot delete your last connection unless you have a password.',
+ )
+ const connectionId = formData.get('connectionId')
+ invariantResponse(typeof connectionId === 'string', 'Invalid connectionId')
+ await prisma.connection.delete({
+ where: {
+ id: connectionId,
+ userId: userId,
+ },
+ })
+ const toastHeaders = await createToastHeaders({
+ title: 'Deleted',
+ description: 'Your connection has been deleted.',
+ })
+ return json({ status: 'success' } as const, { headers: toastHeaders })
+}
+
+export default function Connections() {
+ const data = useLoaderData()
+ const isGitHubSubmitting = useIsPending({ formAction: '/auth/github' })
+
+ return (
+
+ {data.connections.length ? (
+
+
Here are your current connections:
+
+ {data.connections.map(c => (
+
+
+
+ ))}
+
+
+ ) : (
+
You don't have any connections yet.
+ )}
+
+
+ )
+}
+
+function Connection({
+ connection,
+ canDelete,
+}: {
+ connection: SerializeFrom['connections'][number]
+ canDelete: boolean
+}) {
+ const deleteFetcher = useFetcher()
+ const [infoOpen, setInfoOpen] = useState(false)
+ return (
+
+
+ {connection.link ? (
+
+ {connection.displayName}
+
+ ) : (
+ connection.displayName
+ )}{' '}
+ ({connection.createdAtFormatted})
+
+ {canDelete ? (
+
+
+
+
+
+
+
+
+
+ Disconnect this account
+
+
+
+ ) : (
+
+
+ setInfoOpen(true)}>
+
+
+
+ You cannot delete your last connection unless you have a password.
+
+
+
+ )}
+
+ )
+}
diff --git a/app/routes/settings+/profile.index.tsx b/app/routes/settings+/profile.index.tsx
new file mode 100644
index 0000000..9273433
--- /dev/null
+++ b/app/routes/settings+/profile.index.tsx
@@ -0,0 +1,353 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
+import { Link, useFetcher, useLoaderData } from '@remix-run/react'
+import { z } from 'zod'
+import { ErrorList, Field } from '#app/components/forms.tsx'
+import { Button } from '#app/components/ui/button.tsx'
+import { Icon } from '#app/components/ui/icon.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import { requireUserId, sessionKey } from '#app/utils/auth.server.ts'
+import { prisma } from '#app/utils/db.server.ts'
+import {
+ getUserImgSrc,
+ invariantResponse,
+ useDoubleCheck,
+} from '#app/utils/misc.tsx'
+import { sessionStorage } from '#app/utils/session.server.ts'
+import { NameSchema, UsernameSchema } from '#app/utils/user-validation.ts'
+import { twoFAVerificationType } from './profile.two-factor.tsx'
+
+const ProfileFormSchema = z.object({
+ name: NameSchema.optional(),
+ username: UsernameSchema,
+})
+
+export async function loader({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const user = await prisma.user.findUniqueOrThrow({
+ where: { id: userId },
+ select: {
+ id: true,
+ name: true,
+ username: true,
+ email: true,
+ image: {
+ select: { id: true },
+ },
+ _count: {
+ select: {
+ sessions: {
+ where: {
+ expirationDate: { gt: new Date() },
+ },
+ },
+ },
+ },
+ },
+ })
+
+ const twoFactorVerification = await prisma.verification.findUnique({
+ select: { id: true },
+ where: { target_type: { type: twoFAVerificationType, target: userId } },
+ })
+
+ const password = await prisma.password.findUnique({
+ select: { userId: true },
+ where: { userId },
+ })
+
+ return json({
+ user,
+ hasPassword: Boolean(password),
+ isTwoFactorEnabled: Boolean(twoFactorVerification),
+ })
+}
+
+type ProfileActionArgs = {
+ request: Request
+ userId: string
+ formData: FormData
+}
+const profileUpdateActionIntent = 'update-profile'
+const signOutOfSessionsActionIntent = 'sign-out-of-sessions'
+const deleteDataActionIntent = 'delete-data'
+
+export async function action({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const formData = await request.formData()
+ const intent = formData.get('intent')
+ switch (intent) {
+ case profileUpdateActionIntent: {
+ return profileUpdateAction({ request, userId, formData })
+ }
+ case signOutOfSessionsActionIntent: {
+ return signOutOfSessionsAction({ request, userId, formData })
+ }
+ case deleteDataActionIntent: {
+ return deleteDataAction({ request, userId, formData })
+ }
+ default: {
+ throw new Response(`Invalid intent "${intent}"`, { status: 400 })
+ }
+ }
+}
+
+export default function EditUserProfile() {
+ const data = useLoaderData()
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Change email from {data.user.email}
+
+
+
+
+
+ {data.isTwoFactorEnabled ? (
+ 2FA is enabled
+ ) : (
+ Enable 2FA
+ )}
+
+
+
+
+
+ {data.hasPassword ? 'Change Password' : 'Create a Password'}
+
+
+
+
+
+ Manage connections
+
+
+
+
+ Download your data
+
+
+
+
+
+
+ )
+}
+
+async function profileUpdateAction({ userId, formData }: ProfileActionArgs) {
+ const submission = await parse(formData, {
+ async: true,
+ schema: ProfileFormSchema.superRefine(async ({ username }, ctx) => {
+ const existingUsername = await prisma.user.findUnique({
+ where: { username },
+ select: { id: true },
+ })
+ if (existingUsername && existingUsername.id !== userId) {
+ ctx.addIssue({
+ path: ['username'],
+ code: 'custom',
+ message: 'A user already exists with this username',
+ })
+ }
+ }),
+ })
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ const data = submission.value
+
+ await prisma.user.update({
+ select: { username: true },
+ where: { id: userId },
+ data: {
+ name: data.name,
+ username: data.username,
+ },
+ })
+
+ return json({ status: 'success', submission } as const)
+}
+
+function UpdateProfile() {
+ const data = useLoaderData()
+
+ const fetcher = useFetcher()
+
+ const [form, fields] = useForm({
+ id: 'edit-profile',
+ constraint: getFieldsetConstraint(ProfileFormSchema),
+ lastSubmission: fetcher.data?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: ProfileFormSchema })
+ },
+ defaultValue: {
+ username: data.user.username,
+ name: data.user.name ?? '',
+ email: data.user.email,
+ },
+ })
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Save changes
+
+
+
+ )
+}
+
+async function signOutOfSessionsAction({ request, userId }: ProfileActionArgs) {
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const sessionId = cookieSession.get(sessionKey)
+ invariantResponse(
+ sessionId,
+ 'You must be authenticated to sign out of other sessions',
+ )
+ await prisma.session.deleteMany({
+ where: {
+ userId,
+ id: { not: sessionId },
+ },
+ })
+ return json({ status: 'success' } as const)
+}
+
+function SignOutOfSessions() {
+ const data = useLoaderData()
+ const dc = useDoubleCheck()
+
+ const fetcher = useFetcher()
+ const otherSessionsCount = data.user._count.sessions - 1
+ return (
+
+ {otherSessionsCount ? (
+
+
+
+ {dc.doubleCheck
+ ? `Are you sure?`
+ : `Sign out of ${otherSessionsCount} other sessions`}
+
+
+
+ ) : (
+ This is your only session
+ )}
+
+ )
+}
+
+async function deleteDataAction({ userId }: ProfileActionArgs) {
+ await prisma.user.delete({ where: { id: userId } })
+ return redirect('/')
+}
+
+function DeleteData() {
+ const dc = useDoubleCheck()
+
+ const fetcher = useFetcher()
+ return (
+
+
+
+
+ {dc.doubleCheck ? `Are you sure?` : `Delete all your data`}
+
+
+
+
+ )
+}
diff --git a/app/routes/settings+/profile.password.tsx b/app/routes/settings+/profile.password.tsx
new file mode 100644
index 0000000..5dcf6cf
--- /dev/null
+++ b/app/routes/settings+/profile.password.tsx
@@ -0,0 +1,160 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
+import { Form, Link, useActionData } from '@remix-run/react'
+import { z } from 'zod'
+import { ErrorList, Field } from '#app/components/forms.tsx'
+import { Button } from '#app/components/ui/button.tsx'
+import { Icon } from '#app/components/ui/icon.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import {
+ getPasswordHash,
+ requireUserId,
+ verifyUserPassword,
+} from '#app/utils/auth.server.ts'
+import { prisma } from '#app/utils/db.server.ts'
+import { useIsPending } from '#app/utils/misc.tsx'
+import { redirectWithToast } from '#app/utils/toast.server.ts'
+import { PasswordSchema } from '#app/utils/user-validation.ts'
+
+export const handle = {
+ breadcrumb: Password ,
+}
+
+const ChangePasswordForm = z
+ .object({
+ currentPassword: PasswordSchema,
+ newPassword: PasswordSchema,
+ confirmNewPassword: PasswordSchema,
+ })
+ .superRefine(({ confirmNewPassword, newPassword }, ctx) => {
+ if (confirmNewPassword !== newPassword) {
+ ctx.addIssue({
+ path: ['confirmNewPassword'],
+ code: 'custom',
+ message: 'The passwords must match',
+ })
+ }
+ })
+
+async function requirePassword(userId: string) {
+ const password = await prisma.password.findUnique({
+ select: { userId: true },
+ where: { userId },
+ })
+ if (!password) {
+ throw redirect('/settings/profile/password/create')
+ }
+}
+
+export async function loader({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ await requirePassword(userId)
+ return json({})
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ await requirePassword(userId)
+ const formData = await request.formData()
+ const submission = await parse(formData, {
+ async: true,
+ schema: ChangePasswordForm.superRefine(
+ async ({ currentPassword, newPassword }, ctx) => {
+ if (currentPassword && newPassword) {
+ const user = await verifyUserPassword({ id: userId }, currentPassword)
+ if (!user) {
+ ctx.addIssue({
+ path: ['currentPassword'],
+ code: 'custom',
+ message: 'Incorrect password.',
+ })
+ }
+ }
+ },
+ ),
+ })
+ // clear the payload so we don't send the password back to the client
+ submission.payload = {}
+ if (submission.intent !== 'submit') {
+ // clear the value so we don't send the password back to the client
+ submission.value = undefined
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ const { newPassword } = submission.value
+
+ await prisma.user.update({
+ select: { username: true },
+ where: { id: userId },
+ data: {
+ password: {
+ update: {
+ hash: await getPasswordHash(newPassword),
+ },
+ },
+ },
+ })
+
+ return redirectWithToast(
+ `/settings/profile`,
+ {
+ type: 'success',
+ title: 'Password Changed',
+ description: 'Your password has been changed.',
+ },
+ { status: 302 },
+ )
+}
+
+export default function ChangePasswordRoute() {
+ const actionData = useActionData()
+ const isPending = useIsPending()
+
+ const [form, fields] = useForm({
+ id: 'password-change-form',
+ constraint: getFieldsetConstraint(ChangePasswordForm),
+ lastSubmission: actionData?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: ChangePasswordForm })
+ },
+ shouldRevalidate: 'onBlur',
+ })
+
+ return (
+
+ )
+}
diff --git a/app/routes/settings+/profile.password_.create.tsx b/app/routes/settings+/profile.password_.create.tsx
new file mode 100644
index 0000000..e5bc636
--- /dev/null
+++ b/app/routes/settings+/profile.password_.create.tsx
@@ -0,0 +1,128 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
+import { Form, Link, useActionData } from '@remix-run/react'
+import { z } from 'zod'
+import { ErrorList, Field } from '#app/components/forms.tsx'
+import { Button } from '#app/components/ui/button.tsx'
+import { Icon } from '#app/components/ui/icon.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import { getPasswordHash, requireUserId } from '#app/utils/auth.server.ts'
+import { prisma } from '#app/utils/db.server.ts'
+import { useIsPending } from '#app/utils/misc.tsx'
+import { PasswordSchema } from '#app/utils/user-validation.ts'
+
+export const handle = {
+ breadcrumb: Password ,
+}
+
+const CreatePasswordForm = z
+ .object({
+ newPassword: PasswordSchema,
+ confirmNewPassword: PasswordSchema,
+ })
+ .superRefine(({ confirmNewPassword, newPassword }, ctx) => {
+ if (confirmNewPassword !== newPassword) {
+ ctx.addIssue({
+ path: ['confirmNewPassword'],
+ code: 'custom',
+ message: 'The passwords must match',
+ })
+ }
+ })
+
+async function requireNoPassword(userId: string) {
+ const password = await prisma.password.findUnique({
+ select: { userId: true },
+ where: { userId },
+ })
+ if (password) {
+ throw redirect('/settings/profile/password')
+ }
+}
+
+export async function loader({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ await requireNoPassword(userId)
+ return json({})
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ await requireNoPassword(userId)
+ const formData = await request.formData()
+ const submission = await parse(formData, {
+ async: true,
+ schema: CreatePasswordForm,
+ })
+ // clear the payload so we don't send the password back to the client
+ submission.payload = {}
+ if (submission.intent !== 'submit') {
+ // clear the value so we don't send the password back to the client
+ submission.value = undefined
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ const { newPassword } = submission.value
+
+ await prisma.user.update({
+ select: { username: true },
+ where: { id: userId },
+ data: {
+ password: {
+ create: {
+ hash: await getPasswordHash(newPassword),
+ },
+ },
+ },
+ })
+
+ return redirect(`/settings/profile`, { status: 302 })
+}
+
+export default function CreatePasswordRoute() {
+ const actionData = useActionData()
+ const isPending = useIsPending()
+
+ const [form, fields] = useForm({
+ id: 'password-create-form',
+ constraint: getFieldsetConstraint(CreatePasswordForm),
+ lastSubmission: actionData?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: CreatePasswordForm })
+ },
+ shouldRevalidate: 'onBlur',
+ })
+
+ return (
+
+ )
+}
diff --git a/app/routes/settings+/profile.photo.tsx b/app/routes/settings+/profile.photo.tsx
new file mode 100644
index 0000000..5fc95aa
--- /dev/null
+++ b/app/routes/settings+/profile.photo.tsx
@@ -0,0 +1,223 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import {
+ json,
+ redirect,
+ unstable_createMemoryUploadHandler,
+ unstable_parseMultipartFormData,
+ type DataFunctionArgs,
+} from '@remix-run/node'
+import { Form, useActionData, useLoaderData } from '@remix-run/react'
+import { useState } from 'react'
+import { ServerOnly } from 'remix-utils'
+import { z } from 'zod'
+import { ErrorList } from '#app/components/forms.tsx'
+import { Button } from '#app/components/ui/button.tsx'
+import { Icon } from '#app/components/ui/icon.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import { requireUserId } from '#app/utils/auth.server.ts'
+import { prisma } from '#app/utils/db.server.ts'
+import {
+ getUserImgSrc,
+ invariantResponse,
+ useDoubleCheck,
+ useIsPending,
+} from '#app/utils/misc.tsx'
+
+export const handle = {
+ breadcrumb: Photo ,
+}
+
+const MAX_SIZE = 1024 * 1024 * 3 // 3MB
+
+const DeleteImageSchema = z.object({
+ intent: z.literal('delete'),
+})
+
+const SubmitFormSchema = z.object({
+ intent: z.literal('submit'),
+ photoFile: z
+ .instanceof(File)
+ .refine(file => file.size > 0, 'Image is required')
+ .refine(file => file.size <= MAX_SIZE, 'Image size must be less than 3MB'),
+})
+
+const PhotoFormSchema = z.union([DeleteImageSchema, SubmitFormSchema])
+
+export async function loader({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: {
+ id: true,
+ name: true,
+ username: true,
+ image: { select: { id: true } },
+ },
+ })
+ invariantResponse(user, 'User not found', { status: 404 })
+ return json({ user })
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const formData = await unstable_parseMultipartFormData(
+ request,
+ unstable_createMemoryUploadHandler({ maxPartSize: MAX_SIZE }),
+ )
+
+ const submission = await parse(formData, {
+ schema: PhotoFormSchema.transform(async data => {
+ if (data.intent === 'delete') return { intent: 'delete' }
+ if (data.photoFile.size <= 0) return z.NEVER
+ return {
+ intent: data.intent,
+ image: {
+ contentType: data.photoFile.type,
+ blob: Buffer.from(await data.photoFile.arrayBuffer()),
+ },
+ }
+ }),
+ async: true,
+ })
+
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ const { image, intent } = submission.value
+
+ if (intent === 'delete') {
+ await prisma.userImage.deleteMany({ where: { userId } })
+ return redirect('/settings/profile')
+ }
+
+ await prisma.$transaction(async $prisma => {
+ await $prisma.userImage.deleteMany({ where: { userId } })
+ await $prisma.user.update({
+ where: { id: userId },
+ data: { image: { create: image } },
+ })
+ })
+
+ return redirect('/settings/profile')
+}
+
+export default function PhotoRoute() {
+ const data = useLoaderData()
+
+ const doubleCheckDeleteImage = useDoubleCheck()
+
+ const actionData = useActionData()
+
+ const [form, fields] = useForm({
+ id: 'profile-photo',
+ constraint: getFieldsetConstraint(PhotoFormSchema),
+ lastSubmission: actionData?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: PhotoFormSchema })
+ },
+ shouldRevalidate: 'onBlur',
+ })
+
+ const isPending = useIsPending()
+
+ const [newImageSrc, setNewImageSrc] = useState(null)
+
+ return (
+
+ )
+}
diff --git a/app/routes/settings+/profile.tsx b/app/routes/settings+/profile.tsx
new file mode 100644
index 0000000..26cb44a
--- /dev/null
+++ b/app/routes/settings+/profile.tsx
@@ -0,0 +1,65 @@
+import { json, type DataFunctionArgs } from '@remix-run/node'
+import { Link, Outlet, useMatches } from '@remix-run/react'
+import { Spacer } from '#app/components/spacer.tsx'
+import { Icon } from '#app/components/ui/icon.tsx'
+import { requireUserId } from '#app/utils/auth.server.ts'
+import { prisma } from '#app/utils/db.server.ts'
+import { cn, invariantResponse } from '#app/utils/misc.tsx'
+import { useUser } from '#app/utils/user.ts'
+
+export const handle = {
+ breadcrumb: Edit Profile ,
+}
+
+export async function loader({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { username: true },
+ })
+ invariantResponse(user, 'User not found', { status: 404 })
+ return json({})
+}
+
+export default function EditUserProfile() {
+ const user = useUser()
+ const matches = useMatches()
+ const breadcrumbs = matches
+ .map(m =>
+ m.handle?.breadcrumb ? (
+
+ {m.handle.breadcrumb}
+
+ ) : null,
+ )
+ .filter(Boolean)
+
+ return (
+
+
+
+
+ Profile
+
+
+ {breadcrumbs.map((breadcrumb, i, arr) => (
+
+ âśď¸ {breadcrumb}
+
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/app/routes/settings+/profile.two-factor.disable.tsx b/app/routes/settings+/profile.two-factor.disable.tsx
new file mode 100644
index 0000000..1b627f8
--- /dev/null
+++ b/app/routes/settings+/profile.two-factor.disable.tsx
@@ -0,0 +1,59 @@
+import { json, type DataFunctionArgs } from '@remix-run/node'
+import { useFetcher } from '@remix-run/react'
+import { Icon } from '#app/components/ui/icon.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import { requireRecentVerification } from '#app/routes/_auth+/verify.tsx'
+import { requireUserId } from '#app/utils/auth.server.ts'
+import { prisma } from '#app/utils/db.server.ts'
+import { useDoubleCheck } from '#app/utils/misc.tsx'
+import { redirectWithToast } from '#app/utils/toast.server.ts'
+import { twoFAVerificationType } from './profile.two-factor.tsx'
+
+export const handle = {
+ breadcrumb: Disable ,
+}
+
+export async function loader({ request }: DataFunctionArgs) {
+ await requireRecentVerification(request)
+ return json({})
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ await requireRecentVerification(request)
+ const userId = await requireUserId(request)
+ await prisma.verification.delete({
+ where: { target_type: { target: userId, type: twoFAVerificationType } },
+ })
+ return redirectWithToast('/settings/profile/two-factor', {
+ title: '2FA Disabled',
+ description: 'Two factor authentication has been disabled.',
+ })
+}
+
+export default function TwoFactorDisableRoute() {
+ const disable2FAFetcher = useFetcher()
+ const dc = useDoubleCheck()
+
+ return (
+
+
+
+ Disabling two factor authentication is not recommended. However, if
+ you would like to do so, click here:
+
+
+ {dc.doubleCheck ? 'Are you sure?' : 'Disable 2FA'}
+
+
+
+ )
+}
diff --git a/app/routes/settings+/profile.two-factor.index.tsx b/app/routes/settings+/profile.two-factor.index.tsx
new file mode 100644
index 0000000..22c9225
--- /dev/null
+++ b/app/routes/settings+/profile.two-factor.index.tsx
@@ -0,0 +1,86 @@
+import { generateTOTP } from '@epic-web/totp'
+import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
+import { Link, useFetcher, useLoaderData } from '@remix-run/react'
+import { Icon } from '#app/components/ui/icon.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import { requireUserId } from '#app/utils/auth.server.ts'
+import { prisma } from '#app/utils/db.server.ts'
+import { twoFAVerificationType } from './profile.two-factor.tsx'
+import { twoFAVerifyVerificationType } from './profile.two-factor.verify.tsx'
+
+export async function loader({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const verification = await prisma.verification.findUnique({
+ where: { target_type: { type: twoFAVerificationType, target: userId } },
+ select: { id: true },
+ })
+ return json({ is2FAEnabled: Boolean(verification) })
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const { otp: _otp, ...config } = generateTOTP()
+ const verificationData = {
+ ...config,
+ type: twoFAVerifyVerificationType,
+ target: userId,
+ }
+ await prisma.verification.upsert({
+ where: {
+ target_type: { target: userId, type: twoFAVerifyVerificationType },
+ },
+ create: verificationData,
+ update: verificationData,
+ })
+ return redirect('/settings/profile/two-factor/verify')
+}
+
+export default function TwoFactorRoute() {
+ const data = useLoaderData()
+ const enable2FAFetcher = useFetcher()
+
+ return (
+
+ {data.is2FAEnabled ? (
+ <>
+
+
+ You have enabled two-factor authentication.
+
+
+
+
Disable 2FA
+
+ >
+ ) : (
+ <>
+
+
+ You have not enabled two-factor authentication yet.
+
+
+
+ Two factor authentication adds an extra layer of security to your
+ account. You will need to enter a code from an authenticator app
+ like{' '}
+
+ 1Password
+ {' '}
+ to log in.
+
+
+
+ Enable 2FA
+
+
+ >
+ )}
+
+ )
+}
diff --git a/app/routes/settings+/profile.two-factor.tsx b/app/routes/settings+/profile.two-factor.tsx
new file mode 100644
index 0000000..ae3eb61
--- /dev/null
+++ b/app/routes/settings+/profile.two-factor.tsx
@@ -0,0 +1,13 @@
+import { Outlet } from '@remix-run/react'
+import { Icon } from '#app/components/ui/icon.tsx'
+import { type VerificationTypes } from '#app/routes/_auth+/verify.tsx'
+
+export const handle = {
+ breadcrumb: 2FA ,
+}
+
+export const twoFAVerificationType = '2fa' satisfies VerificationTypes
+
+export default function TwoFactorRoute() {
+ return
+}
diff --git a/app/routes/settings+/profile.two-factor.verify.tsx b/app/routes/settings+/profile.two-factor.verify.tsx
new file mode 100644
index 0000000..71a4f48
--- /dev/null
+++ b/app/routes/settings+/profile.two-factor.verify.tsx
@@ -0,0 +1,197 @@
+import { conform, useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import { getTOTPAuthUri } from '@epic-web/totp'
+import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
+import {
+ Form,
+ useActionData,
+ useLoaderData,
+ useNavigation,
+} from '@remix-run/react'
+import * as QRCode from 'qrcode'
+import { z } from 'zod'
+import { Field } from '#app/components/forms.tsx'
+import { Icon } from '#app/components/ui/icon.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import { isCodeValid } from '#app/routes/_auth+/verify.tsx'
+import { requireUserId } from '#app/utils/auth.server.ts'
+import { prisma } from '#app/utils/db.server.ts'
+import { getDomainUrl, useIsPending } from '#app/utils/misc.tsx'
+import { redirectWithToast } from '#app/utils/toast.server.ts'
+import { twoFAVerificationType } from './profile.two-factor.tsx'
+
+export const handle = {
+ breadcrumb: Verify ,
+}
+
+const VerifySchema = z.object({
+ code: z.string().min(6).max(6),
+})
+
+export const twoFAVerifyVerificationType = '2fa-verify'
+
+export async function loader({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const verification = await prisma.verification.findUnique({
+ where: {
+ target_type: { type: twoFAVerifyVerificationType, target: userId },
+ },
+ select: {
+ id: true,
+ algorithm: true,
+ secret: true,
+ period: true,
+ digits: true,
+ },
+ })
+ if (!verification) {
+ return redirect('/settings/profile/two-factor')
+ }
+ const user = await prisma.user.findUniqueOrThrow({
+ where: { id: userId },
+ select: { email: true },
+ })
+ const issuer = new URL(getDomainUrl(request)).host
+ const otpUri = getTOTPAuthUri({
+ ...verification,
+ accountName: user.email,
+ issuer,
+ })
+ const qrCode = await QRCode.toDataURL(otpUri)
+ return json({ otpUri, qrCode })
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const formData = await request.formData()
+
+ if (formData.get('intent') === 'cancel') {
+ await prisma.verification.deleteMany({
+ where: { type: twoFAVerifyVerificationType, target: userId },
+ })
+ return redirect('/settings/profile/two-factor')
+ }
+ const submission = await parse(formData, {
+ schema: () =>
+ VerifySchema.superRefine(async (data, ctx) => {
+ const codeIsValid = await isCodeValid({
+ code: data.code,
+ type: twoFAVerifyVerificationType,
+ target: userId,
+ })
+ if (!codeIsValid) {
+ ctx.addIssue({
+ path: ['code'],
+ code: z.ZodIssueCode.custom,
+ message: `Invalid code`,
+ })
+ return
+ }
+ }),
+ async: true,
+ })
+
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ await prisma.verification.update({
+ where: {
+ target_type: { type: twoFAVerifyVerificationType, target: userId },
+ },
+ data: { type: twoFAVerificationType },
+ })
+ return redirectWithToast('/settings/profile/two-factor', {
+ type: 'success',
+ title: 'Enabled',
+ description: 'Two-factor authentication has been enabled.',
+ })
+}
+
+export default function TwoFactorRoute() {
+ const data = useLoaderData()
+ const actionData = useActionData()
+ const navigation = useNavigation()
+
+ const isPending = useIsPending()
+ const pendingIntent = isPending ? navigation.formData?.get('intent') : null
+
+ const [form, fields] = useForm({
+ id: 'verify-form',
+ constraint: getFieldsetConstraint(VerifySchema),
+ lastSubmission: actionData?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: VerifySchema })
+ },
+ })
+
+ return (
+
+
+
+
Scan this QR code with your authenticator app.
+
+ If you cannot scan the QR code, you can manually add this account to
+ your authenticator app using this code:
+
+
+
+ Once you've added the account, enter the code from your authenticator
+ app below. Once you enable 2FA, you will need to enter a code from
+ your authenticator app every time you log in or perform important
+ actions. Do not lose access to your authenticator app, or you will
+ lose access to your account.
+
+
+
+
+ )
+}
diff --git a/app/routes/users+/$username.tsx b/app/routes/users+/$username.tsx
new file mode 100644
index 0000000..b44ccb9
--- /dev/null
+++ b/app/routes/users+/$username.tsx
@@ -0,0 +1,126 @@
+import { json, type DataFunctionArgs } from '@remix-run/node'
+import {
+ Form,
+ Link,
+ useLoaderData,
+ type V2_MetaFunction,
+} from '@remix-run/react'
+import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
+import { Spacer } from '#app/components/spacer.tsx'
+import { Button } from '#app/components/ui/button.tsx'
+import { Icon } from '#app/components/ui/icon.tsx'
+import { prisma } from '#app/utils/db.server.ts'
+import { getUserImgSrc, invariantResponse } from '#app/utils/misc.tsx'
+import { useOptionalUser } from '#app/utils/user.ts'
+
+export async function loader({ params }: DataFunctionArgs) {
+ const user = await prisma.user.findFirst({
+ select: {
+ id: true,
+ name: true,
+ username: true,
+ createdAt: true,
+ image: { select: { id: true } },
+ },
+ where: {
+ username: params.username,
+ },
+ })
+
+ invariantResponse(user, 'User not found', { status: 404 })
+
+ return json({ user, userJoinedDisplay: user.createdAt.toLocaleDateString() })
+}
+
+export default function ProfileRoute() {
+ const data = useLoaderData()
+ const user = data.user
+ const userDisplayName = user.name ?? user.username
+ const loggedInUser = useOptionalUser()
+ const isLoggedInUser = data.user.id === loggedInUser?.id
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{userDisplayName}
+
+
+ Joined {data.userJoinedDisplay}
+
+ {isLoggedInUser ? (
+
+ ) : null}
+
+ {isLoggedInUser ? (
+ <>
+
+
+ My notes
+
+
+
+
+ Edit profile
+
+
+ >
+ ) : (
+
+
+ {userDisplayName}'s notes
+
+
+ )}
+
+
+
+
+ )
+}
+
+export const meta: V2_MetaFunction = ({ data, params }) => {
+ const displayName = data?.user.name ?? params.username
+ return [
+ { title: `${displayName} | Epic Notes` },
+ {
+ name: 'description',
+ content: `Profile of ${displayName} on Epic Notes`,
+ },
+ ]
+}
+
+export function ErrorBoundary() {
+ return (
+ (
+ No user with the username "{params.username}" exists
+ ),
+ }}
+ />
+ )
+}
diff --git a/app/routes/users+/$username_+/__note-editor.tsx b/app/routes/users+/$username_+/__note-editor.tsx
new file mode 100644
index 0000000..e74ce75
--- /dev/null
+++ b/app/routes/users+/$username_+/__note-editor.tsx
@@ -0,0 +1,401 @@
+import {
+ conform,
+ list,
+ useFieldList,
+ useFieldset,
+ useForm,
+ type FieldConfig,
+} from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import { createId as cuid } from '@paralleldrive/cuid2'
+import { type Note, type NoteImage } from '@prisma/client'
+import {
+ unstable_createMemoryUploadHandler as createMemoryUploadHandler,
+ json,
+ unstable_parseMultipartFormData as parseMultipartFormData,
+ redirect,
+ type DataFunctionArgs,
+ type SerializeFrom,
+} from '@remix-run/node'
+import { Form, useFetcher } from '@remix-run/react'
+import { useRef, useState } from 'react'
+import { z } from 'zod'
+import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
+import { floatingToolbarClassName } from '#app/components/floating-toolbar.tsx'
+import { ErrorList, Field, TextareaField } from '#app/components/forms.tsx'
+import { Button } from '#app/components/ui/button.tsx'
+import { Icon } from '#app/components/ui/icon.tsx'
+import { Label } from '#app/components/ui/label.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import { Textarea } from '#app/components/ui/textarea.tsx'
+import { requireUserId } from '#app/utils/auth.server.ts'
+import { prisma } from '#app/utils/db.server.ts'
+import { cn, getNoteImgSrc } from '#app/utils/misc.tsx'
+
+const titleMinLength = 1
+const titleMaxLength = 100
+const contentMinLength = 1
+const contentMaxLength = 10000
+
+const MAX_UPLOAD_SIZE = 1024 * 1024 * 3 // 3MB
+
+const ImageFieldsetSchema = z.object({
+ id: z.string().optional(),
+ file: z
+ .instanceof(File)
+ .optional()
+ .refine(file => {
+ return !file || file.size <= MAX_UPLOAD_SIZE
+ }, 'File size must be less than 3MB'),
+ altText: z.string().optional(),
+})
+
+type ImageFieldset = z.infer
+
+function imageHasFile(
+ image: ImageFieldset,
+): image is ImageFieldset & { file: NonNullable } {
+ return Boolean(image.file?.size && image.file?.size > 0)
+}
+
+function imageHasId(
+ image: ImageFieldset,
+): image is ImageFieldset & { id: NonNullable } {
+ return image.id != null
+}
+
+const NoteEditorSchema = z.object({
+ id: z.string().optional(),
+ title: z.string().min(titleMinLength).max(titleMaxLength),
+ content: z.string().min(contentMinLength).max(contentMaxLength),
+ images: z.array(ImageFieldsetSchema).max(5).optional(),
+})
+
+export async function action({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+
+ const formData = await parseMultipartFormData(
+ request,
+ createMemoryUploadHandler({ maxPartSize: MAX_UPLOAD_SIZE }),
+ )
+
+ const submission = await parse(formData, {
+ schema: NoteEditorSchema.superRefine(async (data, ctx) => {
+ if (!data.id) return
+
+ const note = await prisma.note.findUnique({
+ select: { id: true },
+ where: { id: data.id, ownerId: userId },
+ })
+ if (!note) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Note not found',
+ })
+ }
+ }).transform(async ({ images = [], ...data }) => {
+ return {
+ ...data,
+ imageUpdates: await Promise.all(
+ images.filter(imageHasId).map(async i => {
+ if (imageHasFile(i)) {
+ return {
+ id: i.id,
+ altText: i.altText,
+ contentType: i.file.type,
+ blob: Buffer.from(await i.file.arrayBuffer()),
+ }
+ } else {
+ return {
+ id: i.id,
+ altText: i.altText,
+ }
+ }
+ }),
+ ),
+ newImages: await Promise.all(
+ images
+ .filter(imageHasFile)
+ .filter(i => !i.id)
+ .map(async image => {
+ return {
+ altText: image.altText,
+ contentType: image.file.type,
+ blob: Buffer.from(await image.file.arrayBuffer()),
+ }
+ }),
+ ),
+ }
+ }),
+ async: true,
+ })
+
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+
+ if (!submission.value) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ const {
+ id: noteId,
+ title,
+ content,
+ imageUpdates = [],
+ newImages = [],
+ } = submission.value
+
+ const updatedNote = await prisma.note.upsert({
+ select: { id: true, owner: { select: { username: true } } },
+ where: { id: noteId ?? '__new_note__' },
+ create: {
+ ownerId: userId,
+ title,
+ content,
+ images: { create: newImages },
+ },
+ update: {
+ title,
+ content,
+ images: {
+ deleteMany: { id: { notIn: imageUpdates.map(i => i.id) } },
+ updateMany: imageUpdates.map(updates => ({
+ where: { id: updates.id },
+ data: { ...updates, id: updates.blob ? cuid() : updates.id },
+ })),
+ create: newImages,
+ },
+ },
+ })
+
+ return redirect(
+ `/users/${updatedNote.owner.username}/notes/${updatedNote.id}`,
+ )
+}
+
+export function NoteEditor({
+ note,
+}: {
+ note?: SerializeFrom<
+ Pick & {
+ images: Array>
+ }
+ >
+}) {
+ const noteFetcher = useFetcher()
+ const isPending = noteFetcher.state !== 'idle'
+
+ const [form, fields] = useForm({
+ id: 'note-editor',
+ constraint: getFieldsetConstraint(NoteEditorSchema),
+ lastSubmission: noteFetcher.data?.submission,
+ onValidate({ formData }) {
+ return parse(formData, { schema: NoteEditorSchema })
+ },
+ defaultValue: {
+ title: note?.title ?? '',
+ content: note?.content ?? '',
+ images: note?.images ?? [{}],
+ },
+ })
+ const imageList = useFieldList(form.ref, fields.images)
+
+ return (
+
+
+
+
+ Reset
+
+
+ Submit
+
+
+
+ )
+}
+
+function ImageChooser({
+ config,
+}: {
+ config: FieldConfig>
+}) {
+ const ref = useRef(null)
+ const fields = useFieldset(ref, config)
+ const existingImage = Boolean(fields.id.defaultValue)
+ const [previewImage, setPreviewImage] = useState(
+ fields.id.defaultValue ? getNoteImgSrc(fields.id.defaultValue) : null,
+ )
+ const [altText, setAltText] = useState(fields.altText.defaultValue ?? '')
+
+ return (
+
+
+
+
+
+ {previewImage ? (
+
+
+ {existingImage ? null : (
+
+ new
+
+ )}
+
+ ) : (
+
+
+
+ )}
+ {existingImage ? (
+
+ ) : null}
+ {
+ const file = event.target.files?.[0]
+
+ if (file) {
+ const reader = new FileReader()
+ reader.onloadend = () => {
+ setPreviewImage(reader.result as string)
+ }
+ reader.readAsDataURL(file)
+ } else {
+ setPreviewImage(null)
+ }
+ }}
+ accept="image/*"
+ {...conform.input(fields.file, {
+ type: 'file',
+ ariaAttributes: true,
+ })}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export function ErrorBoundary() {
+ return (
+ (
+ No note with the id "{params.noteId}" exists
+ ),
+ }}
+ />
+ )
+}
diff --git a/app/routes/users+/$username_+/notes.$noteId.tsx b/app/routes/users+/$username_+/notes.$noteId.tsx
new file mode 100644
index 0000000..15ad015
--- /dev/null
+++ b/app/routes/users+/$username_+/notes.$noteId.tsx
@@ -0,0 +1,227 @@
+import { useForm } from '@conform-to/react'
+import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import { json, type DataFunctionArgs } from '@remix-run/node'
+import {
+ Form,
+ Link,
+ useActionData,
+ useLoaderData,
+ type V2_MetaFunction,
+} from '@remix-run/react'
+import { formatDistanceToNow } from 'date-fns'
+import { z } from 'zod'
+import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
+import { floatingToolbarClassName } from '#app/components/floating-toolbar.tsx'
+import { ErrorList } from '#app/components/forms.tsx'
+import { Button } from '#app/components/ui/button.tsx'
+import { Icon } from '#app/components/ui/icon.tsx'
+import { StatusButton } from '#app/components/ui/status-button.tsx'
+import { requireUserId } from '#app/utils/auth.server.ts'
+import { prisma } from '#app/utils/db.server.ts'
+import {
+ getNoteImgSrc,
+ invariantResponse,
+ useIsPending,
+} from '#app/utils/misc.tsx'
+import {
+ requireUserWithPermission,
+ userHasPermission,
+} from '#app/utils/permissions.ts'
+import { redirectWithToast } from '#app/utils/toast.server.ts'
+import { useOptionalUser } from '#app/utils/user.ts'
+import { type loader as notesLoader } from './notes.tsx'
+
+export async function loader({ params }: DataFunctionArgs) {
+ const note = await prisma.note.findUnique({
+ where: { id: params.noteId },
+ select: {
+ id: true,
+ title: true,
+ content: true,
+ ownerId: true,
+ updatedAt: true,
+ images: {
+ select: {
+ id: true,
+ altText: true,
+ },
+ },
+ },
+ })
+
+ invariantResponse(note, 'Not found', { status: 404 })
+
+ const date = new Date(note.updatedAt)
+ const timeAgo = formatDistanceToNow(date)
+
+ return json({
+ note,
+ timeAgo,
+ })
+}
+
+const DeleteFormSchema = z.object({
+ intent: z.literal('delete-note'),
+ noteId: z.string(),
+})
+
+export async function action({ request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const formData = await request.formData()
+ const submission = parse(formData, {
+ schema: DeleteFormSchema,
+ })
+ if (submission.intent !== 'submit') {
+ return json({ status: 'idle', submission } as const)
+ }
+ if (!submission.value) {
+ return json({ status: 'error', submission } as const, { status: 400 })
+ }
+
+ const { noteId } = submission.value
+
+ const note = await prisma.note.findFirst({
+ select: { id: true, ownerId: true, owner: { select: { username: true } } },
+ where: { id: noteId },
+ })
+ invariantResponse(note, 'Not found', { status: 404 })
+
+ const isOwner = note.ownerId === userId
+ await requireUserWithPermission(
+ request,
+ isOwner ? `delete:note:own` : `delete:note:any`,
+ )
+
+ await prisma.note.delete({ where: { id: note.id } })
+
+ return redirectWithToast(`/users/${note.owner.username}/notes`, {
+ type: 'success',
+ title: 'Success',
+ description: 'Your note has been deleted.',
+ })
+}
+
+export default function NoteRoute() {
+ const data = useLoaderData()
+ const user = useOptionalUser()
+ const isOwner = user?.id === data.note.ownerId
+ const canDelete = userHasPermission(
+ user,
+ isOwner ? `delete:note:own` : `delete:note:any`,
+ )
+ const displayBar = canDelete || isOwner
+
+ return (
+
+
{data.note.title}
+
+
+ {data.note.images.map(image => (
+
+
+
+
+
+ ))}
+
+
+ {data.note.content}
+
+
+ {displayBar ? (
+
+
+
+ {data.timeAgo} ago
+
+
+
+ {canDelete ? : null}
+
+
+
+ Edit
+
+
+
+
+
+ ) : null}
+
+ )
+}
+
+export function DeleteNote({ id }: { id: string }) {
+ const actionData = useActionData()
+ const isPending = useIsPending()
+ const [form] = useForm({
+ id: 'delete-note',
+ lastSubmission: actionData?.submission,
+ constraint: getFieldsetConstraint(DeleteFormSchema),
+ onValidate({ formData }) {
+ return parse(formData, { schema: DeleteFormSchema })
+ },
+ })
+
+ return (
+
+ )
+}
+
+export const meta: V2_MetaFunction<
+ typeof loader,
+ { 'routes/users+/$username_+/notes': typeof notesLoader }
+> = ({ data, params, matches }) => {
+ const notesMatch = matches.find(
+ m => m.id === 'routes/users+/$username_+/notes',
+ )
+ const displayName = notesMatch?.data?.owner.name ?? params.username
+ const noteTitle = data?.note.title ?? 'Note'
+ const noteContentsSummary =
+ data && data.note.content.length > 100
+ ? data?.note.content.slice(0, 97) + '...'
+ : 'No content'
+ return [
+ { title: `${noteTitle} | ${displayName}'s Notes | Epic Notes` },
+ {
+ name: 'description',
+ content: noteContentsSummary,
+ },
+ ]
+}
+
+export function ErrorBoundary() {
+ return (
+ You are not allowed to do that
,
+ 404: ({ params }) => (
+ No note with the id "{params.noteId}" exists
+ ),
+ }}
+ />
+ )
+}
diff --git a/app/routes/users+/$username_+/notes.$noteId_.edit.tsx b/app/routes/users+/$username_+/notes.$noteId_.edit.tsx
new file mode 100644
index 0000000..83c86e3
--- /dev/null
+++ b/app/routes/users+/$username_+/notes.$noteId_.edit.tsx
@@ -0,0 +1,37 @@
+import { json, type DataFunctionArgs } from '@remix-run/node'
+import { useLoaderData } from '@remix-run/react'
+import { requireUserId } from '#app/utils/auth.server.ts'
+import { prisma } from '#app/utils/db.server.ts'
+import { invariantResponse } from '#app/utils/misc.tsx'
+import { NoteEditor, action } from './__note-editor.tsx'
+
+export { action }
+
+export async function loader({ params, request }: DataFunctionArgs) {
+ const userId = await requireUserId(request)
+ const note = await prisma.note.findFirst({
+ select: {
+ id: true,
+ title: true,
+ content: true,
+ images: {
+ select: {
+ id: true,
+ altText: true,
+ },
+ },
+ },
+ where: {
+ id: params.noteId,
+ ownerId: userId,
+ },
+ })
+ invariantResponse(note, 'Not found', { status: 404 })
+ return json({ note: note })
+}
+
+export default function NoteEdit() {
+ const data = useLoaderData()
+
+ return
+}
diff --git a/app/routes/users+/$username_+/notes.index.tsx b/app/routes/users+/$username_+/notes.index.tsx
new file mode 100644
index 0000000..e4a8714
--- /dev/null
+++ b/app/routes/users+/$username_+/notes.index.tsx
@@ -0,0 +1,29 @@
+import { type V2_MetaFunction } from '@remix-run/react'
+import { type loader as notesLoader } from './notes.tsx'
+
+export default function NotesIndexRoute() {
+ return (
+
+ )
+}
+
+export const meta: V2_MetaFunction<
+ null,
+ { 'routes/users+/$username_+/notes': typeof notesLoader }
+> = ({ params, matches }) => {
+ const notesMatch = matches.find(
+ m => m.id === 'routes/users+/$username_+/notes',
+ )
+ const displayName = notesMatch?.data?.owner.name ?? params.username
+ const noteCount = notesMatch?.data?.owner.notes.length ?? 0
+ const notesText = noteCount === 1 ? 'note' : 'notes'
+ return [
+ { title: `${displayName}'s Notes | Epic Notes` },
+ {
+ name: 'description',
+ content: `Checkout ${displayName}'s ${noteCount} ${notesText} on Epic Notes`,
+ },
+ ]
+}
diff --git a/app/routes/users+/$username_+/notes.new.tsx b/app/routes/users+/$username_+/notes.new.tsx
new file mode 100644
index 0000000..35248e9
--- /dev/null
+++ b/app/routes/users+/$username_+/notes.new.tsx
@@ -0,0 +1,12 @@
+import { json } from '@remix-run/router'
+import { type DataFunctionArgs } from '@remix-run/server-runtime'
+import { requireUserId } from '#app/utils/auth.server.ts'
+import { NoteEditor, action } from './__note-editor.tsx'
+
+export async function loader({ request }: DataFunctionArgs) {
+ await requireUserId(request)
+ return json({})
+}
+
+export { action }
+export default NoteEditor
diff --git a/app/routes/users+/$username_+/notes.tsx b/app/routes/users+/$username_+/notes.tsx
new file mode 100644
index 0000000..82c43a6
--- /dev/null
+++ b/app/routes/users+/$username_+/notes.tsx
@@ -0,0 +1,99 @@
+import { json, type DataFunctionArgs } from '@remix-run/node'
+import { Link, NavLink, Outlet, useLoaderData } from '@remix-run/react'
+import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
+import { Icon } from '#app/components/ui/icon.tsx'
+import { prisma } from '#app/utils/db.server.ts'
+import { cn, getUserImgSrc, invariantResponse } from '#app/utils/misc.tsx'
+import { useOptionalUser } from '#app/utils/user.ts'
+
+export async function loader({ params }: DataFunctionArgs) {
+ const owner = await prisma.user.findFirst({
+ select: {
+ id: true,
+ name: true,
+ username: true,
+ image: { select: { id: true } },
+ notes: { select: { id: true, title: true } },
+ },
+ where: { username: params.username },
+ })
+
+ invariantResponse(owner, 'Owner not found', { status: 404 })
+
+ return json({ owner })
+}
+
+export default function NotesRoute() {
+ const data = useLoaderData()
+ const user = useOptionalUser()
+ const isOwner = user?.id === data.owner.id
+ const ownerDisplayName = data.owner.name ?? data.owner.username
+ const navLinkDefaultClassName =
+ 'line-clamp-2 block rounded-l-full py-2 pl-8 pr-6 text-base lg:text-xl'
+ return (
+
+
+
+
+
+
+
+ {ownerDisplayName}'s Notes
+
+
+
+ {isOwner ? (
+
+
+ cn(navLinkDefaultClassName, isActive && 'bg-accent')
+ }
+ >
+ New Note
+
+
+ ) : null}
+ {data.owner.notes.map(note => (
+
+
+ cn(navLinkDefaultClassName, isActive && 'bg-accent')
+ }
+ >
+ {note.title}
+
+
+ ))}
+
+
+
+
+
+
+
+
+ )
+}
+
+export function ErrorBoundary() {
+ return (
+ (
+ No user with the username "{params.username}" exists
+ ),
+ }}
+ />
+ )
+}
diff --git a/app/routes/users+/index.tsx b/app/routes/users+/index.tsx
new file mode 100644
index 0000000..c20f0e7
--- /dev/null
+++ b/app/routes/users+/index.tsx
@@ -0,0 +1,113 @@
+import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
+import { Link, useLoaderData } from '@remix-run/react'
+import { z } from 'zod'
+import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
+import { ErrorList } from '#app/components/forms.tsx'
+import { SearchBar } from '#app/components/search-bar.tsx'
+import { prisma } from '#app/utils/db.server.ts'
+import { cn, getUserImgSrc, useDelayedIsPending } from '#app/utils/misc.tsx'
+
+const UserSearchResultSchema = z.object({
+ id: z.string(),
+ username: z.string(),
+ name: z.string().nullable(),
+ imageId: z.string().nullable(),
+})
+
+const UserSearchResultsSchema = z.array(UserSearchResultSchema)
+
+export async function loader({ request }: DataFunctionArgs) {
+ const searchTerm = new URL(request.url).searchParams.get('search')
+ if (searchTerm === '') {
+ return redirect('/users')
+ }
+
+ const like = `%${searchTerm ?? ''}%`
+ const rawUsers = await prisma.$queryRaw`
+ SELECT User.id, User.username, User.name, UserImage.id AS imageId
+ FROM User
+ LEFT JOIN UserImage ON User.id = UserImage.userId
+ WHERE User.username LIKE ${like}
+ OR User.name LIKE ${like}
+ ORDER BY (
+ SELECT Note.updatedAt
+ FROM Note
+ WHERE Note.ownerId = User.id
+ ORDER BY Note.updatedAt DESC
+ LIMIT 1
+ ) DESC
+ LIMIT 50
+ `
+
+ const result = UserSearchResultsSchema.safeParse(rawUsers)
+ if (!result.success) {
+ return json({ status: 'error', error: result.error.message } as const, {
+ status: 400,
+ })
+ }
+ return json({ status: 'idle', users: result.data } as const)
+}
+
+export default function UsersRoute() {
+ const data = useLoaderData()
+ const isPending = useDelayedIsPending({
+ formMethod: 'GET',
+ formAction: '/users',
+ })
+
+ if (data.status === 'error') {
+ console.error(data.error)
+ }
+
+ return (
+
+
Epic Notes Users
+
+
+
+
+ {data.status === 'idle' ? (
+ data.users.length ? (
+
+ {data.users.map(user => (
+
+
+
+ {user.name ? (
+
+ {user.name}
+
+ ) : null}
+
+ {user.username}
+
+
+
+ ))}
+
+ ) : (
+ No users found
+ )
+ ) : data.status === 'error' ? (
+
+ ) : null}
+
+
+ )
+}
+
+export function ErrorBoundary() {
+ return
+}
diff --git a/app/styles/font.css b/app/styles/font.css
new file mode 100644
index 0000000..b4319d6
--- /dev/null
+++ b/app/styles/font.css
@@ -0,0 +1,328 @@
+/* nunito-sans-200 - latin_latin-ext */
+@font-face {
+ font-display: swap;
+ font-family: 'Nunito Sans';
+ font-style: normal;
+ font-weight: 200;
+ src:
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200.woff2')
+ format('woff2'),
+ /* Chrome 36+, Opera 23+, Firefox 39+ */
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200.woff')
+ format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* nunito-sans-200italic - latin_latin-ext */
+@font-face {
+ font-display: swap;
+ font-family: 'Nunito Sans';
+ font-style: italic;
+ font-weight: 200;
+ src:
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200italic.woff2')
+ format('woff2'),
+ /* Chrome 36+, Opera 23+, Firefox 39+ */
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200italic.woff')
+ format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* nunito-sans-300 - latin_latin-ext */
+@font-face {
+ font-display: swap;
+ font-family: 'Nunito Sans';
+ font-style: normal;
+ font-weight: 300;
+ src:
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300.woff2')
+ format('woff2'),
+ /* Chrome 36+, Opera 23+, Firefox 39+ */
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300.woff')
+ format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* nunito-sans-300italic - latin_latin-ext */
+@font-face {
+ font-display: swap;
+ font-family: 'Nunito Sans';
+ font-style: italic;
+ font-weight: 300;
+ src:
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300italic.woff2')
+ format('woff2'),
+ /* Chrome 36+, Opera 23+, Firefox 39+ */
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300italic.woff')
+ format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* nunito-sans-regular - latin_latin-ext */
+@font-face {
+ font-display: swap;
+ font-family: 'Nunito Sans';
+ font-style: normal;
+ font-weight: 400;
+ src:
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-regular.woff2')
+ format('woff2'),
+ /* Chrome 36+, Opera 23+, Firefox 39+ */
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-regular.woff')
+ format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* nunito-sans-italic - latin_latin-ext */
+@font-face {
+ font-display: swap;
+ font-family: 'Nunito Sans';
+ font-style: italic;
+ font-weight: 400;
+ src:
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-italic.woff2')
+ format('woff2'),
+ /* Chrome 36+, Opera 23+, Firefox 39+ */
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-italic.woff')
+ format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* nunito-sans-600 - latin_latin-ext */
+@font-face {
+ font-display: swap;
+ font-family: 'Nunito Sans';
+ font-style: normal;
+ font-weight: 600;
+ src:
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600.woff2')
+ format('woff2'),
+ /* Chrome 36+, Opera 23+, Firefox 39+ */
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600.woff')
+ format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* nunito-sans-600italic - latin_latin-ext */
+@font-face {
+ font-display: swap;
+ font-family: 'Nunito Sans';
+ font-style: italic;
+ font-weight: 600;
+ src:
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600italic.woff2')
+ format('woff2'),
+ /* Chrome 36+, Opera 23+, Firefox 39+ */
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600italic.woff')
+ format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* nunito-sans-700 - latin_latin-ext */
+@font-face {
+ font-display: swap;
+ font-family: 'Nunito Sans';
+ font-style: normal;
+ font-weight: 700;
+ src:
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700.woff2')
+ format('woff2'),
+ /* Chrome 36+, Opera 23+, Firefox 39+ */
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700.woff')
+ format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* nunito-sans-700italic - latin_latin-ext */
+@font-face {
+ font-display: swap;
+ font-family: 'Nunito Sans';
+ font-style: italic;
+ font-weight: 700;
+ src:
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700italic.woff2')
+ format('woff2'),
+ /* Chrome 36+, Opera 23+, Firefox 39+ */
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700italic.woff')
+ format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* nunito-sans-800 - latin_latin-ext */
+@font-face {
+ font-display: swap;
+ font-family: 'Nunito Sans';
+ font-style: normal;
+ font-weight: 800;
+ src:
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800.woff2')
+ format('woff2'),
+ /* Chrome 36+, Opera 23+, Firefox 39+ */
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800.woff')
+ format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* nunito-sans-800italic - latin_latin-ext */
+@font-face {
+ font-display: swap;
+ font-family: 'Nunito Sans';
+ font-style: italic;
+ font-weight: 800;
+ src:
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800italic.woff2')
+ format('woff2'),
+ /* Chrome 36+, Opera 23+, Firefox 39+ */
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800italic.woff')
+ format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* nunito-sans-900 - latin_latin-ext */
+@font-face {
+ font-display: swap;
+ font-family: 'Nunito Sans';
+ font-style: normal;
+ font-weight: 900;
+ src:
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900.woff2')
+ format('woff2'),
+ /* Chrome 36+, Opera 23+, Firefox 39+ */
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900.woff')
+ format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* nunito-sans-900italic - latin_latin-ext */
+@font-face {
+ font-display: swap;
+ font-family: 'Nunito Sans';
+ font-style: italic;
+ font-weight: 900;
+ src:
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900italic.woff2')
+ format('woff2'),
+ /* Chrome 36+, Opera 23+, Firefox 39+ */
+ url('/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900italic.woff')
+ format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* Font metric overrides reduces CLS from font-swap.
+This is manually inserted, metric generated using CLI commands:
+npx fontpie ./public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200.woff2 -weight 200 -style normal
+npx fontpie ./public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300italic.woff2 -weight 300 -style italic
+etc...
+*/
+@font-face {
+ font-family: 'Nunito Sans Fallback';
+ font-style: normal;
+ font-weight: 200;
+ src: local('Arial');
+ ascent-override: 103.02%;
+ descent-override: 35.97%;
+ line-gap-override: 0%;
+ size-adjust: 98.13%;
+}
+@font-face {
+ font-family: 'Nunito Sans Fallback';
+ font-style: italic;
+ font-weight: 200;
+ src: local('Arial Italic');
+ ascent-override: 103.02%;
+ descent-override: 35.97%;
+ line-gap-override: 0%;
+ size-adjust: 98.13%;
+}
+@font-face {
+ font-family: 'Nunito Sans Fallback';
+ font-style: normal;
+ font-weight: 300;
+ src: local('Arial');
+ ascent-override: 101.29%;
+ descent-override: 35.37%;
+ line-gap-override: 0%;
+ size-adjust: 99.82%;
+}
+@font-face {
+ font-family: 'Nunito Sans Fallback';
+ font-style: italic;
+ font-weight: 300;
+ src: local('Arial Italic');
+ ascent-override: 101.27%;
+ descent-override: 35.36%;
+ line-gap-override: 0%;
+ size-adjust: 99.84%;
+}
+@font-face {
+ font-family: 'Nunito Sans Fallback';
+ font-style: normal;
+ font-weight: 400;
+ src: local('Arial');
+ ascent-override: 99.49%;
+ descent-override: 34.74%;
+ line-gap-override: 0%;
+ size-adjust: 101.62%;
+}
+@font-face {
+ font-family: 'Nunito Sans Fallback';
+ font-style: italic;
+ font-weight: 400;
+ src: local('Arial Italic');
+ ascent-override: 99.49%;
+ descent-override: 34.74%;
+ line-gap-override: 0%;
+ size-adjust: 101.62%;
+}
+@font-face {
+ font-family: 'Nunito Sans Fallback';
+ font-style: normal;
+ font-weight: 600;
+ src: local('Arial Bold');
+ ascent-override: 105.78%;
+ descent-override: 36.94%;
+ line-gap-override: 0%;
+ size-adjust: 95.57%;
+}
+@font-face {
+ font-family: 'Nunito Sans Fallback';
+ font-style: italic;
+ font-weight: 600;
+ src: local('Arial Bold Italic');
+ ascent-override: 105.78%;
+ descent-override: 36.94%;
+ line-gap-override: 0%;
+ size-adjust: 95.57%;
+}
+@font-face {
+ font-family: 'Nunito Sans Fallback';
+ font-style: normal;
+ font-weight: 700;
+ src: local('Arial Bold');
+ ascent-override: 103.63%;
+ descent-override: 36.18%;
+ line-gap-override: 0%;
+ size-adjust: 97.56%;
+}
+@font-face {
+ font-family: 'Nunito Sans Fallback';
+ font-style: italic;
+ font-weight: 700;
+ src: local('Arial Bold Italic');
+ ascent-override: 103.63%;
+ descent-override: 36.18%;
+ line-gap-override: 0%;
+ size-adjust: 97.56%;
+}
+@font-face {
+ font-family: 'Nunito Sans Fallback';
+ font-style: normal;
+ font-weight: 800;
+ src: local('Arial Bold');
+ ascent-override: 101.35%;
+ descent-override: 35.39%;
+ line-gap-override: 0%;
+ size-adjust: 99.75%;
+}
+@font-face {
+ font-family: 'Nunito Sans Fallback';
+ font-style: italic;
+ font-weight: 800;
+ src: local('Arial Bold Italic');
+ ascent-override: 101.35%;
+ descent-override: 35.39%;
+ line-gap-override: 0%;
+ size-adjust: 99.75%;
+}
+@font-face {
+ font-family: 'Nunito Sans Fallback';
+ font-style: normal;
+ font-weight: 900;
+ src: local('Arial Bold');
+ ascent-override: 99.1%;
+ descent-override: 34.6%;
+ line-gap-override: 0%;
+ size-adjust: 102.02%;
+}
+@font-face {
+ font-family: 'Nunito Sans Fallback';
+ font-style: italic;
+ font-weight: 900;
+ src: local('Arial Bold Italic');
+ ascent-override: 99.1%;
+ descent-override: 34.6%;
+ line-gap-override: 0%;
+ size-adjust: 102.02%;
+}
diff --git a/app/styles/tailwind.css b/app/styles/tailwind.css
new file mode 100644
index 0000000..801bd23
--- /dev/null
+++ b/app/styles/tailwind.css
@@ -0,0 +1,89 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --font-sans: Nunito Sans, Nunito Sans Fallback;
+ /* --font-mono: here if you got it... */
+
+ /* prefixed with foreground because it should look good on the background */
+ --foreground-danger: 345 82.7% 40.8%;
+
+ --background: 0 0% 100%;
+ --foreground: 222.2 84% 4.9%;
+
+ --muted: 210 40% 96.1%;
+ --muted-foreground: 215.4 16.3% 46.9%;
+
+ --popover: 0 0% 100%;
+ --popover-foreground: 222.2 84% 4.9%;
+
+ --card: 0 0% 100%;
+ --card-foreground: 222.2 84% 4.9%;
+
+ --border: 214.3 31.8% 91.4%;
+ --input: 214.3 31.8% 91.4%;
+ --input-invalid: 0 84.2% 60.2%;
+
+ --primary: 222.2 47.4% 11.2%;
+ --primary-foreground: 210 40% 98%;
+
+ --secondary: 210 40% 96.1%;
+ --secondary-foreground: 222.2 47.4% 11.2%;
+
+ --accent: 210 40% 90%;
+ --accent-foreground: 222.2 47.4% 11.2%;
+
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 210 40% 98%;
+
+ --ring: 215 20.2% 65.1%;
+
+ --radius: 0.5rem;
+ }
+
+ .dark {
+ --background: 222.2 84% 4.9%;
+ --foreground: 210 40% 98%;
+
+ /* prefixed with foreground because it should look good on the background */
+ --foreground-danger: -4 84% 60%;
+
+ --muted: 217.2 32.6% 17.5%;
+ --muted-foreground: 215 20.2% 65.1%;
+
+ --popover: 222.2 84% 4.9%;
+ --popover-foreground: 210 40% 98%;
+
+ --card: 222.2 84% 4.9%;
+ --card-foreground: 210 40% 98%;
+
+ --border: 217.2 32.6% 17.5%;
+ --input: 217.2 32.6% 17.5%;
+ --input-invalid: 0 62.8% 30.6%;
+
+ --primary: 210 40% 98%;
+ --primary-foreground: 222.2 47.4% 11.2%;
+
+ --secondary: 217.2 32.6% 17.5%;
+ --secondary-foreground: 210 40% 98%;
+
+ --accent: 217.2 32.6% 10%;
+ --accent-foreground: 210 40% 98%;
+
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 0 85.7% 97.3%;
+
+ --ring: 217.2 32.6% 17.5%;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/app/utils/auth.server.ts b/app/utils/auth.server.ts
new file mode 100644
index 0000000..4b8857e
--- /dev/null
+++ b/app/utils/auth.server.ts
@@ -0,0 +1,235 @@
+import { type Connection, type Password, type User } from '@prisma/client'
+import { redirect } from '@remix-run/node'
+import bcrypt from 'bcryptjs'
+import { Authenticator } from 'remix-auth'
+import { safeRedirect } from 'remix-utils'
+import { connectionSessionStorage, providers } from './connections.server.ts'
+import { prisma } from './db.server.ts'
+import { combineHeaders, downloadFile } from './misc.tsx'
+import { type ProviderUser } from './providers/provider.ts'
+import { sessionStorage } from './session.server.ts'
+
+export const SESSION_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30
+export const getSessionExpirationDate = () =>
+ new Date(Date.now() + SESSION_EXPIRATION_TIME)
+
+export const sessionKey = 'sessionId'
+
+export const authenticator = new Authenticator(
+ connectionSessionStorage,
+)
+
+for (const [providerName, provider] of Object.entries(providers)) {
+ authenticator.use(provider.getAuthStrategy(), providerName)
+}
+
+export async function getUserId(request: Request) {
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const sessionId = cookieSession.get(sessionKey)
+ if (!sessionId) return null
+ const session = await prisma.session.findUnique({
+ select: { user: { select: { id: true } } },
+ where: { id: sessionId, expirationDate: { gt: new Date() } },
+ })
+ if (!session?.user) {
+ throw redirect('/', {
+ headers: {
+ 'set-cookie': await sessionStorage.destroySession(cookieSession),
+ },
+ })
+ }
+ return session.user.id
+}
+
+export async function requireUserId(
+ request: Request,
+ { redirectTo }: { redirectTo?: string | null } = {},
+) {
+ const userId = await getUserId(request)
+ if (!userId) {
+ const requestUrl = new URL(request.url)
+ redirectTo =
+ redirectTo === null
+ ? null
+ : redirectTo ?? `${requestUrl.pathname}${requestUrl.search}`
+ const loginParams = redirectTo ? new URLSearchParams({ redirectTo }) : null
+ const loginRedirect = ['/login', loginParams?.toString()]
+ .filter(Boolean)
+ .join('?')
+ throw redirect(loginRedirect)
+ }
+ return userId
+}
+
+export async function requireAnonymous(request: Request) {
+ const userId = await getUserId(request)
+ if (userId) {
+ throw redirect('/')
+ }
+}
+
+export async function login({
+ username,
+ password,
+}: {
+ username: User['username']
+ password: string
+}) {
+ const user = await verifyUserPassword({ username }, password)
+ if (!user) return null
+ const session = await prisma.session.create({
+ select: { id: true, expirationDate: true, userId: true },
+ data: {
+ expirationDate: getSessionExpirationDate(),
+ userId: user.id,
+ },
+ })
+ return session
+}
+
+export async function resetUserPassword({
+ username,
+ password,
+}: {
+ username: User['username']
+ password: string
+}) {
+ const hashedPassword = await bcrypt.hash(password, 10)
+ return prisma.user.update({
+ where: { username },
+ data: {
+ password: {
+ update: {
+ hash: hashedPassword,
+ },
+ },
+ },
+ })
+}
+
+export async function signup({
+ email,
+ username,
+ password,
+ name,
+}: {
+ email: User['email']
+ username: User['username']
+ name: User['name']
+ password: string
+}) {
+ const hashedPassword = await getPasswordHash(password)
+
+ const session = await prisma.session.create({
+ data: {
+ expirationDate: getSessionExpirationDate(),
+ user: {
+ create: {
+ email: email.toLowerCase(),
+ username: username.toLowerCase(),
+ name,
+ roles: { connect: { name: 'user' } },
+ password: {
+ create: {
+ hash: hashedPassword,
+ },
+ },
+ },
+ },
+ },
+ select: { id: true, expirationDate: true },
+ })
+
+ return session
+}
+
+export async function signupWithConnection({
+ email,
+ username,
+ name,
+ providerId,
+ providerName,
+ imageUrl,
+}: {
+ email: User['email']
+ username: User['username']
+ name: User['name']
+ providerId: Connection['providerId']
+ providerName: Connection['providerName']
+ imageUrl?: string
+}) {
+ const session = await prisma.session.create({
+ data: {
+ expirationDate: getSessionExpirationDate(),
+ user: {
+ create: {
+ email: email.toLowerCase(),
+ username: username.toLowerCase(),
+ name,
+ connections: { create: { providerId, providerName } },
+ image: imageUrl
+ ? { create: await downloadFile(imageUrl) }
+ : undefined,
+ },
+ },
+ },
+ select: { id: true, expirationDate: true },
+ })
+
+ return session
+}
+
+export async function logout(
+ {
+ request,
+ redirectTo = '/',
+ }: {
+ request: Request
+ redirectTo?: string
+ },
+ responseInit?: ResponseInit,
+) {
+ const cookieSession = await sessionStorage.getSession(
+ request.headers.get('cookie'),
+ )
+ const sessionId = cookieSession.get(sessionKey)
+ // if this fails, we still need to delete the session from the user's browser
+ // and it doesn't do any harm staying in the db anyway.
+ void prisma.session.delete({ where: { id: sessionId } })
+ throw redirect(safeRedirect(redirectTo), {
+ ...responseInit,
+ headers: combineHeaders(
+ { 'set-cookie': await sessionStorage.destroySession(cookieSession) },
+ responseInit?.headers,
+ ),
+ })
+}
+
+export async function getPasswordHash(password: string) {
+ const hash = await bcrypt.hash(password, 10)
+ return hash
+}
+
+export async function verifyUserPassword(
+ where: Pick | Pick,
+ password: Password['hash'],
+) {
+ const userWithPassword = await prisma.user.findUnique({
+ where,
+ select: { id: true, password: { select: { hash: true } } },
+ })
+
+ if (!userWithPassword || !userWithPassword.password) {
+ return null
+ }
+
+ const isValid = await bcrypt.compare(password, userWithPassword.password.hash)
+
+ if (!isValid) {
+ return null
+ }
+
+ return { id: userWithPassword.id }
+}
diff --git a/app/utils/cache.server.ts b/app/utils/cache.server.ts
new file mode 100644
index 0000000..3b9373e
--- /dev/null
+++ b/app/utils/cache.server.ts
@@ -0,0 +1,167 @@
+import fs from 'fs'
+import { remember } from '@epic-web/remember'
+import Database from 'better-sqlite3'
+import {
+ cachified as baseCachified,
+ lruCacheAdapter,
+ verboseReporter,
+ mergeReporters,
+ type CacheEntry,
+ type Cache as CachifiedCache,
+ type CachifiedOptions,
+} from 'cachified'
+import { LRUCache } from 'lru-cache'
+import { z } from 'zod'
+import { updatePrimaryCacheValue } from '#app/routes/admin+/cache_.sqlite.tsx'
+import { getInstanceInfo, getInstanceInfoSync } from './litefs.server.ts'
+import { cachifiedTimingReporter, type Timings } from './timing.server.ts'
+
+const CACHE_DATABASE_PATH = process.env.CACHE_DATABASE_PATH
+
+const cacheDb = remember('cacheDb', createDatabase)
+
+function createDatabase(tryAgain = true): Database.Database {
+ const db = new Database(CACHE_DATABASE_PATH)
+ const { currentIsPrimary } = getInstanceInfoSync()
+ if (!currentIsPrimary) return db
+
+ try {
+ // create cache table with metadata JSON column and value JSON column if it does not exist already
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS cache (
+ key TEXT PRIMARY KEY,
+ metadata TEXT,
+ value TEXT
+ )
+ `)
+ } catch (error: unknown) {
+ fs.unlinkSync(CACHE_DATABASE_PATH)
+ if (tryAgain) {
+ console.error(
+ `Error creating cache database, deleting the file at "${CACHE_DATABASE_PATH}" and trying again...`,
+ )
+ return createDatabase(false)
+ }
+ throw error
+ }
+ return db
+}
+
+const lru = remember(
+ 'lru-cache',
+ () => new LRUCache>({ max: 5000 }),
+)
+
+export const lruCache = lruCacheAdapter(lru)
+
+const cacheEntrySchema = z.object({
+ metadata: z.object({
+ createdTime: z.number(),
+ ttl: z.number().nullable().optional(),
+ swr: z.number().nullable().optional(),
+ }),
+ value: z.unknown(),
+})
+const cacheQueryResultSchema = z.object({
+ metadata: z.string(),
+ value: z.string(),
+})
+
+export const cache: CachifiedCache = {
+ name: 'SQLite cache',
+ get(key) {
+ const result = cacheDb
+ .prepare('SELECT value, metadata FROM cache WHERE key = ?')
+ .get(key)
+ const parseResult = cacheQueryResultSchema.safeParse(result)
+ if (!parseResult.success) return null
+
+ const parsedEntry = cacheEntrySchema.safeParse({
+ metadata: JSON.parse(parseResult.data.metadata),
+ value: JSON.parse(parseResult.data.value),
+ })
+ if (!parsedEntry.success) return null
+ const { metadata, value } = parsedEntry.data
+ if (!value) return null
+ return { metadata, value }
+ },
+ async set(key, entry) {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ const { currentIsPrimary, primaryInstance } = await getInstanceInfo()
+ if (currentIsPrimary) {
+ cacheDb
+ .prepare(
+ 'INSERT OR REPLACE INTO cache (key, value, metadata) VALUES (@key, @value, @metadata)',
+ )
+ .run({
+ key,
+ value: JSON.stringify(entry.value),
+ metadata: JSON.stringify(entry.metadata),
+ })
+ } else {
+ // fire-and-forget cache update
+ void updatePrimaryCacheValue({
+ key,
+ cacheValue: entry,
+ }).then(response => {
+ if (!response.ok) {
+ console.error(
+ `Error updating cache value for key "${key}" on primary instance (${primaryInstance}): ${response.status} ${response.statusText}`,
+ { entry },
+ )
+ }
+ })
+ }
+ },
+ async delete(key) {
+ const { currentIsPrimary, primaryInstance } = await getInstanceInfo()
+ if (currentIsPrimary) {
+ cacheDb.prepare('DELETE FROM cache WHERE key = ?').run(key)
+ } else {
+ // fire-and-forget cache update
+ void updatePrimaryCacheValue({
+ key,
+ cacheValue: undefined,
+ }).then(response => {
+ if (!response.ok) {
+ console.error(
+ `Error deleting cache value for key "${key}" on primary instance (${primaryInstance}): ${response.status} ${response.statusText}`,
+ )
+ }
+ })
+ }
+ },
+}
+
+export async function getAllCacheKeys(limit: number) {
+ return {
+ sqlite: cacheDb
+ .prepare('SELECT key FROM cache LIMIT ?')
+ .all(limit)
+ .map(row => (row as { key: string }).key),
+ lru: [...lru.keys()],
+ }
+}
+
+export async function searchCacheKeys(search: string, limit: number) {
+ return {
+ sqlite: cacheDb
+ .prepare('SELECT key FROM cache WHERE key LIKE ? LIMIT ?')
+ .all(`%${search}%`, limit)
+ .map(row => (row as { key: string }).key),
+ lru: [...lru.keys()].filter(key => key.includes(search)),
+ }
+}
+
+export async function cachified({
+ timings,
+ reporter = verboseReporter(),
+ ...options
+}: CachifiedOptions & {
+ timings?: Timings
+}): Promise {
+ return baseCachified({
+ ...options,
+ reporter: mergeReporters(cachifiedTimingReporter(timings), reporter),
+ })
+}
diff --git a/app/utils/client-hints.tsx b/app/utils/client-hints.tsx
new file mode 100644
index 0000000..f3e1628
--- /dev/null
+++ b/app/utils/client-hints.tsx
@@ -0,0 +1,141 @@
+/**
+ * This file contains utilities for using client hints for user preference which
+ * are needed by the server, but are only known by the browser.
+ */
+import { useRevalidator } from '@remix-run/react'
+import * as React from 'react'
+import { useRequestInfo } from './request-info.ts'
+
+const clientHints = {
+ theme: {
+ cookieName: 'CH-prefers-color-scheme',
+ getValueCode: `window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'`,
+ fallback: 'light',
+ transform(value: string) {
+ return value === 'dark' ? 'dark' : 'light'
+ },
+ },
+ timeZone: {
+ cookieName: 'CH-time-zone',
+ getValueCode: `Intl.DateTimeFormat().resolvedOptions().timeZone`,
+ fallback: 'UTC',
+ },
+ // add other hints here
+}
+
+type ClientHintNames = keyof typeof clientHints
+
+function getCookieValue(cookieString: string, name: ClientHintNames) {
+ const hint = clientHints[name]
+ if (!hint) {
+ throw new Error(`Unknown client hint: ${name}`)
+ }
+ const value = cookieString
+ .split(';')
+ .map(c => c.trim())
+ .find(c => c.startsWith(hint.cookieName + '='))
+ ?.split('=')[1]
+
+ return value ? decodeURIComponent(value) : null
+}
+
+/**
+ *
+ * @param request {Request} - optional request object (only used on server)
+ * @returns an object with the client hints and their values
+ */
+export function getHints(request?: Request) {
+ const cookieString =
+ typeof document !== 'undefined'
+ ? document.cookie
+ : typeof request !== 'undefined'
+ ? request.headers.get('Cookie') ?? ''
+ : ''
+
+ return Object.entries(clientHints).reduce(
+ (acc, [name, hint]) => {
+ const hintName = name as ClientHintNames
+ if ('transform' in hint) {
+ acc[hintName] = hint.transform(
+ getCookieValue(cookieString, hintName) ?? hint.fallback,
+ )
+ } else {
+ // @ts-expect-error - this is fine (PRs welcome though)
+ acc[hintName] = getCookieValue(cookieString, hintName) ?? hint.fallback
+ }
+ return acc
+ },
+ {} as {
+ [name in ClientHintNames]: (typeof clientHints)[name] extends {
+ transform: (value: any) => infer ReturnValue
+ }
+ ? ReturnValue
+ : (typeof clientHints)[name]['fallback']
+ },
+ )
+}
+
+/**
+ * @returns an object with the client hints and their values
+ */
+export function useHints() {
+ const requestInfo = useRequestInfo()
+ return requestInfo.hints
+}
+
+/**
+ * @returns inline script element that checks for client hints and sets cookies
+ * if they are not set then reloads the page if any cookie was set to an
+ * inaccurate value.
+ */
+export function ClientHintCheck({ nonce }: { nonce: string }) {
+ const { revalidate } = useRevalidator()
+ React.useEffect(() => {
+ const themeQuery = window.matchMedia('(prefers-color-scheme: dark)')
+ function handleThemeChange() {
+ document.cookie = `${clientHints.theme.cookieName}=${
+ themeQuery.matches ? 'dark' : 'light'
+ }`
+ revalidate()
+ }
+ themeQuery.addEventListener('change', handleThemeChange)
+ return () => {
+ themeQuery.removeEventListener('change', handleThemeChange)
+ }
+ }, [revalidate])
+
+ return (
+