├── .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 |
26 |

Appearance

27 |

28 | Customize the appearance of the app. Automatically switch between day 29 | and night themes. 30 |

31 |
32 |
33 | setColorScheme(value)} 39 | > 40 | {ColorSchemeSchema.shape.colorScheme.options.map((value) => ( 41 | 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 |
52 |

{MODAL_TITLE}

53 |

54 | Once you delete your account, you will not be able to sign in again. 55 | You will also lose access to your account and any data associated with 56 | it. 57 |

58 |
59 |
60 | {isDesktop ? ( 61 | 62 | 63 | 64 | 65 | 66 | 67 | {MODAL_TITLE} 68 | 69 | {MODAL_DESCRIPTION(user.email)} 70 | 71 | 72 | 73 | 74 | 75 | ) : ( 76 | 77 | 78 | 79 | 80 | 81 | 82 | {MODAL_TITLE} 83 | 84 | {MODAL_DESCRIPTION(user.email)} 85 | 86 | 87 | 88 | 89 | 90 | 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 |
123 |
124 | 128 | {email.errors && ( 129 |

134 | {email.errors.join(", ")} 135 |

136 | )} 137 |
138 | 145 | 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 |