├── .assets
├── account.png
├── email.png
├── home.png
├── login.png
└── verify.png
├── .dev.vars.example
├── .github
├── dependabot.yml
└── workflows
│ └── pull-request-validation.yaml
├── .gitignore
├── .vscode
├── extensions.json
├── react-router.code-snippets
└── settings.json
├── README.md
├── app
├── components
│ ├── account
│ │ ├── appearance.tsx
│ │ ├── delete-account.tsx
│ │ ├── session-manage.tsx
│ │ └── user-profile.tsx
│ ├── color-scheme-toggle.tsx
│ ├── error-boundary.tsx
│ ├── icons.tsx
│ ├── progress-bar.tsx
│ ├── todos
│ │ ├── delete-todo.tsx
│ │ └── toggle-todo.tsx
│ ├── ui
│ │ ├── alert-dialog.tsx
│ │ ├── avatar.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── checkbox.tsx
│ │ ├── dialog.tsx
│ │ ├── drawer.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── radio-group.tsx
│ │ ├── skeleton.tsx
│ │ ├── spinner.tsx
│ │ ├── status-button.tsx
│ │ ├── switch.tsx
│ │ ├── textarea.tsx
│ │ └── tooltip.tsx
│ └── user-nav.tsx
├── entry.server.tsx
├── hooks
│ ├── use-double-check.ts
│ ├── use-is-pending.ts
│ ├── use-media-query.tsx
│ ├── use-nonce.ts
│ ├── use-toast.ts
│ └── use-user.ts
├── lib
│ ├── auth
│ │ ├── auth.server.ts
│ │ ├── honeypot.server.ts
│ │ ├── session.server.ts
│ │ ├── strategies
│ │ │ ├── github.ts
│ │ │ ├── google.ts
│ │ │ └── totp.ts
│ │ └── verification.server.ts
│ ├── color-scheme
│ │ ├── components.tsx
│ │ └── server.ts
│ ├── config.ts
│ ├── contexts.ts
│ ├── db
│ │ ├── drizzle.server.ts
│ │ ├── helpers.ts
│ │ └── schema.ts
│ ├── email
│ │ ├── email-validator.server.ts
│ │ ├── email.server.ts
│ │ ├── providers
│ │ │ ├── resend.server.ts
│ │ │ └── types.ts
│ │ └── templates
│ │ │ └── auth-totp.ts
│ ├── env.server.ts
│ ├── http.server.ts
│ ├── logger.ts
│ ├── middlewares
│ │ └── auth-guard.server.ts
│ ├── schemas.ts
│ ├── toast.server.ts
│ ├── utils.ts
│ └── workers
│ │ ├── helpers.ts
│ │ ├── rate-limiter.server.ts
│ │ └── session-manager.server.ts
├── root.tsx
├── routes.ts
├── routes
│ ├── account.tsx
│ ├── api
│ │ └── color-scheme.ts
│ ├── auth
│ │ ├── layout.tsx
│ │ ├── login.tsx
│ │ ├── logout.ts
│ │ ├── provider-callback.ts
│ │ └── verify.tsx
│ ├── home.tsx
│ ├── index.tsx
│ ├── layout.tsx
│ ├── not-found.tsx
│ └── todos.tsx
└── styles
│ └── app.css
├── biome.json
├── commitlint.config.cjs
├── components.json
├── drizzle.config.ts
├── drizzle
├── 0000_lethal_kulan_gath.sql
└── meta
│ ├── 0000_snapshot.json
│ └── _journal.json
├── lefthook.yml
├── package.json
├── pnpm-lock.yaml
├── public
├── favicon.ico
├── icons
│ ├── apple-touch-icon.png
│ ├── icon-192x192.png
│ ├── icon-256x256.png
│ ├── icon-384x384.png
│ └── icon-512x512.png
├── images
│ ├── ui-dark.png
│ ├── ui-light.png
│ └── ui-system.png
└── manifest.json
├── react-router.config.ts
├── tsconfig.json
├── vite.config.ts
├── worker-configuration.d.ts
├── workers
├── app.ts
└── workflows
│ └── backup-workflow.ts
└── wrangler.jsonc
/.assets/account.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/foxlau/react-router-v7-remix-auth/d0379709c1cf4da24aef1c9b3cd9315d5645684e/.assets/account.png
--------------------------------------------------------------------------------
/.assets/email.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/foxlau/react-router-v7-remix-auth/d0379709c1cf4da24aef1c9b3cd9315d5645684e/.assets/email.png
--------------------------------------------------------------------------------
/.assets/home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/foxlau/react-router-v7-remix-auth/d0379709c1cf4da24aef1c9b3cd9315d5645684e/.assets/home.png
--------------------------------------------------------------------------------
/.assets/login.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/foxlau/react-router-v7-remix-auth/d0379709c1cf4da24aef1c9b3cd9315d5645684e/.assets/login.png
--------------------------------------------------------------------------------
/.assets/verify.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/foxlau/react-router-v7-remix-auth/d0379709c1cf4da24aef1c9b3cd9315d5645684e/.assets/verify.png
--------------------------------------------------------------------------------
/.dev.vars.example:
--------------------------------------------------------------------------------
1 | ENVIRONMENT = "development" # development | production
2 |
3 | APP_URL = "http://localhost:5173"
4 | SESSION_SECRET = "3ebc25b381e87193f29ffea6b6d380dd"
5 | HONEYPOT_SECRET = "759657ffa254f2f17d9df02763f2138f"
6 |
7 | GITHUB_CLIENT_ID = "..."
8 | GITHUB_CLIENT_SECRET = "..."
9 | GOOGLE_CLIENT_ID = "..."
10 | GOOGLE_CLIENT_SECRET = "..."
11 | RESEND_API_KEY = "..."
12 |
13 | CLOUDFLARE_ACCOUNT_ID = "..."
14 | CLOUDFLARE_DATABASE_ID = "..."
15 | D1_REST_API_TOKEN = "..."
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "npm" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "weekly"
12 |
13 | - package-ecosystem: github-actions # Enable version updates for Github-Actions.
14 | directory: /
15 | schedule:
16 | interval: weekly
17 |
--------------------------------------------------------------------------------
/.github/workflows/pull-request-validation.yaml:
--------------------------------------------------------------------------------
1 | name: 🔍 PR Code Validation
2 |
3 | concurrency:
4 | group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }}
5 | cancel-in-progress: true
6 |
7 | on:
8 | pull_request:
9 | branches:
10 | - main
11 | paths:
12 | - "app/**"
13 | - "workers/**"
14 | - "*.js"
15 | - "*.ts"
16 | - "*.tsx"
17 | - "package.json"
18 | - "pnpm-lock.yaml"
19 |
20 | jobs:
21 | lint:
22 | name: ⬣ Biome lint
23 | runs-on: ubuntu-latest
24 | steps:
25 | - uses: actions/checkout@v4
26 | - uses: biomejs/setup-biome@v2
27 | - run: biome ci . --reporter=github
28 |
29 | typecheck:
30 | needs: lint
31 | name: 🔎 Type check
32 | runs-on: ubuntu-latest
33 | steps:
34 | - name: 📥 Checkout
35 | uses: actions/checkout@v4
36 |
37 | - name: 📦 Setup pnpm
38 | uses: pnpm/action-setup@v4
39 | with:
40 | version: 9.15.5
41 |
42 | - name: 🟢 Set up Node.js
43 | uses: actions/setup-node@v4
44 | with:
45 | node-version: 20
46 | cache: "pnpm"
47 |
48 | - name: 📎 Install dependencies
49 | run: pnpm install
50 |
51 | - name: 🔧 Generate Types
52 | run: pnpm typegen
53 |
54 | - name: 📝 Run type check
55 | run: pnpm typecheck
56 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /node_modules/
3 |
4 | # React Router
5 | /.react-router/
6 | /build/
7 | /dist/
8 |
9 | # Cloudflare
10 | .mf
11 | .wrangler
12 | .dev.vars
13 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["biomejs.biome", "redhat.vscode-yaml"]
3 | }
4 |
--------------------------------------------------------------------------------
/.vscode/react-router.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | "loader": {
3 | "prefix": "/loader",
4 | "body": [
5 | "",
6 | "export async function loader({ request }: Route.LoaderArgs) {",
7 | " return null",
8 | "}"
9 | ]
10 | },
11 | "clientLoader": {
12 | "prefix": "/clientLoader",
13 | "body": [
14 | "",
15 | "export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) {",
16 | " const data = await serverLoader();",
17 | " return data",
18 | "}"
19 | ]
20 | },
21 | "action": {
22 | "prefix": "/action",
23 | "body": [
24 | "",
25 | "export async function action({ request }: Route.ActionArgs) {",
26 | " return null",
27 | "}"
28 | ]
29 | },
30 | "clientAction": {
31 | "prefix": "/clientAction",
32 | "body": [
33 | "",
34 | "export async function clientAction({ request }: Route.ClientActionArgs) {",
35 | " return null",
36 | "}"
37 | ]
38 | },
39 | "default": {
40 | "prefix": "/default",
41 | "body": [
42 | "export default function ${TM_FILENAME_BASE/[^a-zA-Z0-9]*([a-zA-Z0-9])([a-zA-Z0-9]*)/${1:/capitalize}${2}/g}Route() {",
43 | " return (",
44 | "
",
45 | "
Unknown Route ",
46 | " ",
47 | " )",
48 | "}"
49 | ]
50 | },
51 | "headers": {
52 | "prefix": "/headers",
53 | "body": [
54 | "",
55 | "export const headers: Route.HeadersFunction = ({ loaderHeaders }) => ({",
56 | " 'Cache-Control': loaderHeaders.get('Cache-Control') ?? '',",
57 | "})"
58 | ]
59 | },
60 | "links": {
61 | "prefix": "/links",
62 | "body": [
63 | "",
64 | "export const links: Route.LinksFunction = () => [",
65 | " ",
66 | "];"
67 | ]
68 | },
69 | "meta": {
70 | "prefix": "/meta",
71 | "body": [
72 | "",
73 | "export const meta: Route.MetaFunction = () => [",
74 | " { title: \"Page title\" }",
75 | "]"
76 | ]
77 | },
78 | "component": {
79 | "prefix": "/component",
80 | "body": [
81 | "export function ${TM_FILENAME_BASE/[^a-zA-Z0-9]*([a-zA-Z0-9])([a-zA-Z0-9]*)/${1:/capitalize}${2}/g}() {",
82 | " return (",
83 | "
",
84 | " )",
85 | "}"
86 | ]
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.enable": false,
3 | "prettier.enable": false,
4 | "editor.defaultFormatter": "biomejs.biome",
5 | "editor.formatOnSave": true,
6 | "editor.formatOnType": false,
7 | "editor.trimAutoWhitespace": true,
8 | "editor.insertSpaces": false,
9 | "editor.renderWhitespace": "all",
10 | "editor.codeActionsOnSave": {
11 | "source.fixAll.biome": "explicit",
12 | "source.organizeImports": "never",
13 | "source.organizeImports.biome": "always",
14 | "quickfix.biome": "always"
15 | },
16 | "files.associations": {
17 | "*.css": "css",
18 | "*.js": "javascript",
19 | "*.jsx": "javascriptreact",
20 | "*.json": "json",
21 | "*.ts": "typescript",
22 | "*.tsx": "typescriptreact",
23 | "*.yml": "yaml",
24 | "*.yaml": "yaml"
25 | },
26 | "files.trimFinalNewlines": true,
27 | "files.trimTrailingWhitespace": true,
28 | "files.trimTrailingWhitespaceInRegexAndStrings": true,
29 | "[css]": {
30 | "editor.defaultFormatter": "biomejs.biome"
31 | },
32 | "[javascript]": {
33 | "editor.defaultFormatter": "biomejs.biome"
34 | },
35 | "[javascriptreact]": {
36 | "editor.defaultFormatter": "biomejs.biome"
37 | },
38 | "[json]": {
39 | "editor.defaultFormatter": "biomejs.biome"
40 | },
41 | "[typescript]": {
42 | "editor.defaultFormatter": "biomejs.biome"
43 | },
44 | "[typescriptreact]": {
45 | "editor.defaultFormatter": "biomejs.biome"
46 | },
47 | "[yaml]": {
48 | "editor.defaultFormatter": "redhat.vscode-yaml"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Router v7 with Remix Auth Starter Kit
2 |
3 | An introductory starter kit for building applications with React Router v7 (Remix) and Remix Auth, designed to run seamlessly on Cloudflare Workers.
4 |
5 | ## Features
6 | - 🔐 **TOTP, Google, and GitHub Login**
7 | Supports TOTP authentication, Google login, and GitHub login. TOTP is integrated with Resend for secure email-based verification.
8 |
9 | - 🔑 **KV-Based Authentication and Rate Limiting**
10 | Efficient session management and rate limiting using Cloudflare KV.
11 |
12 | - 🛢️ **Drizzle ORM + Cloudflare D1**
13 | Seamless database integration with Drizzle ORM and Cloudflare D1.
14 |
15 | - 🌗 **Dynamic Color Schemes**
16 | Supports theme customization with color scheme switching.
17 |
18 | - 🎨 **TailwindCSS + Shadcn UI**
19 | Modern and customizable UI styling with TailwindCSS and Shadcn components.
20 |
21 | - 🧪 **Biome.js for Code Quality**
22 | Ensures high-quality code with integrated linting and formatting.
23 |
24 | - 🚀 **Cloudflare Workers-Ready**
25 | Optimized for deployment on Cloudflare Workers.
26 |
27 | ## Demo
28 |
29 | Here's a preview of the app:
30 |
31 |
32 |
33 |
34 |
35 |
36 | For more demo images, check the **.assets** directory.
37 |
38 |
39 | ## Links
40 |
41 | More from the React Router v7 Series:
42 | - [React Router v7 with Better Auth](https://github.com/foxlau/react-router-v7-better-auth) - Authentication demo using Better Auth package.
43 | - [React Router v7 Cloudflare workers template](https://github.com/foxlau/react-router-v7-cloudflare-workers) - React Router v7 Cloudflare workers template.
44 |
45 | ## Getting Started
46 |
47 | ### Installation
48 |
49 | Git clone the repository:
50 |
51 | ```bash
52 | git clone https://github.com/foxlau/react-router-v7-remix-auth.git
53 | ```
54 |
55 | Install the dependencies:
56 |
57 | ```bash
58 | npm install
59 | ```
60 |
61 | ### Development
62 |
63 | First, copy the .dev.vars.example file and rename it to .dev.vars:
64 |
65 | ```bash
66 | cp .dev.vars.example .dev.vars
67 | ```
68 |
69 | Update the environment variables in the .dev.vars file according to your needs. These variables will be used by Wrangler during local development.
70 |
71 | Run an initial database migration:
72 |
73 | ```bash
74 | npm run db:apply
75 | ```
76 |
77 | If you modify the Drizzle ORM schema, please run `npm run db:generate` first. If you need to delete the generated SQL migrations, execute `npm run db:drop` and select the SQL migration you wish to remove.
78 |
79 | Start the development server with HMR:
80 |
81 | ```bash
82 | npm run dev
83 | ```
84 |
85 | Your application will be available at `http://localhost:5173`.
86 |
87 | ## Building for Production
88 |
89 | Create a production build:
90 |
91 | ```bash
92 | npm run build
93 | ```
94 |
95 | ## Deployment
96 |
97 | Deployment is done using the Wrangler CLI.
98 |
99 | ```bash
100 | npx wrangler d1 create rr7-remix-auth
101 | npx wrangler kv namespace create APP_KV
102 | ```
103 |
104 | To deploy directly to production:
105 |
106 | ```sh
107 | npm run db:apply-prod
108 | npm run deploy
109 | ```
110 |
111 | To deploy a preview URL:
112 |
113 | ```sh
114 | npm run deploy:version
115 | ```
116 |
117 | You can then promote a version to production after verification or roll it out progressively.
118 |
119 | ```sh
120 | npm run deploy:promote
121 | ```
122 |
123 | ## Questions
124 |
125 | If you have any questions, please open an issue.
--------------------------------------------------------------------------------
/app/components/account/appearance.tsx:
--------------------------------------------------------------------------------
1 | import { CheckIcon, MinusIcon } from "lucide-react";
2 | import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group";
3 | import {
4 | type ColorScheme,
5 | ColorSchemeSchema,
6 | useColorScheme,
7 | useSetColorScheme,
8 | } from "~/lib/color-scheme/components";
9 | import UiDark from "/images/ui-dark.png";
10 | import UiLight from "/images/ui-light.png";
11 | import UiSystem from "/images/ui-system.png";
12 |
13 | const THEME_IMAGES = {
14 | light: UiLight,
15 | dark: UiDark,
16 | system: UiSystem,
17 | } as const;
18 |
19 | export function Appearance() {
20 | const setColorScheme = useSetColorScheme();
21 | const colorScheme = useColorScheme();
22 |
23 | return (
24 |
25 |
32 |
33 |
setColorScheme(value)}
39 | >
40 | {ColorSchemeSchema.shape.colorScheme.options.map((value) => (
41 |
42 |
47 |
54 |
55 |
60 |
65 | {value}
66 |
67 |
68 | ))}
69 |
70 |
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/app/components/account/delete-account.tsx:
--------------------------------------------------------------------------------
1 | import { getFormProps, getInputProps, useForm } from "@conform-to/react";
2 | import { getZodConstraint, parseWithZod } from "@conform-to/zod";
3 | import { useState } from "react";
4 | import { Form } from "react-router";
5 | import { useIsPending } from "~/hooks/use-is-pending";
6 | import { useMediaQuery } from "~/hooks/use-media-query";
7 | import { useUser } from "~/hooks/use-user";
8 | import { accountSchema } from "~/lib/schemas";
9 | import { cn } from "~/lib/utils";
10 | import { Button } from "../ui/button";
11 | import {
12 | Dialog,
13 | DialogContent,
14 | DialogDescription,
15 | DialogHeader,
16 | DialogTitle,
17 | DialogTrigger,
18 | } from "../ui/dialog";
19 | import {
20 | Drawer,
21 | DrawerClose,
22 | DrawerContent,
23 | DrawerDescription,
24 | DrawerFooter,
25 | DrawerHeader,
26 | DrawerTitle,
27 | DrawerTrigger,
28 | } from "../ui/drawer";
29 | import { Input } from "../ui/input";
30 | import { StatusButton } from "../ui/status-button";
31 |
32 | const MODAL_TITLE = "Delete account";
33 | const MODAL_DESCRIPTION = (email: string) => (
34 | <>
35 | This action is irreversible. To confirm, please type{" "}
36 | {email} in the box below.
37 | >
38 | );
39 |
40 | export function DeleteAccount() {
41 | const user = useUser();
42 | const [open, setOpen] = useState(false);
43 | const isDesktop = useMediaQuery("(min-width: 768px)");
44 | const isPending = useIsPending({
45 | formAction: "/account",
46 | formMethod: "DELETE",
47 | });
48 |
49 | return (
50 |
51 |
59 |
60 | {isDesktop ? (
61 |
62 |
63 | Delete account
64 |
65 |
66 |
67 | {MODAL_TITLE}
68 |
69 | {MODAL_DESCRIPTION(user.email)}
70 |
71 |
72 |
73 |
74 |
75 | ) : (
76 |
77 |
78 | Delete account
79 |
80 |
81 |
82 | {MODAL_TITLE}
83 |
84 | {MODAL_DESCRIPTION(user.email)}
85 |
86 |
87 |
88 |
89 |
90 |
91 | Cancel
92 |
93 |
94 |
95 |
96 |
97 | )}
98 |
99 |
100 | );
101 | }
102 |
103 | export function DeleteAccountForm({
104 | isPending,
105 | className,
106 | }: React.ComponentProps<"form"> & { isPending: boolean }) {
107 | const [form, { email }] = useForm({
108 | onValidate({ formData }) {
109 | return parseWithZod(formData, { schema: accountSchema });
110 | },
111 | constraint: getZodConstraint(accountSchema),
112 | shouldRevalidate: "onInput",
113 | });
114 |
115 | return (
116 |
146 | );
147 | }
148 |
--------------------------------------------------------------------------------
/app/components/account/session-manage.tsx:
--------------------------------------------------------------------------------
1 | import { Monitor, Smartphone, XIcon } from "lucide-react";
2 | import { Suspense, useState } from "react";
3 | import { Await, useFetcher } from "react-router";
4 | import { Skeleton } from "~/components/ui/skeleton";
5 | import { useMediaQuery } from "~/hooks/use-media-query";
6 | import type { ProcessedSession } from "~/routes/account";
7 | import {
8 | AlertDialog,
9 | AlertDialogAction,
10 | AlertDialogCancel,
11 | AlertDialogContent,
12 | AlertDialogDescription,
13 | AlertDialogFooter,
14 | AlertDialogHeader,
15 | AlertDialogTitle,
16 | AlertDialogTrigger,
17 | } from "../ui/alert-dialog";
18 | import { Badge } from "../ui/badge";
19 | import { Button } from "../ui/button";
20 | import {
21 | Drawer,
22 | DrawerClose,
23 | DrawerContent,
24 | DrawerDescription,
25 | DrawerFooter,
26 | DrawerHeader,
27 | DrawerTitle,
28 | DrawerTrigger,
29 | } from "../ui/drawer";
30 | import { StatusButton } from "../ui/status-button";
31 |
32 | const MODAL_TITLE = "Are you sure?";
33 | const MODAL_DESCRIPTION = "Clicking continue will sign you out of this device.";
34 |
35 | export function SessionManage({
36 | sessionsPromise,
37 | }: { sessionsPromise: Promise }) {
38 | return (
39 |
40 |
41 | Active sessions
42 |
43 | If necessary, you can sign out of all other browser sessions. Some of
44 | your recent sessions are listed below, but this list may not be
45 | complete.
46 |
47 |
48 |
}>
49 |
Error loading sessions. }
52 | >
53 | {(sessions: ProcessedSession[]) => (
54 |
55 | {sessions.map((session) => (
56 |
57 | ))}
58 |
59 | )}
60 |
61 |
62 |
63 | );
64 | }
65 |
66 | export function SessionItem({
67 | session,
68 | }: {
69 | session: ProcessedSession;
70 | }) {
71 | const [open, setOpen] = useState(false);
72 | const fetcher = useFetcher();
73 | const isPending = fetcher.state !== "idle";
74 | const isDesktop = useMediaQuery("(min-width: 768px)");
75 |
76 | const handleSignOut = () => {
77 | fetcher.submit(
78 | {
79 | intent: "signOutSession",
80 | sessionId: session.id,
81 | },
82 | {
83 | method: "POST",
84 | action: "/account",
85 | preventScrollReset: true,
86 | },
87 | );
88 | setOpen(false);
89 | };
90 |
91 | const logoutButton = (
92 | }
95 | variant="ghost"
96 | size="icon"
97 | className="size-6 [&_svg]:text-muted-foreground/60"
98 | />
99 | );
100 |
101 | return (
102 |
103 |
104 |
105 | {session.isMobile ? (
106 |
107 | ) : (
108 |
109 | )}
110 |
111 |
112 |
{session.userAgent}
113 |
114 |
115 | Ip address: {session.ipAddress}{" "}
116 | {session.country !== "Unknown" && `(${session.country})`}
117 |
118 | Last active: {session.createdAt}
119 |
120 |
121 |
122 |
123 | {!session.isCurrent ? (
124 | isDesktop ? (
125 |
126 | {logoutButton}
127 |
128 |
129 | {MODAL_TITLE}
130 |
131 | {MODAL_DESCRIPTION}
132 |
133 |
134 |
135 | Cancel
136 |
137 | Continue
138 |
139 |
140 |
141 |
142 | ) : (
143 |
144 | {logoutButton}
145 |
146 |
147 | {MODAL_TITLE}
148 | {MODAL_DESCRIPTION}
149 |
150 |
151 | Continue
152 |
153 | Cancel
154 |
155 |
156 |
157 |
158 | )
159 | ) : (
160 |
164 |
168 | This device
169 |
170 | )}
171 |
172 |
173 | );
174 | }
175 |
176 | function SessionManageSkeleton() {
177 | return (
178 |
179 |
180 |
181 |
182 |
183 |
184 | );
185 | }
186 |
--------------------------------------------------------------------------------
/app/components/account/user-profile.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
2 | import { useUser } from "~/hooks/use-user";
3 |
4 | export function UserProfile() {
5 | const user = useUser();
6 | return (
7 |
8 |
Profile
9 |
10 |
11 |
12 |
20 |
21 | {user.displayName?.slice(0, 2)}
22 |
23 |
24 |
25 |
26 |
27 | {user.displayName} ({user.email})
28 |
29 |
30 |
31 | Joined {user.createdAt.toLocaleDateString()}
32 |
33 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/app/components/color-scheme-toggle.tsx:
--------------------------------------------------------------------------------
1 | import { LaptopIcon, MoonIcon, SunIcon } from "lucide-react";
2 | import {
3 | DropdownMenu,
4 | DropdownMenuContent,
5 | DropdownMenuItem,
6 | DropdownMenuTrigger,
7 | } from "~/components/ui/dropdown-menu";
8 | import {
9 | ColorSchemeSchema,
10 | useColorScheme,
11 | useSetColorScheme,
12 | } from "~/lib/color-scheme/components";
13 | import { Button } from "./ui/button";
14 |
15 | const THEME_ICONS = {
16 | light: ,
17 | dark: ,
18 | system: ,
19 | } as const;
20 |
21 | export function ColorSchemeToggle() {
22 | const setColorScheme = useSetColorScheme();
23 | const colorScheme = useColorScheme();
24 |
25 | const getIcon = () => {
26 | switch (colorScheme) {
27 | case "dark":
28 | return ;
29 | case "light":
30 | return ;
31 | default:
32 | return ;
33 | }
34 | };
35 |
36 | return (
37 |
38 |
39 |
40 | {getIcon()}
41 |
42 |
43 |
44 | {ColorSchemeSchema.shape.colorScheme.options.map((value) => (
45 | setColorScheme(value)}
48 | aria-selected={colorScheme === value}
49 | className="capitalize"
50 | >
51 | {THEME_ICONS[value]}
52 | {value}
53 |
54 | ))}
55 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/app/components/error-boundary.tsx:
--------------------------------------------------------------------------------
1 | import { MehIcon } from "lucide-react";
2 | import { isRouteErrorResponse, useRouteError } from "react-router";
3 | import { buttonVariants } from "./ui/button";
4 |
5 | type ErrorDisplayProps = {
6 | message: string;
7 | details: string;
8 | stack?: string;
9 | };
10 |
11 | const ERROR_STATUS_MAP: Record<
12 | number,
13 | { message: string; defaultDetails: string }
14 | > = {
15 | 400: {
16 | message: "400 Bad Request",
17 | defaultDetails: "The request was invalid.",
18 | },
19 | 401: {
20 | message: "401 Unauthorized Access",
21 | defaultDetails:
22 | "Please log in with the appropriate credentials to access this resource.",
23 | },
24 | 403: {
25 | message: "403 Access Forbidden",
26 | defaultDetails:
27 | "You don't have necessary permission to view this resource.",
28 | },
29 | 500: {
30 | message: "Oops! Something went wrong :')",
31 | defaultDetails:
32 | "We apologize for the inconvenience. Please try again later.",
33 | },
34 | 503: {
35 | message: "503 Website is under maintenance!",
36 | defaultDetails:
37 | "The site is not available at the moment. We'll be back online shortly.",
38 | },
39 | };
40 |
41 | function DevErrorDisplay({ message, details, stack }: ErrorDisplayProps) {
42 | return (
43 |
44 |
45 |
{message}
46 |
{details}
47 |
48 |
49 | {stack}
50 |
51 |
52 | );
53 | }
54 |
55 | export function ProductionErrorDisplay({
56 | message,
57 | details,
58 | }: ErrorDisplayProps) {
59 | return (
60 |
61 |
75 |
76 | );
77 | }
78 |
79 | export function GeneralErrorBoundary() {
80 | const error = useRouteError();
81 |
82 | const defaultMessage = "Oops! App Crashed 💥";
83 | const defaultDetails = "Please reload the page. or try again later.";
84 |
85 | // Handle route errors, Example: 404, 500, 503
86 | if (isRouteErrorResponse(error)) {
87 | const errorConfig = ERROR_STATUS_MAP[error.status];
88 | const message = errorConfig?.message ?? defaultMessage;
89 | const details =
90 | error.statusText || errorConfig?.defaultDetails || defaultDetails;
91 | return ;
92 | }
93 |
94 | // Handle development errors
95 | if (import.meta.env.DEV && error && error instanceof Error) {
96 | console.log("🔴 error on dev", error);
97 | return (
98 |
103 | );
104 | }
105 |
106 | // Handle other errors
107 | return (
108 |
109 | );
110 | }
111 |
--------------------------------------------------------------------------------
/app/components/icons.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "~/lib/utils";
2 |
3 | interface IconProps {
4 | className?: string;
5 | theme?: "light" | "dark";
6 | }
7 |
8 | export function ReactRouterIcon({ className, theme }: IconProps) {
9 | const color = theme === "light" ? "#121212" : "#FFFFFF";
10 |
11 | return (
12 |
20 | React Router
21 |
25 |
29 |
33 |
37 |
38 | );
39 | }
40 |
41 | export function GoogleIcon({ className }: IconProps) {
42 | return (
43 |
51 | Google
52 |
56 |
60 |
64 |
68 |
69 | );
70 | }
71 |
72 | export function GithubIcon({ className }: IconProps) {
73 | return (
74 |
81 | GitHub
82 |
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/app/components/progress-bar.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 | import { useNavigation } from "react-router";
3 | import { useSpinDelay } from "spin-delay";
4 | import { cn } from "~/lib/utils";
5 | import { Spinner } from "./ui/spinner";
6 |
7 | interface ProgressBarProps {
8 | showSpinner?: boolean;
9 | }
10 |
11 | function ProgressBar({ showSpinner = false }: ProgressBarProps) {
12 | const transition = useNavigation();
13 | const busy = transition.state !== "idle";
14 | const delayedPending = useSpinDelay(busy, {
15 | delay: 600,
16 | minDuration: 400,
17 | });
18 | const ref = useRef(null);
19 | const [animationComplete, setAnimationComplete] = useState(true);
20 |
21 | useEffect(() => {
22 | if (!ref.current) return;
23 | if (delayedPending) setAnimationComplete(false);
24 |
25 | const animationPromises = ref.current
26 | .getAnimations()
27 | .map(({ finished }) => finished);
28 |
29 | Promise.allSettled(animationPromises).then(() => {
30 | if (!delayedPending) setAnimationComplete(true);
31 | });
32 | }, [delayedPending]);
33 |
34 | return (
35 |
40 |
54 | {delayedPending && showSpinner && (
55 |
56 |
57 |
58 | )}
59 |
60 | );
61 | }
62 |
63 | export { ProgressBar };
64 |
--------------------------------------------------------------------------------
/app/components/todos/delete-todo.tsx:
--------------------------------------------------------------------------------
1 | import { CheckIcon, XIcon } from "lucide-react";
2 | import { useFetcher } from "react-router";
3 | import { useDoubleCheck } from "~/hooks/use-double-check";
4 | import { cn } from "~/lib/utils";
5 | import { Button } from "../ui/button";
6 | import { Spinner } from "../ui/spinner";
7 |
8 | export function DeleteTodo({ todoId }: { todoId: string }) {
9 | const { doubleCheck, getButtonProps } = useDoubleCheck();
10 | const fetcher = useFetcher();
11 | const isDeleting = fetcher.state !== "idle";
12 |
13 | return (
14 |
15 |
16 |
29 | {doubleCheck ? isDeleting ? : : }
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/app/components/todos/toggle-todo.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useFetcher } from "react-router";
3 | import { cn } from "~/lib/utils";
4 | import type { loader } from "~/routes/todos";
5 | import { Checkbox } from "../ui/checkbox";
6 | import { Label } from "../ui/label";
7 |
8 | export function ToggleTodo({
9 | todo,
10 | }: {
11 | todo: Awaited>["data"]["todos"][number];
12 | }) {
13 | const [checked, setChecked] = useState(todo.completed === 1);
14 | const fetcher = useFetcher();
15 |
16 | return (
17 |
18 | {
23 | setChecked((prevIsChecked) => prevIsChecked !== true);
24 | const formData = new FormData();
25 | formData.append("todoId", todo.id.toString());
26 | formData.append("intent", "complete");
27 | fetcher.submit(formData, {
28 | method: "POST",
29 | });
30 | }}
31 | />
32 |
37 | {todo.title}
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/app/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
2 | import type * as React from "react";
3 |
4 | import { buttonVariants } from "~/components/ui/button";
5 | import { cn } from "~/lib/utils";
6 |
7 | function AlertDialog({
8 | ...props
9 | }: React.ComponentProps) {
10 | return ;
11 | }
12 |
13 | function AlertDialogTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return (
17 |
18 | );
19 | }
20 |
21 | function AlertDialogPortal({
22 | ...props
23 | }: React.ComponentProps) {
24 | return (
25 |
26 | );
27 | }
28 |
29 | function AlertDialogOverlay({
30 | className,
31 | ...props
32 | }: React.ComponentProps) {
33 | return (
34 |
42 | );
43 | }
44 |
45 | function AlertDialogContent({
46 | className,
47 | ...props
48 | }: React.ComponentProps) {
49 | return (
50 |
51 |
52 |
60 |
61 | );
62 | }
63 |
64 | function AlertDialogHeader({
65 | className,
66 | ...props
67 | }: React.ComponentProps<"div">) {
68 | return (
69 |
74 | );
75 | }
76 |
77 | function AlertDialogFooter({
78 | className,
79 | ...props
80 | }: React.ComponentProps<"div">) {
81 | return (
82 |
90 | );
91 | }
92 |
93 | function AlertDialogTitle({
94 | className,
95 | ...props
96 | }: React.ComponentProps) {
97 | return (
98 |
103 | );
104 | }
105 |
106 | function AlertDialogDescription({
107 | className,
108 | ...props
109 | }: React.ComponentProps) {
110 | return (
111 |
116 | );
117 | }
118 |
119 | function AlertDialogAction({
120 | className,
121 | ...props
122 | }: React.ComponentProps) {
123 | return (
124 |
128 | );
129 | }
130 |
131 | function AlertDialogCancel({
132 | className,
133 | ...props
134 | }: React.ComponentProps) {
135 | return (
136 |
140 | );
141 | }
142 |
143 | export {
144 | AlertDialog,
145 | AlertDialogPortal,
146 | AlertDialogOverlay,
147 | AlertDialogTrigger,
148 | AlertDialogContent,
149 | AlertDialogHeader,
150 | AlertDialogFooter,
151 | AlertDialogTitle,
152 | AlertDialogDescription,
153 | AlertDialogAction,
154 | AlertDialogCancel,
155 | };
156 |
--------------------------------------------------------------------------------
/app/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as AvatarPrimitive from "@radix-ui/react-avatar";
2 | import type * as React from "react";
3 |
4 | import { cn } from "~/lib/utils";
5 |
6 | function Avatar({
7 | className,
8 | ...props
9 | }: React.ComponentProps) {
10 | return (
11 |
19 | );
20 | }
21 |
22 | function AvatarImage({
23 | className,
24 | ...props
25 | }: React.ComponentProps) {
26 | return (
27 |
32 | );
33 | }
34 |
35 | function AvatarFallback({
36 | className,
37 | ...props
38 | }: React.ComponentProps) {
39 | return (
40 |
48 | );
49 | }
50 |
51 | export { Avatar, AvatarImage, AvatarFallback };
52 |
--------------------------------------------------------------------------------
/app/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from "@radix-ui/react-slot";
2 | import { type VariantProps, cva } from "class-variance-authority";
3 | import type * as React from "react";
4 |
5 | import { cn } from "~/lib/utils";
6 |
7 | const badgeVariants = cva(
8 | "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-auto whitespace-nowrap rounded-md border px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
14 | secondary:
15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
16 | destructive:
17 | "border-transparent bg-destructive text-white focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
18 | outline:
19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
20 | },
21 | },
22 | defaultVariants: {
23 | variant: "default",
24 | },
25 | },
26 | );
27 |
28 | function Badge({
29 | className,
30 | variant,
31 | asChild = false,
32 | ...props
33 | }: React.ComponentProps<"span"> &
34 | VariantProps & { asChild?: boolean }) {
35 | const Comp = asChild ? Slot : "span";
36 |
37 | return (
38 |
43 | );
44 | }
45 |
46 | export { Badge, badgeVariants };
47 |
--------------------------------------------------------------------------------
/app/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from "@radix-ui/react-slot";
2 | import { type VariantProps, cva } from "class-variance-authority";
3 | import type * as React from "react";
4 |
5 | import { cn } from "~/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
16 | outline:
17 | "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
25 | sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5",
26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
27 | icon: "size-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | },
35 | );
36 |
37 | function Button({
38 | className,
39 | variant,
40 | size,
41 | asChild = false,
42 | ...props
43 | }: React.ComponentProps<"button"> &
44 | VariantProps & {
45 | asChild?: boolean;
46 | }) {
47 | const Comp = asChild ? Slot : "button";
48 |
49 | return (
50 |
55 | );
56 | }
57 |
58 | export { Button, buttonVariants };
59 |
--------------------------------------------------------------------------------
/app/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
2 | import { CheckIcon } from "lucide-react";
3 | import type * as React from "react";
4 |
5 | import { cn } from "~/lib/utils";
6 |
7 | function Checkbox({
8 | className,
9 | ...props
10 | }: React.ComponentProps) {
11 | return (
12 |
20 |
24 |
25 |
26 |
27 | );
28 | }
29 |
30 | export { Checkbox };
31 |
--------------------------------------------------------------------------------
/app/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as DialogPrimitive from "@radix-ui/react-dialog";
2 | import { XIcon } from "lucide-react";
3 | import type * as React from "react";
4 |
5 | import { cn } from "~/lib/utils";
6 |
7 | function Dialog({
8 | ...props
9 | }: React.ComponentProps) {
10 | return ;
11 | }
12 |
13 | function DialogTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return ;
17 | }
18 |
19 | function DialogPortal({
20 | ...props
21 | }: React.ComponentProps) {
22 | return ;
23 | }
24 |
25 | function DialogClose({
26 | ...props
27 | }: React.ComponentProps) {
28 | return ;
29 | }
30 |
31 | function DialogOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | );
45 | }
46 |
47 | function DialogContent({
48 | className,
49 | children,
50 | ...props
51 | }: React.ComponentProps) {
52 | return (
53 |
54 |
55 |
63 | {children}
64 |
65 |
66 | Close
67 |
68 |
69 |
70 | );
71 | }
72 |
73 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
74 | return (
75 |
80 | );
81 | }
82 |
83 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
84 | return (
85 |
93 | );
94 | }
95 |
96 | function DialogTitle({
97 | className,
98 | ...props
99 | }: React.ComponentProps) {
100 | return (
101 |
106 | );
107 | }
108 |
109 | function DialogDescription({
110 | className,
111 | ...props
112 | }: React.ComponentProps) {
113 | return (
114 |
119 | );
120 | }
121 |
122 | export {
123 | Dialog,
124 | DialogClose,
125 | DialogContent,
126 | DialogDescription,
127 | DialogFooter,
128 | DialogHeader,
129 | DialogOverlay,
130 | DialogPortal,
131 | DialogTitle,
132 | DialogTrigger,
133 | };
134 |
--------------------------------------------------------------------------------
/app/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | import type * as React from "react";
2 | import { Drawer as DrawerPrimitive } from "vaul";
3 |
4 | import { cn } from "~/lib/utils";
5 |
6 | function Drawer({
7 | ...props
8 | }: React.ComponentProps) {
9 | return ;
10 | }
11 |
12 | function DrawerTrigger({
13 | ...props
14 | }: React.ComponentProps) {
15 | return ;
16 | }
17 |
18 | function DrawerPortal({
19 | ...props
20 | }: React.ComponentProps) {
21 | return ;
22 | }
23 |
24 | function DrawerClose({
25 | ...props
26 | }: React.ComponentProps) {
27 | return ;
28 | }
29 |
30 | function DrawerOverlay({
31 | className,
32 | ...props
33 | }: React.ComponentProps) {
34 | return (
35 |
43 | );
44 | }
45 |
46 | function DrawerContent({
47 | className,
48 | children,
49 | ...props
50 | }: React.ComponentProps) {
51 | return (
52 |
53 |
54 |
66 |
67 | {children}
68 |
69 |
70 | );
71 | }
72 |
73 | function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
74 | return (
75 |
80 | );
81 | }
82 |
83 | function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
84 | return (
85 |
90 | );
91 | }
92 |
93 | function DrawerTitle({
94 | className,
95 | ...props
96 | }: React.ComponentProps) {
97 | return (
98 |
103 | );
104 | }
105 |
106 | function DrawerDescription({
107 | className,
108 | ...props
109 | }: React.ComponentProps) {
110 | return (
111 |
116 | );
117 | }
118 |
119 | export {
120 | Drawer,
121 | DrawerPortal,
122 | DrawerOverlay,
123 | DrawerTrigger,
124 | DrawerClose,
125 | DrawerContent,
126 | DrawerHeader,
127 | DrawerFooter,
128 | DrawerTitle,
129 | DrawerDescription,
130 | };
131 |
--------------------------------------------------------------------------------
/app/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import type * as React from "react";
2 |
3 | import { cn } from "~/lib/utils";
4 |
5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6 | return (
7 |
18 | );
19 | }
20 |
21 | export { Input };
22 |
--------------------------------------------------------------------------------
/app/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as LabelPrimitive from "@radix-ui/react-label";
2 | import type * as React from "react";
3 |
4 | import { cn } from "~/lib/utils";
5 |
6 | function Label({
7 | className,
8 | ...props
9 | }: React.ComponentProps) {
10 | return (
11 |
19 | );
20 | }
21 |
22 | export { Label };
23 |
--------------------------------------------------------------------------------
/app/components/ui/radio-group.tsx:
--------------------------------------------------------------------------------
1 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
2 | import { CircleIcon } from "lucide-react";
3 | import type * as React from "react";
4 |
5 | import { cn } from "~/lib/utils";
6 |
7 | function RadioGroup({
8 | className,
9 | ...props
10 | }: React.ComponentProps) {
11 | return (
12 |
17 | );
18 | }
19 |
20 | function RadioGroupItem({
21 | className,
22 | ...props
23 | }: React.ComponentProps) {
24 | return (
25 |
33 |
37 |
38 |
39 |
40 | );
41 | }
42 |
43 | export { RadioGroup, RadioGroupItem };
44 |
--------------------------------------------------------------------------------
/app/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "~/lib/utils";
2 |
3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4 | return (
5 |
10 | );
11 | }
12 |
13 | export { Skeleton };
14 |
--------------------------------------------------------------------------------
/app/components/ui/spinner.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "~/lib/utils";
2 |
3 | const Spinner = ({ className }: { className?: string }) => {
4 | return (
5 |
11 | Loading...
12 |
21 |
26 |
27 | );
28 | };
29 |
30 | export { Spinner };
31 |
--------------------------------------------------------------------------------
/app/components/ui/status-button.tsx:
--------------------------------------------------------------------------------
1 | import type { VariantProps } from "class-variance-authority";
2 | import React, { type ComponentProps } from "react";
3 | import { Button, type buttonVariants } from "./button";
4 | import { Spinner } from "./spinner";
5 |
6 | type ButtonProps = ComponentProps<"button"> &
7 | VariantProps & {
8 | asChild?: boolean;
9 | formProps?: {
10 | name?: string;
11 | value?: string;
12 | };
13 | };
14 |
15 | interface StatusButtonProps extends ButtonProps {
16 | isLoading: boolean;
17 | text?: string;
18 | loadingText?: string;
19 | icon?: React.ReactNode;
20 | }
21 |
22 | const StatusButton = React.forwardRef(
23 | (
24 | { isLoading = false, text, loadingText, icon, formProps, ...props },
25 | ref,
26 | ) => {
27 | return (
28 |
29 | {isLoading ? : icon}
30 | {isLoading ? (loadingText ?? text) : text}
31 |
32 | );
33 | },
34 | );
35 |
36 | StatusButton.displayName = "StatusButton";
37 |
38 | export { StatusButton };
39 |
--------------------------------------------------------------------------------
/app/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as SwitchPrimitive from "@radix-ui/react-switch";
2 | import type * as React from "react";
3 |
4 | import { cn } from "~/lib/utils";
5 |
6 | function Switch({
7 | className,
8 | ...props
9 | }: React.ComponentProps) {
10 | return (
11 |
19 |
25 |
26 | );
27 | }
28 |
29 | export { Switch };
30 |
--------------------------------------------------------------------------------
/app/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import type * as React from "react";
2 |
3 | import { cn } from "~/lib/utils";
4 |
5 | function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6 | return (
7 |
15 | );
16 | }
17 |
18 | export { Textarea };
19 |
--------------------------------------------------------------------------------
/app/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as TooltipPrimitive from "@radix-ui/react-tooltip";
2 | import type * as React from "react";
3 |
4 | import { cn } from "~/lib/utils";
5 |
6 | function TooltipProvider({
7 | delayDuration = 0,
8 | ...props
9 | }: React.ComponentProps) {
10 | return (
11 |
16 | );
17 | }
18 |
19 | function Tooltip({
20 | ...props
21 | }: React.ComponentProps) {
22 | return (
23 |
24 |
25 |
26 | );
27 | }
28 |
29 | function TooltipTrigger({
30 | ...props
31 | }: React.ComponentProps) {
32 | return ;
33 | }
34 |
35 | function TooltipContent({
36 | className,
37 | sideOffset = 4,
38 | children,
39 | ...props
40 | }: React.ComponentProps) {
41 | return (
42 |
43 |
52 | {children}
53 |
54 |
55 |
56 | );
57 | }
58 |
59 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
60 |
--------------------------------------------------------------------------------
/app/components/user-nav.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | LogOut,
3 | LucideSquareArrowOutUpRight,
4 | SettingsIcon,
5 | } from "lucide-react";
6 | import { useNavigate, useSubmit } from "react-router";
7 | import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
8 | import {
9 | DropdownMenu,
10 | DropdownMenuContent,
11 | DropdownMenuGroup,
12 | DropdownMenuItem,
13 | DropdownMenuLabel,
14 | DropdownMenuSeparator,
15 | DropdownMenuTrigger,
16 | } from "~/components/ui/dropdown-menu";
17 | import { useUser } from "~/hooks/use-user";
18 | import { Button } from "./ui/button";
19 |
20 | export function UserNav() {
21 | const user = useUser();
22 | const submit = useSubmit();
23 | const navigate = useNavigate();
24 |
25 | return (
26 |
27 |
28 |
29 |
30 |
38 |
39 | {user?.displayName?.slice(0, 2)}
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | {user?.displayName ?? user?.email}
48 |
49 |
50 | {user?.email}
51 |
52 |
53 |
54 |
55 |
56 | navigate("/")}>
57 |
58 | Home page
59 |
60 | navigate("/account")}>
61 |
62 | Account
63 |
64 |
65 |
66 |
67 | {
69 | setTimeout(() => {
70 | submit(null, { method: "POST", action: "/auth/logout" });
71 | }, 100);
72 | }}
73 | >
74 |
75 | Logout
76 |
77 |
78 |
79 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import { isbot } from "isbot";
2 | import { renderToReadableStream } from "react-dom/server";
3 | import type {
4 | AppLoadContext,
5 | EntryContext,
6 | HandleErrorFunction,
7 | } from "react-router";
8 | import { ServerRouter } from "react-router";
9 | import { NonceProvider } from "./hooks/use-nonce";
10 |
11 | export default async function handleRequest(
12 | request: Request,
13 | responseStatusCode: number,
14 | responseHeaders: Headers,
15 | routerContext: EntryContext,
16 | _loadContext: AppLoadContext,
17 | ) {
18 | let shellRendered = false;
19 | const userAgent = request.headers.get("user-agent");
20 |
21 | // Set a random nonce for CSP.
22 | const nonce = crypto.randomUUID() ?? undefined;
23 |
24 | // Set CSP headers to prevent 'Prop nonce did not match' error
25 | // Without this, browser security policy will clear the nonce attribute on the client side
26 | responseHeaders.set(
27 | "Content-Security-Policy",
28 | `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none';`,
29 | );
30 |
31 | const body = await renderToReadableStream(
32 |
33 |
34 | ,
35 | {
36 | onError(error: unknown) {
37 | responseStatusCode = 500;
38 | // Log streaming rendering errors from inside the shell. Don't log
39 | // errors encountered during initial shell rendering since they'll
40 | // reject and get logged in handleDocumentRequest.
41 | if (shellRendered) {
42 | console.error(error);
43 | }
44 | },
45 | signal: request.signal,
46 | nonce,
47 | },
48 | );
49 | shellRendered = true;
50 |
51 | // Ensure requests from bots and SPA Mode renders wait for all content to load before responding
52 | // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
53 | if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) {
54 | await body.allReady;
55 | }
56 |
57 | responseHeaders.set("Content-Type", "text/html");
58 | return new Response(body, {
59 | headers: responseHeaders,
60 | status: responseStatusCode,
61 | });
62 | }
63 |
64 | // Error Reporting
65 | // https://reactrouter.com/how-to/error-reporting
66 | export const handleError: HandleErrorFunction = (error, { request }) => {
67 | if (request.signal.aborted) {
68 | return;
69 | }
70 |
71 | if (error instanceof Error) {
72 | console.error(error.stack);
73 | } else {
74 | console.error(error);
75 | }
76 | };
77 |
--------------------------------------------------------------------------------
/app/hooks/use-double-check.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 | import { callAll } from "~/lib/utils";
3 |
4 | export function useDoubleCheck() {
5 | const [doubleCheck, setDoubleCheck] = useState(false);
6 | const buttonRef = useRef(null);
7 |
8 | useEffect(() => {
9 | if (doubleCheck && buttonRef.current) {
10 | const handleClickOutside = (event: MouseEvent) => {
11 | if (
12 | buttonRef.current &&
13 | !buttonRef.current.contains(event.target as Node)
14 | ) {
15 | setDoubleCheck(false);
16 | }
17 | };
18 |
19 | document.addEventListener("mousedown", handleClickOutside);
20 | return () => {
21 | document.removeEventListener("mousedown", handleClickOutside);
22 | };
23 | }
24 | }, [doubleCheck]);
25 |
26 | function getButtonProps(
27 | props?: React.ButtonHTMLAttributes,
28 | ) {
29 | const onClick: React.ButtonHTMLAttributes["onClick"] =
30 | doubleCheck
31 | ? undefined
32 | : (e) => {
33 | e.preventDefault();
34 | setDoubleCheck(true);
35 | };
36 |
37 | const onKeyUp: React.ButtonHTMLAttributes["onKeyUp"] = (
38 | e,
39 | ) => {
40 | if (e.key === "Escape") {
41 | setDoubleCheck(false);
42 | }
43 | };
44 |
45 | return {
46 | ...props,
47 | onClick: callAll(onClick, props?.onClick),
48 | onKeyUp: callAll(onKeyUp, props?.onKeyUp),
49 | ref: buttonRef,
50 | };
51 | }
52 |
53 | return { doubleCheck, setDoubleCheck, getButtonProps };
54 | }
55 |
--------------------------------------------------------------------------------
/app/hooks/use-is-pending.ts:
--------------------------------------------------------------------------------
1 | import { useFormAction, useNavigation } from "react-router";
2 |
3 | export function useIsPending({
4 | formAction,
5 | formMethod = "POST",
6 | state = "non-idle",
7 | }: {
8 | formAction?: string;
9 | formMethod?: "POST" | "GET" | "PUT" | "PATCH" | "DELETE";
10 | state?: "submitting" | "loading" | "non-idle";
11 | } = {}) {
12 | const contextualFormAction = useFormAction();
13 | const navigation = useNavigation();
14 | const isPendingState =
15 | state === "non-idle"
16 | ? navigation.state !== "idle"
17 | : navigation.state === state;
18 | return (
19 | isPendingState &&
20 | navigation.formAction === (formAction ?? contextualFormAction) &&
21 | navigation.formMethod === formMethod
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/app/hooks/use-media-query.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export function useMediaQuery(query: string) {
4 | const [value, setValue] = React.useState(false);
5 |
6 | React.useEffect(() => {
7 | function onChange(event: MediaQueryListEvent) {
8 | setValue(event.matches);
9 | }
10 |
11 | const result = matchMedia(query);
12 | result.addEventListener("change", onChange);
13 | setValue(result.matches);
14 |
15 | return () => result.removeEventListener("change", onChange);
16 | }, [query]);
17 |
18 | return value;
19 | }
20 |
--------------------------------------------------------------------------------
/app/hooks/use-nonce.ts:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from "react";
2 |
3 | export const NonceContext = createContext("");
4 |
5 | export const NonceProvider = NonceContext.Provider;
6 |
7 | export const useNonce = () => useContext(NonceContext);
8 |
--------------------------------------------------------------------------------
/app/hooks/use-toast.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { toast as showToast } from "sonner";
3 | import type { Toast } from "~/lib/toast.server";
4 |
5 | export function useToast(toast?: Toast | null) {
6 | useEffect(() => {
7 | if (toast) {
8 | setTimeout(() => {
9 | showToast[toast.type](toast.title, {
10 | id: toast.id,
11 | description: toast.description,
12 | });
13 | }, 0);
14 | }
15 | }, [toast]);
16 | }
17 |
--------------------------------------------------------------------------------
/app/hooks/use-user.ts:
--------------------------------------------------------------------------------
1 | import { useRouteLoaderData } from "react-router";
2 | import type { loader as rootLoader } from "~/routes/layout";
3 |
4 | type UserType = Awaited>["data"]["user"];
5 |
6 | function isUser(user: UserType) {
7 | return user && typeof user === "object" && typeof user.id === "string";
8 | }
9 |
10 | export function useOptionalUser() {
11 | const data = useRouteLoaderData("routes/layout");
12 | if (!data || !isUser(data.user)) return undefined;
13 | return data.user;
14 | }
15 |
16 | export function useUser() {
17 | const optionalUser = useOptionalUser();
18 | if (!optionalUser) throw new Error("No user found.");
19 | return optionalUser;
20 | }
21 |
--------------------------------------------------------------------------------
/app/lib/auth/honeypot.server.ts:
--------------------------------------------------------------------------------
1 | import { Honeypot, SpamError } from "remix-utils/honeypot/server";
2 |
3 | export async function checkHoneypot(env: Env, formData: FormData) {
4 | const honeypot = new Honeypot({
5 | encryptionSeed: env.HONEYPOT_SECRET,
6 | validFromFieldName: "from__confirm",
7 | });
8 |
9 | const timestampFieldName = "from__confirm";
10 |
11 | if (!formData.has(timestampFieldName)) {
12 | console.warn(
13 | `Honeypot manual check failed: Missing timestamp field '${timestampFieldName}'`,
14 | );
15 | throw new Response("Form not submitted properly", { status: 400 });
16 | }
17 |
18 | try {
19 | await honeypot.check(formData);
20 | } catch (error) {
21 | if (error instanceof SpamError) {
22 | throw new Response("Form not submitted properly", { status: 400 });
23 | }
24 | throw error;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/app/lib/auth/session.server.ts:
--------------------------------------------------------------------------------
1 | import { env } from "cloudflare:workers";
2 | import { type Session, type SessionData, redirect } from "react-router";
3 | import { db } from "../db/drizzle.server";
4 | import { logger } from "../logger";
5 | import { redirectWithToast } from "../toast.server";
6 | import { getErrorMessage } from "../utils";
7 | import { SessionManager } from "../workers/session-manager.server";
8 | import { type AuthUserSession, auth } from "./auth.server";
9 |
10 | export const AUTH_USER_KEY = "auth-user";
11 | export const AUTH_SUCCESS_REDIRECT_TO = "/home";
12 | export const AUTH_ERROR_REDIRECT_TO = "/auth/login";
13 |
14 | /**
15 | * Authenticate and redirect to home page
16 | *
17 | * @param request - The request object
18 | * @param provider - The authentication provider
19 | * @param redirectTo - The redirect URL
20 | * @returns The redirect response
21 | */
22 | export async function handleAuthSuccess(
23 | provider: string,
24 | request: Request,
25 | redirectTo = AUTH_SUCCESS_REDIRECT_TO,
26 | ) {
27 | const user = await auth.authenticate(provider, request);
28 | const session = await auth.getSession(request.headers.get("Cookie"));
29 | session.unset("auth:email");
30 | session.set(AUTH_USER_KEY, user);
31 |
32 | return redirect(redirectTo, {
33 | headers: { "Set-Cookie": await auth.commitSession(session) },
34 | });
35 | }
36 |
37 | /**
38 | * Handle authentication errors
39 | *
40 | * @param error - The error object
41 | * @param provider - The authentication provider
42 | * @param redirectTo - The redirect URL
43 | * @returns The redirect response
44 | */
45 | export async function handleAuthError(
46 | provider: string,
47 | error: unknown,
48 | redirectTo = AUTH_ERROR_REDIRECT_TO,
49 | ) {
50 | if (error instanceof Response) throw error;
51 | const message = getErrorMessage(error);
52 | logger.error({ event: "auth_login_error", provider, message });
53 |
54 | throw await redirectWithToast(redirectTo, {
55 | title: message,
56 | type: "error",
57 | });
58 | }
59 |
60 | /**
61 | * Validate session and get user data
62 | *
63 | * @param session - The session
64 | * @param sessionUser - The session user
65 | * @returns The user data
66 | */
67 | export async function validateSession(
68 | session: Session,
69 | sessionUser: AuthUserSession | null,
70 | ) {
71 | if (!sessionUser?.userId || !sessionUser?.sessionId) {
72 | return null;
73 | }
74 |
75 | const sessionManager = new SessionManager(env.APP_KV);
76 | const [user, sessionData] = await Promise.all([
77 | db.query.usersTable.findFirst({
78 | where: (users, { eq }) => eq(users.id, sessionUser.userId),
79 | columns: {
80 | id: true,
81 | email: true,
82 | displayName: true,
83 | avatarUrl: true,
84 | status: true,
85 | createdAt: true,
86 | },
87 | }),
88 | sessionManager.getSession(sessionUser.userId, sessionUser.sessionId),
89 | ]);
90 |
91 | // If the user is not active or the session data does not exist
92 | // destroy the session and redirect to the home page
93 | if (user?.status !== "active" || !sessionData) {
94 | throw redirect("/", {
95 | headers: { "Set-Cookie": await auth.destroySession(session) },
96 | });
97 | }
98 |
99 | return {
100 | session: {
101 | id: sessionData.sessionId,
102 | },
103 | user,
104 | };
105 | }
106 |
107 | /**
108 | * Get session data from cookie
109 | *
110 | * @param request - The request object
111 | * @returns The session data
112 | */
113 | export async function getSessionFromCookie(request: Request) {
114 | const session = await auth.getSession(request.headers.get("Cookie"));
115 | const sessionUser = session.get(AUTH_USER_KEY);
116 | return { session, sessionUser: sessionUser ?? null };
117 | }
118 |
119 | /**
120 | * Query current session and validate its status
121 | *
122 | * @param request - The request object
123 | * @returns The session data
124 | */
125 | export async function querySession(request: Request) {
126 | const { session, sessionUser } = await getSessionFromCookie(request);
127 | const validSession = await validateSession(session, sessionUser);
128 | return { session, validSession };
129 | }
130 |
131 | /**
132 | * Logout user
133 | *
134 | * @param request - The request object
135 | * @param kv - The KV namespace
136 | * @returns The redirect response
137 | */
138 | export async function logout(request: Request, kv: KVNamespace) {
139 | const { session, sessionUser } = await getSessionFromCookie(request);
140 |
141 | if (sessionUser) {
142 | const sessionManager = new SessionManager(kv);
143 | await sessionManager.deleteSession(
144 | sessionUser.userId,
145 | sessionUser.sessionId,
146 | );
147 | }
148 |
149 | return redirect(AUTH_ERROR_REDIRECT_TO, {
150 | headers: { "Set-Cookie": await auth.destroySession(session) },
151 | });
152 | }
153 |
--------------------------------------------------------------------------------
/app/lib/auth/strategies/github.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * GitHub OAuth 2.0 strategy for Remix Auth
3 | * @see https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
4 | * @see https://docs.github.com/en/rest/users/users
5 | */
6 |
7 | import type { OAuth2Tokens } from "arctic";
8 | import { OAuth2Strategy } from "remix-auth-oauth2";
9 |
10 | export type GitHubProfile = {
11 | id: string;
12 | displayName: string;
13 | username: string;
14 | profileUrl: string;
15 | photos: [{ value: string }];
16 | emails: Array<{ value: string }>;
17 | _json: {
18 | login: string;
19 | id: number;
20 | node_id: string;
21 | avatar_url: string;
22 | url: string;
23 | html_url: string;
24 | name: string;
25 | email: string | null;
26 | bio: string;
27 | };
28 | };
29 |
30 | export type GitHubStrategyOptions = {
31 | clientId: string;
32 | clientSecret: string;
33 | redirectURI: string;
34 | scopes?: string[];
35 | };
36 |
37 | export const GitHubStrategyDefaultScopes = ["user:email"];
38 | export const GitHubStrategyDefaultName = "github";
39 |
40 | export class GitHubStrategy extends OAuth2Strategy {
41 | name = GitHubStrategyDefaultName;
42 |
43 | constructor(
44 | { clientId, clientSecret, redirectURI, scopes }: GitHubStrategyOptions,
45 | verify: OAuth2Strategy["verify"],
46 | ) {
47 | super(
48 | {
49 | clientId,
50 | clientSecret,
51 | redirectURI,
52 | authorizationEndpoint: "https://github.com/login/oauth/authorize",
53 | tokenEndpoint: "https://github.com/login/oauth/access_token",
54 | scopes: scopes ?? GitHubStrategyDefaultScopes,
55 | },
56 | verify,
57 | );
58 | }
59 |
60 | static async userProfile(tokens: OAuth2Tokens): Promise {
61 | const headers = {
62 | Authorization: `Bearer ${tokens.accessToken()}`,
63 | Accept: "application/vnd.github.v3+json",
64 | "User-Agent": "remix-auth-github",
65 | };
66 |
67 | const [userResponse, emailsResponse] = await Promise.all([
68 | fetch("https://api.github.com/user", { headers }),
69 | fetch("https://api.github.com/user/emails", { headers }),
70 | ]);
71 |
72 | if (!userResponse.ok || !emailsResponse.ok) {
73 | throw new Error(
74 | `Failed to fetch user profile: ${userResponse.statusText || emailsResponse.statusText}`,
75 | );
76 | }
77 |
78 | const raw: GitHubProfile["_json"] = await userResponse.json();
79 |
80 | const emails: Array<{
81 | email: string;
82 | primary: boolean;
83 | verified: boolean;
84 | }> = await emailsResponse.json();
85 |
86 | const primaryEmail =
87 | emails.find((email) => email.primary && email.verified)?.email ||
88 | emails.find((email) => email.verified)?.email;
89 |
90 | return {
91 | id: raw.id.toString(),
92 | displayName: raw.name || raw.login,
93 | username: raw.login,
94 | profileUrl: raw.html_url,
95 | photos: [{ value: raw.avatar_url }],
96 | emails: primaryEmail ? [{ value: primaryEmail }] : [],
97 | _json: {
98 | ...raw,
99 | email: primaryEmail || null,
100 | },
101 | };
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/app/lib/auth/strategies/google.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Google OAuth 2.0 strategy for Remix Auth
3 | * @see https://developers.google.com/identity/protocols/oauth2/web-server
4 | * @see https://developers.google.com/identity/openid-connect/openid-connect
5 | * @see https://developers.google.com/identity/protocols/oauth2/scopes
6 | */
7 |
8 | import type { OAuth2Tokens } from "arctic";
9 | import { OAuth2Strategy } from "remix-auth-oauth2";
10 |
11 | export type GoogleScope = string;
12 |
13 | export type GoogleStrategyOptions = {
14 | clientId: string;
15 | clientSecret: string;
16 | redirectURI: string;
17 | scopes?: GoogleScope[];
18 | accessType?: "online" | "offline";
19 | includeGrantedScopes?: boolean;
20 | prompt?: "none" | "consent" | "select_account";
21 | hd?: string;
22 | loginHint?: string;
23 | };
24 |
25 | export type GoogleProfile = {
26 | id: string;
27 | displayName: string;
28 | name: {
29 | familyName: string;
30 | givenName: string;
31 | };
32 | emails: [{ value: string }];
33 | photos: [{ value: string }];
34 | _json: {
35 | sub: string;
36 | name: string;
37 | given_name: string;
38 | family_name: string;
39 | picture: string;
40 | email: string;
41 | email_verified: boolean;
42 | locale: string;
43 | hd?: string;
44 | };
45 | };
46 |
47 | export type GoogleExtraParams = {
48 | expires_in: 3920;
49 | token_type: "Bearer";
50 | scope: string;
51 | id_token: string;
52 | } & Record;
53 |
54 | export const GoogleStrategyDefaultScopes = [
55 | "openid",
56 | "https://www.googleapis.com/auth/userinfo.profile",
57 | "https://www.googleapis.com/auth/userinfo.email",
58 | ];
59 | export const GoogleStrategyDefaultName = "google";
60 |
61 | export class GoogleStrategy extends OAuth2Strategy {
62 | public name = GoogleStrategyDefaultName;
63 |
64 | private readonly accessType: string;
65 |
66 | private readonly prompt?: "none" | "consent" | "select_account";
67 |
68 | private readonly includeGrantedScopes: boolean;
69 |
70 | private readonly hd?: string;
71 |
72 | private readonly loginHint?: string;
73 |
74 | constructor(
75 | {
76 | clientId,
77 | clientSecret,
78 | redirectURI,
79 | scopes,
80 | accessType,
81 | includeGrantedScopes,
82 | prompt,
83 | hd,
84 | loginHint,
85 | }: GoogleStrategyOptions,
86 | verify: OAuth2Strategy["verify"],
87 | ) {
88 | super(
89 | {
90 | clientId,
91 | clientSecret,
92 | redirectURI,
93 | authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
94 | tokenEndpoint: "https://oauth2.googleapis.com/token",
95 | scopes: scopes ?? GoogleStrategyDefaultScopes,
96 | },
97 | verify,
98 | );
99 | this.accessType = accessType ?? "online";
100 | this.includeGrantedScopes = includeGrantedScopes ?? false;
101 | this.prompt = prompt;
102 | this.hd = hd;
103 | this.loginHint = loginHint;
104 | }
105 |
106 | protected authorizationParams(params: URLSearchParams): URLSearchParams {
107 | const newParams = new URLSearchParams(params);
108 | newParams.set("access_type", this.accessType);
109 | newParams.set("include_granted_scopes", String(this.includeGrantedScopes));
110 | if (this.prompt) newParams.set("prompt", this.prompt);
111 | if (this.hd) newParams.set("hd", this.hd);
112 | if (this.loginHint) newParams.set("login_hint", this.loginHint);
113 | return newParams;
114 | }
115 |
116 | static async userProfile(tokens: OAuth2Tokens): Promise {
117 | const response = await fetch(
118 | "https://www.googleapis.com/oauth2/v3/userinfo",
119 | {
120 | headers: {
121 | Authorization: `Bearer ${tokens.accessToken()}`,
122 | },
123 | },
124 | );
125 |
126 | if (!response.ok) {
127 | throw new Error(`Failed to fetch user profile: ${response.statusText}`);
128 | }
129 |
130 | const raw: GoogleProfile["_json"] = await response.json();
131 |
132 | return {
133 | id: raw.sub,
134 | displayName: raw.name,
135 | name: {
136 | familyName: raw.family_name,
137 | givenName: raw.given_name,
138 | },
139 | emails: [{ value: raw.email }],
140 | photos: [{ value: raw.picture }],
141 | _json: raw,
142 | };
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/app/lib/auth/strategies/totp.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Time-based One-Time Password (TOTP) strategy for Remix Auth
3 | * Implements email-based verification code authentication
4 | */
5 |
6 | import { redirect } from "react-router";
7 | import { Strategy } from "remix-auth/strategy";
8 | import { redirectWithToast } from "../../toast.server";
9 | import { auth } from "../auth.server";
10 | import { getSessionFromCookie } from "../session.server";
11 | import {
12 | Verification,
13 | generateVerification,
14 | verifyCode,
15 | } from "../verification.server";
16 |
17 | export interface SendTOTPOptions {
18 | email: string;
19 | code: string;
20 | magicLink?: string;
21 | request: Request;
22 | formData: FormData;
23 | }
24 |
25 | export type SendTOTP = (options: SendTOTPOptions) => Promise;
26 |
27 | export type ValidateEmail = (email: string) => Promise;
28 |
29 | export interface TOTPStrategyOptions {
30 | kv: KVNamespace;
31 | sendTOTP: SendTOTP;
32 | validateEmail: ValidateEmail;
33 | }
34 |
35 | export interface TOTPVerifyParams {
36 | email: string;
37 | formData?: FormData;
38 | request: Request;
39 | }
40 |
41 | export function toNonEmptyString(value: unknown) {
42 | if (typeof value === "string" && value.length > 0) return value;
43 | return undefined;
44 | }
45 |
46 | export function toOptionalString(value: unknown) {
47 | if (typeof value !== "string" && value !== undefined) {
48 | throw new Error("Value must be a string or undefined.");
49 | }
50 | return value;
51 | }
52 |
53 | export class TOTPStrategy extends Strategy {
54 | public name = "totp";
55 |
56 | private readonly kv: KVNamespace;
57 | private readonly sendTOTP: SendTOTP;
58 | private readonly validateEmail: ValidateEmail;
59 |
60 | constructor(
61 | options: TOTPStrategyOptions,
62 | verify: Strategy.VerifyFunction,
63 | ) {
64 | super(verify);
65 | this.kv = options.kv;
66 | this.sendTOTP = options.sendTOTP;
67 | this.validateEmail = options.validateEmail;
68 | }
69 |
70 | async authenticate(request: Request): Promise {
71 | const { session, sessionUser } = await getSessionFromCookie(request);
72 |
73 | // 1. Check if user is already logged in
74 | if (sessionUser) throw new Error("User already logged in");
75 |
76 | const formData = await request.clone().formData();
77 | const formDataEmail = toNonEmptyString(formData.get("email"));
78 | const formDataCode = toNonEmptyString(formData.get("code"));
79 | const formDataIntent = toNonEmptyString(formData.get("intent"));
80 | const sessionEmail = toOptionalString(session.get("auth:email"));
81 |
82 | // 2. Verify code
83 | if (sessionEmail && formDataCode) {
84 | const isValidCode = await verifyCode(
85 | this.kv,
86 | Verification.Type.EMAIL,
87 | sessionEmail,
88 | formDataCode,
89 | );
90 | if (isValidCode) {
91 | return this.verify({ email: sessionEmail, formData, request });
92 | }
93 | throw new Error("Invalid code");
94 | }
95 |
96 | // 3. Send verification code
97 | const email = formDataEmail ?? sessionEmail;
98 | if (!email) throw new Error("Email is required");
99 |
100 | // Validate email format only when sending the first verification code
101 | const isValidEmail = await this.validateEmail(email);
102 | if (!isValidEmail) throw new Error("Invalid email address");
103 |
104 | const code = await generateVerification(
105 | this.kv,
106 | Verification.Type.EMAIL,
107 | email,
108 | );
109 |
110 | await this.sendTOTP({ email, code, request, formData });
111 |
112 | if (formDataIntent === "resend") {
113 | throw await redirectWithToast("/auth/verify", {
114 | title: "Verification code sent",
115 | type: "success",
116 | });
117 | }
118 |
119 | session.set("auth:email", email);
120 | throw redirect("/auth/verify", {
121 | headers: {
122 | "Set-Cookie": await auth.commitSession(session),
123 | },
124 | });
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/app/lib/auth/verification.server.ts:
--------------------------------------------------------------------------------
1 | import { generateTOTP, verifyTOTP } from "@epic-web/totp";
2 | import { AUTH_TOTP_PERIOD } from "./auth.server";
3 |
4 | /**
5 | * Verification related constants
6 | */
7 | const SEND_COOLDOWN = 60; // Send cooldown period (seconds)
8 | const MAX_ATTEMPTS = 3; // Maximum verification attempts allowed
9 |
10 | /**
11 | * Verification related type definitions
12 | */
13 | export namespace Verification {
14 | export enum Type {
15 | EMAIL = "email",
16 | PHONE = "phone",
17 | }
18 |
19 | export interface Config {
20 | secret: string; // TOTP secret key
21 | }
22 |
23 | export interface Data {
24 | type: Type; // Verification type
25 | identifier: string; // Identifier
26 | verificationConfig: Config; // Verification configuration
27 | verifyAttempts: number; // Number of verification attempts
28 | lastActivityAt?: number; // Last activity timestamp (send or verify)
29 | }
30 |
31 | export interface Metadata {
32 | createdAt: number; // Verification data creation timestamp
33 | }
34 | }
35 |
36 | /**
37 | * Generate verification code
38 | * @param kv KV storage
39 | * @param type Verification type
40 | * @param identifier Identifier
41 | * @returns Generated OTP verification code
42 | * @throws Error if within cooldown period
43 | */
44 | export async function generateVerification(
45 | kv: KVNamespace,
46 | type: Verification.Type,
47 | identifier: string,
48 | ): Promise {
49 | const key = `verification:${identifier}:${type}`;
50 | const { value: existingData } = await kv.getWithMetadata<
51 | Verification.Data,
52 | Verification.Metadata
53 | >(key, "json");
54 |
55 | const now = Date.now();
56 |
57 | // Check if within cooldown period (based on last activity time)
58 | if (existingData?.lastActivityAt) {
59 | const cooldownRemaining =
60 | SEND_COOLDOWN - (now - existingData.lastActivityAt) / 1000;
61 | if (cooldownRemaining > 0) {
62 | throw new Error(
63 | `Please wait ${Math.ceil(cooldownRemaining)} seconds before sending again`,
64 | );
65 | }
66 | }
67 |
68 | const { otp, ...verificationConfig } = await generateTOTP({
69 | digits: 6, // Number of digits in verification code
70 | algorithm: "SHA-256", // Hash algorithm
71 | charSet: "ABCDEFGHJKLMNPQRSTUVWXYZ123456789", // Character set
72 | period: AUTH_TOTP_PERIOD, // TOTP validity period (seconds)
73 | });
74 |
75 | const verificationData: Verification.Data = {
76 | type,
77 | identifier,
78 | verificationConfig,
79 | verifyAttempts: 0,
80 | lastActivityAt: now, // Record current send time
81 | };
82 |
83 | await kv.put(key, JSON.stringify(verificationData), {
84 | expirationTtl: AUTH_TOTP_PERIOD,
85 | metadata: { createdAt: now },
86 | });
87 |
88 | return otp;
89 | }
90 |
91 | /**
92 | * Verify verification code
93 | * @param kv KV storage
94 | * @param type Verification type
95 | * @param identifier Identifier
96 | * @param code Verification code
97 | * @returns Verification result
98 | */
99 | export async function verifyCode(
100 | kv: KVNamespace,
101 | type: Verification.Type,
102 | identifier: string,
103 | code: string,
104 | ): Promise {
105 | const key = `verification:${identifier}:${type}`;
106 | const data = await kv.get(key, "json");
107 |
108 | if (!data) return false;
109 |
110 | if (data.verifyAttempts >= MAX_ATTEMPTS) {
111 | throw new Error("Code has expired, please request a new code");
112 | }
113 |
114 | const result = await verifyTOTP({
115 | otp: code,
116 | ...data.verificationConfig,
117 | });
118 |
119 | // Update verification attempts and last activity time
120 | const updatedData: Verification.Data = {
121 | ...data,
122 | verifyAttempts: data.verifyAttempts + 1,
123 | };
124 | await kv.put(key, JSON.stringify(updatedData), {
125 | expirationTtl: AUTH_TOTP_PERIOD,
126 | });
127 |
128 | // If verification successful, delete verification data
129 | if (result?.delta !== undefined) {
130 | await deleteVerification(kv, type, identifier);
131 | return true;
132 | }
133 |
134 | return false;
135 | }
136 |
137 | /**
138 | * Check if verification data exists
139 | * @param kv KV storage
140 | * @param type Verification type
141 | * @param identifier Identifier
142 | * @returns Whether verification data exists
143 | */
144 | export async function hasVerification(
145 | kv: KVNamespace,
146 | type: Verification.Type,
147 | identifier: string,
148 | ): Promise {
149 | const key = `verification:${identifier}:${type}`;
150 | return !!(await kv.get(key));
151 | }
152 |
153 | /**
154 | * Delete verification data
155 | * @param kv KV storage
156 | * @param type Verification type
157 | * @param identifier Identifier
158 | */
159 | export async function deleteVerification(
160 | kv: KVNamespace,
161 | type: Verification.Type,
162 | identifier: string,
163 | ): Promise {
164 | await kv.delete(`verification:${identifier}:${type}`);
165 | }
166 |
--------------------------------------------------------------------------------
/app/lib/color-scheme/components.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Color scheme implementation based on React Router's official website solution
3 | * @see https://github.com/remix-run/react-router-website
4 | *
5 | * This component provides a complete color theme switching solution:
6 | * - Supports system/light/dark modes
7 | * - Includes client and server-side isomorphic rendering
8 | * - Uses Zod for type validation
9 | * - Responds to system theme changes
10 | */
11 |
12 | import { useLayoutEffect, useMemo } from "react";
13 | import {
14 | useLocation,
15 | useNavigation,
16 | useRouteLoaderData,
17 | useSubmit,
18 | } from "react-router";
19 | import { z } from "zod";
20 | import type { loader as rootLoader } from "~/root";
21 |
22 | export const ColorSchemeSchema = z.object({
23 | colorScheme: z.enum(["light", "dark", "system"]),
24 | returnTo: z.string().optional(),
25 | });
26 |
27 | export type ColorScheme = z.infer["colorScheme"];
28 |
29 | /**
30 | * This hook is used to get the color scheme from the fetcher or the root loader
31 | * @returns The color scheme
32 | */
33 | export function useColorScheme(): ColorScheme {
34 | const rootLoaderData = useRouteLoaderData("root");
35 | const rootColorScheme = rootLoaderData?.colorScheme ?? "system";
36 |
37 | const { formData } = useNavigation();
38 | const optimisticColorScheme = formData?.has("colorScheme")
39 | ? (formData.get("colorScheme") as ColorScheme)
40 | : null;
41 | return optimisticColorScheme || rootColorScheme;
42 | }
43 |
44 | /**
45 | * This hook is used to set the color scheme on the document element
46 | * @returns The submit function
47 | */
48 | export function useSetColorScheme() {
49 | const location = useLocation();
50 | const submit = useSubmit();
51 |
52 | return (colorScheme: ColorScheme) => {
53 | submit(
54 | {
55 | colorScheme,
56 | returnTo: location.pathname + location.search,
57 | },
58 | {
59 | method: "post",
60 | action: "/api/color-scheme",
61 | preventScrollReset: true,
62 | replace: true,
63 | },
64 | );
65 | };
66 | }
67 |
68 | /**
69 | * This component is used to set the color scheme on the document element
70 | * @param nonce The nonce to use for the script
71 | * @returns The script element
72 | */
73 | export function ColorSchemeScript({ nonce }: { nonce: string }) {
74 | const colorScheme = useColorScheme();
75 |
76 | // biome-ignore lint/correctness/useExhaustiveDependencies:
77 | const script = useMemo(
78 | () =>
79 | `let colorScheme = ${JSON.stringify(colorScheme)}; if (colorScheme === "system") { let media = window.matchMedia("(prefers-color-scheme: dark)"); if (media.matches) document.documentElement.classList.add("dark"); }`,
80 | [],
81 | // we don't want this script to ever change
82 | );
83 |
84 | if (typeof document !== "undefined") {
85 | useLayoutEffect(() => {
86 | if (colorScheme === "light") {
87 | document.documentElement.classList.remove("dark");
88 | } else if (colorScheme === "dark") {
89 | document.documentElement.classList.add("dark");
90 | } else if (colorScheme === "system") {
91 | function check(media: MediaQueryList | MediaQueryListEvent) {
92 | if (media.matches) {
93 | document.documentElement.classList.add("dark");
94 | } else {
95 | document.documentElement.classList.remove("dark");
96 | }
97 | }
98 |
99 | const media = window.matchMedia("(prefers-color-scheme: dark)");
100 | check(media);
101 |
102 | media.addEventListener("change", check);
103 | return () => media.removeEventListener("change", check);
104 | } else {
105 | console.error("Impossible color scheme state:", colorScheme);
106 | }
107 | }, [colorScheme]);
108 | }
109 |
110 | return (
111 | <>
112 |
117 |
122 |