This is page 34 of 52. Use http://codebase.md/better-auth/better-auth?lines=false&page={x} to view the full context.
# Directory Structure
```
├── .gitattributes
├── .github
│ ├── CODEOWNERS
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.yml
│ │ └── feature_request.yml
│ ├── renovate.json5
│ └── workflows
│ ├── ci.yml
│ ├── e2e.yml
│ ├── preview.yml
│ └── release.yml
├── .gitignore
├── .npmrc
├── .nvmrc
├── .vscode
│ └── settings.json
├── banner-dark.png
├── banner.png
├── biome.json
├── bump.config.ts
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── demo
│ ├── expo-example
│ │ ├── .env.example
│ │ ├── .gitignore
│ │ ├── app.config.ts
│ │ ├── assets
│ │ │ ├── bg-image.jpeg
│ │ │ ├── fonts
│ │ │ │ └── SpaceMono-Regular.ttf
│ │ │ ├── icon.png
│ │ │ └── images
│ │ │ ├── adaptive-icon.png
│ │ │ ├── favicon.png
│ │ │ ├── logo.png
│ │ │ ├── partial-react-logo.png
│ │ │ ├── react-logo.png
│ │ │ ├── [email protected]
│ │ │ ├── [email protected]
│ │ │ └── splash.png
│ │ ├── babel.config.js
│ │ ├── components.json
│ │ ├── expo-env.d.ts
│ │ ├── index.ts
│ │ ├── metro.config.js
│ │ ├── nativewind-env.d.ts
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── app
│ │ │ │ ├── _layout.tsx
│ │ │ │ ├── api
│ │ │ │ │ └── auth
│ │ │ │ │ └── [...route]+api.ts
│ │ │ │ ├── dashboard.tsx
│ │ │ │ ├── forget-password.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ └── sign-up.tsx
│ │ │ ├── components
│ │ │ │ ├── icons
│ │ │ │ │ └── google.tsx
│ │ │ │ └── ui
│ │ │ │ ├── avatar.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── dialog.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── separator.tsx
│ │ │ │ └── text.tsx
│ │ │ ├── global.css
│ │ │ └── lib
│ │ │ ├── auth-client.ts
│ │ │ ├── auth.ts
│ │ │ ├── icons
│ │ │ │ ├── iconWithClassName.ts
│ │ │ │ └── X.tsx
│ │ │ └── utils.ts
│ │ ├── tailwind.config.js
│ │ └── tsconfig.json
│ ├── nextjs
│ │ ├── .env.example
│ │ ├── .gitignore
│ │ ├── app
│ │ │ ├── (auth)
│ │ │ │ ├── forget-password
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── reset-password
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── sign-in
│ │ │ │ │ ├── loading.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ └── two-factor
│ │ │ │ ├── otp
│ │ │ │ │ └── page.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── accept-invitation
│ │ │ │ └── [id]
│ │ │ │ ├── invitation-error.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── admin
│ │ │ │ └── page.tsx
│ │ │ ├── api
│ │ │ │ └── auth
│ │ │ │ └── [...all]
│ │ │ │ └── route.ts
│ │ │ ├── apps
│ │ │ │ └── register
│ │ │ │ └── page.tsx
│ │ │ ├── client-test
│ │ │ │ └── page.tsx
│ │ │ ├── dashboard
│ │ │ │ ├── change-plan.tsx
│ │ │ │ ├── client.tsx
│ │ │ │ ├── organization-card.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ ├── upgrade-button.tsx
│ │ │ │ └── user-card.tsx
│ │ │ ├── device
│ │ │ │ ├── approve
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── denied
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── success
│ │ │ │ └── page.tsx
│ │ │ ├── favicon.ico
│ │ │ ├── features.tsx
│ │ │ ├── fonts
│ │ │ │ ├── GeistMonoVF.woff
│ │ │ │ └── GeistVF.woff
│ │ │ ├── globals.css
│ │ │ ├── layout.tsx
│ │ │ ├── oauth
│ │ │ │ └── authorize
│ │ │ │ ├── concet-buttons.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── page.tsx
│ │ │ └── pricing
│ │ │ └── page.tsx
│ │ ├── components
│ │ │ ├── account-switch.tsx
│ │ │ ├── blocks
│ │ │ │ └── pricing.tsx
│ │ │ ├── logo.tsx
│ │ │ ├── one-tap.tsx
│ │ │ ├── sign-in-btn.tsx
│ │ │ ├── sign-in.tsx
│ │ │ ├── sign-up.tsx
│ │ │ ├── theme-provider.tsx
│ │ │ ├── theme-toggle.tsx
│ │ │ ├── tier-labels.tsx
│ │ │ ├── ui
│ │ │ │ ├── accordion.tsx
│ │ │ │ ├── alert-dialog.tsx
│ │ │ │ ├── alert.tsx
│ │ │ │ ├── aspect-ratio.tsx
│ │ │ │ ├── avatar.tsx
│ │ │ │ ├── badge.tsx
│ │ │ │ ├── breadcrumb.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── calendar.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── carousel.tsx
│ │ │ │ ├── chart.tsx
│ │ │ │ ├── checkbox.tsx
│ │ │ │ ├── collapsible.tsx
│ │ │ │ ├── command.tsx
│ │ │ │ ├── context-menu.tsx
│ │ │ │ ├── copy-button.tsx
│ │ │ │ ├── dialog.tsx
│ │ │ │ ├── drawer.tsx
│ │ │ │ ├── dropdown-menu.tsx
│ │ │ │ ├── form.tsx
│ │ │ │ ├── hover-card.tsx
│ │ │ │ ├── input-otp.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── label.tsx
│ │ │ │ ├── menubar.tsx
│ │ │ │ ├── navigation-menu.tsx
│ │ │ │ ├── pagination.tsx
│ │ │ │ ├── password-input.tsx
│ │ │ │ ├── popover.tsx
│ │ │ │ ├── progress.tsx
│ │ │ │ ├── radio-group.tsx
│ │ │ │ ├── resizable.tsx
│ │ │ │ ├── scroll-area.tsx
│ │ │ │ ├── select.tsx
│ │ │ │ ├── separator.tsx
│ │ │ │ ├── sheet.tsx
│ │ │ │ ├── skeleton.tsx
│ │ │ │ ├── slider.tsx
│ │ │ │ ├── sonner.tsx
│ │ │ │ ├── switch.tsx
│ │ │ │ ├── table.tsx
│ │ │ │ ├── tabs.tsx
│ │ │ │ ├── tabs2.tsx
│ │ │ │ ├── textarea.tsx
│ │ │ │ ├── toast.tsx
│ │ │ │ ├── toaster.tsx
│ │ │ │ ├── toggle-group.tsx
│ │ │ │ ├── toggle.tsx
│ │ │ │ └── tooltip.tsx
│ │ │ └── wrapper.tsx
│ │ ├── components.json
│ │ ├── hooks
│ │ │ └── use-toast.ts
│ │ ├── lib
│ │ │ ├── auth-client.ts
│ │ │ ├── auth-types.ts
│ │ │ ├── auth.ts
│ │ │ ├── email
│ │ │ │ ├── invitation.tsx
│ │ │ │ ├── resend.ts
│ │ │ │ └── reset-password.tsx
│ │ │ ├── metadata.ts
│ │ │ ├── shared.ts
│ │ │ └── utils.ts
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ ├── postcss.config.mjs
│ │ ├── proxy.ts
│ │ ├── public
│ │ │ ├── __og.png
│ │ │ ├── _og.png
│ │ │ ├── favicon
│ │ │ │ ├── android-chrome-192x192.png
│ │ │ │ ├── android-chrome-512x512.png
│ │ │ │ ├── apple-touch-icon.png
│ │ │ │ ├── favicon-16x16.png
│ │ │ │ ├── favicon-32x32.png
│ │ │ │ ├── favicon.ico
│ │ │ │ ├── light
│ │ │ │ │ ├── android-chrome-192x192.png
│ │ │ │ │ ├── android-chrome-512x512.png
│ │ │ │ │ ├── apple-touch-icon.png
│ │ │ │ │ ├── favicon-16x16.png
│ │ │ │ │ ├── favicon-32x32.png
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ └── site.webmanifest
│ │ │ │ └── site.webmanifest
│ │ │ ├── logo.svg
│ │ │ └── og.png
│ │ ├── README.md
│ │ ├── tailwind.config.ts
│ │ ├── tsconfig.json
│ │ └── turbo.json
│ └── stateless
│ ├── .env.example
│ ├── .gitignore
│ ├── next.config.ts
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── src
│ │ ├── app
│ │ │ ├── api
│ │ │ │ ├── auth
│ │ │ │ │ └── [...all]
│ │ │ │ │ └── route.ts
│ │ │ │ └── user
│ │ │ │ └── route.ts
│ │ │ ├── dashboard
│ │ │ │ └── page.tsx
│ │ │ ├── globals.css
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ │ └── lib
│ │ ├── auth-client.ts
│ │ └── auth.ts
│ ├── tailwind.config.ts
│ └── tsconfig.json
├── docker-compose.yml
├── docs
│ ├── .env.example
│ ├── .gitignore
│ ├── app
│ │ ├── api
│ │ │ ├── ai-chat
│ │ │ │ └── route.ts
│ │ │ ├── analytics
│ │ │ │ ├── conversation
│ │ │ │ │ └── route.ts
│ │ │ │ ├── event
│ │ │ │ │ └── route.ts
│ │ │ │ └── feedback
│ │ │ │ └── route.ts
│ │ │ ├── chat
│ │ │ │ └── route.ts
│ │ │ ├── og
│ │ │ │ └── route.tsx
│ │ │ ├── og-release
│ │ │ │ └── route.tsx
│ │ │ ├── search
│ │ │ │ └── route.ts
│ │ │ └── support
│ │ │ └── route.ts
│ │ ├── blog
│ │ │ ├── _components
│ │ │ │ ├── _layout.tsx
│ │ │ │ ├── blog-list.tsx
│ │ │ │ ├── changelog-layout.tsx
│ │ │ │ ├── default-changelog.tsx
│ │ │ │ ├── fmt-dates.tsx
│ │ │ │ ├── icons.tsx
│ │ │ │ ├── stat-field.tsx
│ │ │ │ └── support.tsx
│ │ │ ├── [[...slug]]
│ │ │ │ └── page.tsx
│ │ │ └── layout.tsx
│ │ ├── changelogs
│ │ │ ├── _components
│ │ │ │ ├── _layout.tsx
│ │ │ │ ├── changelog-layout.tsx
│ │ │ │ ├── default-changelog.tsx
│ │ │ │ ├── fmt-dates.tsx
│ │ │ │ ├── grid-pattern.tsx
│ │ │ │ ├── icons.tsx
│ │ │ │ └── stat-field.tsx
│ │ │ ├── [[...slug]]
│ │ │ │ └── page.tsx
│ │ │ └── layout.tsx
│ │ ├── community
│ │ │ ├── _components
│ │ │ │ ├── header.tsx
│ │ │ │ └── stats.tsx
│ │ │ └── page.tsx
│ │ ├── docs
│ │ │ ├── [[...slug]]
│ │ │ │ ├── page.client.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ └── lib
│ │ │ └── get-llm-text.ts
│ │ ├── global.css
│ │ ├── layout.config.tsx
│ │ ├── layout.tsx
│ │ ├── llms.txt
│ │ │ ├── [...slug]
│ │ │ │ └── route.ts
│ │ │ └── route.ts
│ │ ├── not-found.tsx
│ │ ├── page.tsx
│ │ ├── reference
│ │ │ └── route.ts
│ │ ├── sitemap.xml
│ │ ├── static.json
│ │ │ └── route.ts
│ │ └── v1
│ │ ├── _components
│ │ │ └── v1-text.tsx
│ │ ├── bg-line.tsx
│ │ └── page.tsx
│ ├── assets
│ │ ├── Geist.ttf
│ │ └── GeistMono.ttf
│ ├── components
│ │ ├── ai-chat-modal.tsx
│ │ ├── anchor-scroll-fix.tsx
│ │ ├── api-method-tabs.tsx
│ │ ├── api-method.tsx
│ │ ├── banner.tsx
│ │ ├── blocks
│ │ │ └── features.tsx
│ │ ├── builder
│ │ │ ├── beam.tsx
│ │ │ ├── code-tabs
│ │ │ │ ├── code-editor.tsx
│ │ │ │ ├── code-tabs.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── tab-bar.tsx
│ │ │ │ └── theme.ts
│ │ │ ├── index.tsx
│ │ │ ├── sign-in.tsx
│ │ │ ├── sign-up.tsx
│ │ │ ├── social-provider.tsx
│ │ │ ├── store.ts
│ │ │ └── tabs.tsx
│ │ ├── display-techstack.tsx
│ │ ├── divider-text.tsx
│ │ ├── docs
│ │ │ ├── docs.client.tsx
│ │ │ ├── docs.tsx
│ │ │ ├── layout
│ │ │ │ ├── nav.tsx
│ │ │ │ ├── theme-toggle.tsx
│ │ │ │ ├── toc-thumb.tsx
│ │ │ │ └── toc.tsx
│ │ │ ├── page.client.tsx
│ │ │ ├── page.tsx
│ │ │ ├── shared.tsx
│ │ │ └── ui
│ │ │ ├── button.tsx
│ │ │ ├── collapsible.tsx
│ │ │ ├── popover.tsx
│ │ │ └── scroll-area.tsx
│ │ ├── endpoint.tsx
│ │ ├── features.tsx
│ │ ├── floating-ai-search.tsx
│ │ ├── fork-button.tsx
│ │ ├── generate-apple-jwt.tsx
│ │ ├── generate-secret.tsx
│ │ ├── github-stat.tsx
│ │ ├── icons.tsx
│ │ ├── landing
│ │ │ ├── gradient-bg.tsx
│ │ │ ├── grid-pattern.tsx
│ │ │ ├── hero.tsx
│ │ │ ├── section-svg.tsx
│ │ │ ├── section.tsx
│ │ │ ├── spotlight.tsx
│ │ │ └── testimonials.tsx
│ │ ├── logo-context-menu.tsx
│ │ ├── logo.tsx
│ │ ├── markdown-renderer.tsx
│ │ ├── markdown.tsx
│ │ ├── mdx
│ │ │ ├── add-to-cursor.tsx
│ │ │ └── database-tables.tsx
│ │ ├── message-feedback.tsx
│ │ ├── mobile-search-icon.tsx
│ │ ├── nav-bar.tsx
│ │ ├── nav-link.tsx
│ │ ├── nav-mobile.tsx
│ │ ├── promo-card.tsx
│ │ ├── resource-card.tsx
│ │ ├── resource-grid.tsx
│ │ ├── resource-section.tsx
│ │ ├── ripple.tsx
│ │ ├── search-dialog.tsx
│ │ ├── side-bar.tsx
│ │ ├── sidebar-content.tsx
│ │ ├── techstack-icons.tsx
│ │ ├── theme-provider.tsx
│ │ ├── theme-toggler.tsx
│ │ └── ui
│ │ ├── accordion.tsx
│ │ ├── alert-dialog.tsx
│ │ ├── alert.tsx
│ │ ├── aside-link.tsx
│ │ ├── aspect-ratio.tsx
│ │ ├── avatar.tsx
│ │ ├── background-beams.tsx
│ │ ├── background-boxes.tsx
│ │ ├── badge.tsx
│ │ ├── breadcrumb.tsx
│ │ ├── button.tsx
│ │ ├── calendar.tsx
│ │ ├── callout.tsx
│ │ ├── card.tsx
│ │ ├── carousel.tsx
│ │ ├── chart.tsx
│ │ ├── checkbox.tsx
│ │ ├── code-block.tsx
│ │ ├── collapsible.tsx
│ │ ├── command.tsx
│ │ ├── context-menu.tsx
│ │ ├── dialog.tsx
│ │ ├── drawer.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── dynamic-code-block.tsx
│ │ ├── fade-in.tsx
│ │ ├── form.tsx
│ │ ├── hover-card.tsx
│ │ ├── input-otp.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── menubar.tsx
│ │ ├── navigation-menu.tsx
│ │ ├── pagination.tsx
│ │ ├── popover.tsx
│ │ ├── progress.tsx
│ │ ├── radio-group.tsx
│ │ ├── resizable.tsx
│ │ ├── scroll-area.tsx
│ │ ├── select.tsx
│ │ ├── separator.tsx
│ │ ├── sheet.tsx
│ │ ├── sidebar.tsx
│ │ ├── skeleton.tsx
│ │ ├── slider.tsx
│ │ ├── sonner.tsx
│ │ ├── sparkles.tsx
│ │ ├── switch.tsx
│ │ ├── table.tsx
│ │ ├── tabs.tsx
│ │ ├── textarea.tsx
│ │ ├── toggle-group.tsx
│ │ ├── toggle.tsx
│ │ ├── tooltip-docs.tsx
│ │ ├── tooltip.tsx
│ │ └── use-copy-button.tsx
│ ├── components.json
│ ├── content
│ │ ├── blogs
│ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx
│ │ │ ├── 1-3.mdx
│ │ │ ├── authjs-joins-better-auth.mdx
│ │ │ └── seed-round.mdx
│ │ ├── changelogs
│ │ │ ├── 1-2.mdx
│ │ │ └── 1.0.mdx
│ │ └── docs
│ │ ├── adapters
│ │ │ ├── community-adapters.mdx
│ │ │ ├── drizzle.mdx
│ │ │ ├── mongo.mdx
│ │ │ ├── mssql.mdx
│ │ │ ├── mysql.mdx
│ │ │ ├── other-relational-databases.mdx
│ │ │ ├── postgresql.mdx
│ │ │ ├── prisma.mdx
│ │ │ └── sqlite.mdx
│ │ ├── authentication
│ │ │ ├── apple.mdx
│ │ │ ├── atlassian.mdx
│ │ │ ├── cognito.mdx
│ │ │ ├── discord.mdx
│ │ │ ├── dropbox.mdx
│ │ │ ├── email-password.mdx
│ │ │ ├── facebook.mdx
│ │ │ ├── figma.mdx
│ │ │ ├── github.mdx
│ │ │ ├── gitlab.mdx
│ │ │ ├── google.mdx
│ │ │ ├── huggingface.mdx
│ │ │ ├── kakao.mdx
│ │ │ ├── kick.mdx
│ │ │ ├── line.mdx
│ │ │ ├── linear.mdx
│ │ │ ├── linkedin.mdx
│ │ │ ├── microsoft.mdx
│ │ │ ├── naver.mdx
│ │ │ ├── notion.mdx
│ │ │ ├── other-social-providers.mdx
│ │ │ ├── paypal.mdx
│ │ │ ├── polar.mdx
│ │ │ ├── reddit.mdx
│ │ │ ├── roblox.mdx
│ │ │ ├── salesforce.mdx
│ │ │ ├── slack.mdx
│ │ │ ├── spotify.mdx
│ │ │ ├── tiktok.mdx
│ │ │ ├── twitch.mdx
│ │ │ ├── twitter.mdx
│ │ │ ├── vk.mdx
│ │ │ └── zoom.mdx
│ │ ├── basic-usage.mdx
│ │ ├── comparison.mdx
│ │ ├── concepts
│ │ │ ├── api.mdx
│ │ │ ├── cli.mdx
│ │ │ ├── client.mdx
│ │ │ ├── cookies.mdx
│ │ │ ├── database.mdx
│ │ │ ├── email.mdx
│ │ │ ├── hooks.mdx
│ │ │ ├── oauth.mdx
│ │ │ ├── plugins.mdx
│ │ │ ├── rate-limit.mdx
│ │ │ ├── session-management.mdx
│ │ │ ├── typescript.mdx
│ │ │ └── users-accounts.mdx
│ │ ├── examples
│ │ │ ├── astro.mdx
│ │ │ ├── next-js.mdx
│ │ │ ├── nuxt.mdx
│ │ │ ├── remix.mdx
│ │ │ └── svelte-kit.mdx
│ │ ├── guides
│ │ │ ├── auth0-migration-guide.mdx
│ │ │ ├── browser-extension-guide.mdx
│ │ │ ├── clerk-migration-guide.mdx
│ │ │ ├── create-a-db-adapter.mdx
│ │ │ ├── next-auth-migration-guide.mdx
│ │ │ ├── optimizing-for-performance.mdx
│ │ │ ├── saml-sso-with-okta.mdx
│ │ │ ├── supabase-migration-guide.mdx
│ │ │ └── your-first-plugin.mdx
│ │ ├── installation.mdx
│ │ ├── integrations
│ │ │ ├── astro.mdx
│ │ │ ├── convex.mdx
│ │ │ ├── elysia.mdx
│ │ │ ├── expo.mdx
│ │ │ ├── express.mdx
│ │ │ ├── fastify.mdx
│ │ │ ├── hono.mdx
│ │ │ ├── lynx.mdx
│ │ │ ├── nestjs.mdx
│ │ │ ├── next.mdx
│ │ │ ├── nitro.mdx
│ │ │ ├── nuxt.mdx
│ │ │ ├── remix.mdx
│ │ │ ├── solid-start.mdx
│ │ │ ├── svelte-kit.mdx
│ │ │ ├── tanstack.mdx
│ │ │ └── waku.mdx
│ │ ├── introduction.mdx
│ │ ├── meta.json
│ │ ├── plugins
│ │ │ ├── 2fa.mdx
│ │ │ ├── admin.mdx
│ │ │ ├── anonymous.mdx
│ │ │ ├── api-key.mdx
│ │ │ ├── autumn.mdx
│ │ │ ├── bearer.mdx
│ │ │ ├── captcha.mdx
│ │ │ ├── community-plugins.mdx
│ │ │ ├── device-authorization.mdx
│ │ │ ├── dodopayments.mdx
│ │ │ ├── dub.mdx
│ │ │ ├── email-otp.mdx
│ │ │ ├── generic-oauth.mdx
│ │ │ ├── have-i-been-pwned.mdx
│ │ │ ├── jwt.mdx
│ │ │ ├── last-login-method.mdx
│ │ │ ├── magic-link.mdx
│ │ │ ├── mcp.mdx
│ │ │ ├── multi-session.mdx
│ │ │ ├── oauth-proxy.mdx
│ │ │ ├── oidc-provider.mdx
│ │ │ ├── one-tap.mdx
│ │ │ ├── one-time-token.mdx
│ │ │ ├── open-api.mdx
│ │ │ ├── organization.mdx
│ │ │ ├── passkey.mdx
│ │ │ ├── phone-number.mdx
│ │ │ ├── polar.mdx
│ │ │ ├── siwe.mdx
│ │ │ ├── sso.mdx
│ │ │ ├── stripe.mdx
│ │ │ └── username.mdx
│ │ └── reference
│ │ ├── contributing.mdx
│ │ ├── faq.mdx
│ │ ├── options.mdx
│ │ ├── resources.mdx
│ │ ├── security.mdx
│ │ └── telemetry.mdx
│ ├── hooks
│ │ └── use-mobile.ts
│ ├── ignore-build.sh
│ ├── lib
│ │ ├── blog.ts
│ │ ├── chat
│ │ │ └── inkeep-qa-schema.ts
│ │ ├── constants.ts
│ │ ├── export-search-indexes.ts
│ │ ├── inkeep-analytics.ts
│ │ ├── is-active.ts
│ │ ├── metadata.ts
│ │ ├── source.ts
│ │ └── utils.ts
│ ├── next.config.js
│ ├── package.json
│ ├── postcss.config.js
│ ├── proxy.ts
│ ├── public
│ │ ├── avatars
│ │ │ └── beka.jpg
│ │ ├── blogs
│ │ │ ├── authjs-joins.png
│ │ │ ├── seed-round.png
│ │ │ └── supabase-ps.png
│ │ ├── branding
│ │ │ ├── better-auth-brand-assets.zip
│ │ │ ├── better-auth-logo-dark.png
│ │ │ ├── better-auth-logo-dark.svg
│ │ │ ├── better-auth-logo-light.png
│ │ │ ├── better-auth-logo-light.svg
│ │ │ ├── better-auth-logo-wordmark-dark.png
│ │ │ ├── better-auth-logo-wordmark-dark.svg
│ │ │ ├── better-auth-logo-wordmark-light.png
│ │ │ └── better-auth-logo-wordmark-light.svg
│ │ ├── extension-id.png
│ │ ├── favicon
│ │ │ ├── android-chrome-192x192.png
│ │ │ ├── android-chrome-512x512.png
│ │ │ ├── apple-touch-icon.png
│ │ │ ├── favicon-16x16.png
│ │ │ ├── favicon-32x32.png
│ │ │ ├── favicon.ico
│ │ │ ├── light
│ │ │ │ ├── android-chrome-192x192.png
│ │ │ │ ├── android-chrome-512x512.png
│ │ │ │ ├── apple-touch-icon.png
│ │ │ │ ├── favicon-16x16.png
│ │ │ │ ├── favicon-32x32.png
│ │ │ │ ├── favicon.ico
│ │ │ │ └── site.webmanifest
│ │ │ └── site.webmanifest
│ │ ├── images
│ │ │ └── blogs
│ │ │ └── better auth (1).png
│ │ ├── logo.png
│ │ ├── logo.svg
│ │ ├── LogoDark.webp
│ │ ├── LogoLight.webp
│ │ ├── og.png
│ │ ├── open-api-reference.png
│ │ ├── people-say
│ │ │ ├── code-with-antonio.jpg
│ │ │ ├── dagmawi-babi.png
│ │ │ ├── dax.png
│ │ │ ├── dev-ed.png
│ │ │ ├── egoist.png
│ │ │ ├── guillermo-rauch.png
│ │ │ ├── jonathan-wilke.png
│ │ │ ├── josh-tried-coding.jpg
│ │ │ ├── kitze.jpg
│ │ │ ├── lazar-nikolov.png
│ │ │ ├── nizzy.png
│ │ │ ├── omar-mcadam.png
│ │ │ ├── ryan-vogel.jpg
│ │ │ ├── saltyatom.jpg
│ │ │ ├── sebastien-chopin.png
│ │ │ ├── shreyas-mididoddi.png
│ │ │ ├── tech-nerd.png
│ │ │ ├── theo.png
│ │ │ ├── vybhav-bhargav.png
│ │ │ └── xavier-pladevall.jpg
│ │ ├── plus.svg
│ │ ├── release-og
│ │ │ ├── 1-2.png
│ │ │ ├── 1-3.png
│ │ │ └── changelog-og.png
│ │ └── v1-og.png
│ ├── README.md
│ ├── scripts
│ │ ├── endpoint-to-doc
│ │ │ ├── index.ts
│ │ │ ├── input.ts
│ │ │ ├── output.mdx
│ │ │ └── readme.md
│ │ └── sync-orama.ts
│ ├── source.config.ts
│ ├── tsconfig.json
│ └── turbo.json
├── e2e
│ ├── integration
│ │ ├── package.json
│ │ ├── playwright.config.ts
│ │ ├── solid-vinxi
│ │ │ ├── .gitignore
│ │ │ ├── app.config.ts
│ │ │ ├── e2e
│ │ │ │ ├── test.spec.ts
│ │ │ │ └── utils.ts
│ │ │ ├── package.json
│ │ │ ├── public
│ │ │ │ └── favicon.ico
│ │ │ ├── src
│ │ │ │ ├── app.tsx
│ │ │ │ ├── entry-client.tsx
│ │ │ │ ├── entry-server.tsx
│ │ │ │ ├── global.d.ts
│ │ │ │ ├── lib
│ │ │ │ │ ├── auth-client.ts
│ │ │ │ │ └── auth.ts
│ │ │ │ └── routes
│ │ │ │ ├── [...404].tsx
│ │ │ │ ├── api
│ │ │ │ │ └── auth
│ │ │ │ │ └── [...all].ts
│ │ │ │ └── index.tsx
│ │ │ └── tsconfig.json
│ │ ├── test-utils
│ │ │ ├── package.json
│ │ │ └── src
│ │ │ └── playwright.ts
│ │ └── vanilla-node
│ │ ├── e2e
│ │ │ ├── app.ts
│ │ │ ├── domain.spec.ts
│ │ │ ├── postgres-js.spec.ts
│ │ │ ├── test.spec.ts
│ │ │ └── utils.ts
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── main.ts
│ │ │ └── vite-env.d.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ └── smoke
│ ├── package.json
│ ├── test
│ │ ├── bun.spec.ts
│ │ ├── cloudflare.spec.ts
│ │ ├── deno.spec.ts
│ │ ├── fixtures
│ │ │ ├── bun-simple.ts
│ │ │ ├── cloudflare
│ │ │ │ ├── .gitignore
│ │ │ │ ├── drizzle
│ │ │ │ │ ├── 0000_clean_vector.sql
│ │ │ │ │ └── meta
│ │ │ │ │ ├── _journal.json
│ │ │ │ │ └── 0000_snapshot.json
│ │ │ │ ├── drizzle.config.ts
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ ├── auth-schema.ts
│ │ │ │ │ ├── db.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── test
│ │ │ │ │ ├── apply-migrations.ts
│ │ │ │ │ ├── env.d.ts
│ │ │ │ │ └── index.test.ts
│ │ │ │ ├── tsconfig.json
│ │ │ │ ├── vitest.config.ts
│ │ │ │ ├── worker-configuration.d.ts
│ │ │ │ └── wrangler.json
│ │ │ ├── deno-simple.ts
│ │ │ ├── tsconfig-declaration
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ ├── demo.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── username.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── tsconfig-exact-optional-property-types
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── organization.ts
│ │ │ │ │ ├── user-additional-fields.ts
│ │ │ │ │ └── username.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── tsconfig-isolated-module-bundler
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ └── index.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── tsconfig-verbatim-module-syntax-node10
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ └── index.ts
│ │ │ │ └── tsconfig.json
│ │ │ └── vite
│ │ │ ├── package.json
│ │ │ ├── src
│ │ │ │ ├── client.ts
│ │ │ │ └── server.ts
│ │ │ ├── tsconfig.json
│ │ │ └── vite.config.ts
│ │ ├── ssr.ts
│ │ ├── typecheck.spec.ts
│ │ └── vite.spec.ts
│ └── tsconfig.json
├── LICENSE.md
├── package.json
├── packages
│ ├── better-auth
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── __snapshots__
│ │ │ │ └── init.test.ts.snap
│ │ │ ├── adapters
│ │ │ │ ├── adapter-factory
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── test
│ │ │ │ │ │ ├── __snapshots__
│ │ │ │ │ │ │ └── adapter-factory.test.ts.snap
│ │ │ │ │ │ └── adapter-factory.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── create-test-suite.ts
│ │ │ │ ├── drizzle-adapter
│ │ │ │ │ ├── drizzle-adapter.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── test
│ │ │ │ │ ├── .gitignore
│ │ │ │ │ ├── adapter.drizzle.mysql.test.ts
│ │ │ │ │ ├── adapter.drizzle.pg.test.ts
│ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts
│ │ │ │ │ └── generate-schema.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── kysely-adapter
│ │ │ │ │ ├── bun-sqlite-dialect.ts
│ │ │ │ │ ├── dialect.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── kysely-adapter.ts
│ │ │ │ │ ├── node-sqlite-dialect.ts
│ │ │ │ │ ├── test
│ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts
│ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts
│ │ │ │ │ │ ├── adapter.kysely.pg-custom-schema.test.ts
│ │ │ │ │ │ ├── adapter.kysely.pg.test.ts
│ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts
│ │ │ │ │ │ └── node-sqlite-dialect.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── memory-adapter
│ │ │ │ │ ├── adapter.memory.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── memory-adapter.ts
│ │ │ │ ├── mongodb-adapter
│ │ │ │ │ ├── adapter.mongo-db.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── mongodb-adapter.ts
│ │ │ │ ├── prisma-adapter
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── prisma-adapter.ts
│ │ │ │ │ └── test
│ │ │ │ │ ├── .gitignore
│ │ │ │ │ ├── base.prisma
│ │ │ │ │ ├── generate-auth-config.ts
│ │ │ │ │ ├── generate-prisma-schema.ts
│ │ │ │ │ ├── get-prisma-client.ts
│ │ │ │ │ ├── prisma.mysql.test.ts
│ │ │ │ │ ├── prisma.pg.test.ts
│ │ │ │ │ ├── prisma.sqlite.test.ts
│ │ │ │ │ └── push-prisma-schema.ts
│ │ │ │ ├── test-adapter.ts
│ │ │ │ ├── test.ts
│ │ │ │ ├── tests
│ │ │ │ │ ├── auth-flow.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── normal.ts
│ │ │ │ │ ├── number-id.ts
│ │ │ │ │ ├── performance.ts
│ │ │ │ │ └── transactions.ts
│ │ │ │ └── utils.ts
│ │ │ ├── api
│ │ │ │ ├── check-endpoint-conflicts.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── middlewares
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── origin-check.test.ts
│ │ │ │ │ └── origin-check.ts
│ │ │ │ ├── rate-limiter
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── rate-limiter.test.ts
│ │ │ │ ├── routes
│ │ │ │ │ ├── account.test.ts
│ │ │ │ │ ├── account.ts
│ │ │ │ │ ├── callback.ts
│ │ │ │ │ ├── email-verification.test.ts
│ │ │ │ │ ├── email-verification.ts
│ │ │ │ │ ├── error.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── ok.ts
│ │ │ │ │ ├── reset-password.test.ts
│ │ │ │ │ ├── reset-password.ts
│ │ │ │ │ ├── session-api.test.ts
│ │ │ │ │ ├── session.ts
│ │ │ │ │ ├── sign-in.test.ts
│ │ │ │ │ ├── sign-in.ts
│ │ │ │ │ ├── sign-out.test.ts
│ │ │ │ │ ├── sign-out.ts
│ │ │ │ │ ├── sign-up.test.ts
│ │ │ │ │ ├── sign-up.ts
│ │ │ │ │ ├── update-user.test.ts
│ │ │ │ │ └── update-user.ts
│ │ │ │ ├── to-auth-endpoints.test.ts
│ │ │ │ └── to-auth-endpoints.ts
│ │ │ ├── auth.test.ts
│ │ │ ├── auth.ts
│ │ │ ├── call.test.ts
│ │ │ ├── client
│ │ │ │ ├── client-ssr.test.ts
│ │ │ │ ├── client.test.ts
│ │ │ │ ├── config.ts
│ │ │ │ ├── fetch-plugins.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── lynx
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lynx-store.ts
│ │ │ │ ├── parser.ts
│ │ │ │ ├── path-to-object.ts
│ │ │ │ ├── plugins
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── infer-plugin.ts
│ │ │ │ ├── proxy.ts
│ │ │ │ ├── query.ts
│ │ │ │ ├── react
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── react-store.ts
│ │ │ │ ├── session-atom.ts
│ │ │ │ ├── solid
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── solid-store.ts
│ │ │ │ ├── svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── test-plugin.ts
│ │ │ │ ├── types.ts
│ │ │ │ ├── url.test.ts
│ │ │ │ ├── vanilla.ts
│ │ │ │ └── vue
│ │ │ │ ├── index.ts
│ │ │ │ └── vue-store.ts
│ │ │ ├── cookies
│ │ │ │ ├── check-cookies.ts
│ │ │ │ ├── cookie-utils.ts
│ │ │ │ ├── cookies.test.ts
│ │ │ │ └── index.ts
│ │ │ ├── crypto
│ │ │ │ ├── buffer.ts
│ │ │ │ ├── hash.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── jwt.ts
│ │ │ │ ├── password.test.ts
│ │ │ │ ├── password.ts
│ │ │ │ └── random.ts
│ │ │ ├── db
│ │ │ │ ├── db.test.ts
│ │ │ │ ├── field.ts
│ │ │ │ ├── get-migration-schema.test.ts
│ │ │ │ ├── get-migration.ts
│ │ │ │ ├── get-schema.ts
│ │ │ │ ├── get-tables.test.ts
│ │ │ │ ├── get-tables.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── internal-adapter.test.ts
│ │ │ │ ├── internal-adapter.ts
│ │ │ │ ├── schema.ts
│ │ │ │ ├── secondary-storage.test.ts
│ │ │ │ ├── to-zod.ts
│ │ │ │ ├── utils.ts
│ │ │ │ └── with-hooks.ts
│ │ │ ├── index.ts
│ │ │ ├── init.test.ts
│ │ │ ├── init.ts
│ │ │ ├── integrations
│ │ │ │ ├── next-js.ts
│ │ │ │ ├── node.ts
│ │ │ │ ├── react-start.ts
│ │ │ │ ├── solid-start.ts
│ │ │ │ └── svelte-kit.ts
│ │ │ ├── oauth2
│ │ │ │ ├── index.ts
│ │ │ │ ├── link-account.test.ts
│ │ │ │ ├── link-account.ts
│ │ │ │ ├── state.ts
│ │ │ │ └── utils.ts
│ │ │ ├── plugins
│ │ │ │ ├── access
│ │ │ │ │ ├── access.test.ts
│ │ │ │ │ ├── access.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── additional-fields
│ │ │ │ │ ├── additional-fields.test.ts
│ │ │ │ │ └── client.ts
│ │ │ │ ├── admin
│ │ │ │ │ ├── access
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── statement.ts
│ │ │ │ │ ├── admin.test.ts
│ │ │ │ │ ├── admin.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── error-codes.ts
│ │ │ │ │ ├── has-permission.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── anonymous
│ │ │ │ │ ├── anon.test.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── api-key
│ │ │ │ │ ├── api-key.test.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── rate-limit.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── create-api-key.ts
│ │ │ │ │ │ ├── delete-all-expired-api-keys.ts
│ │ │ │ │ │ ├── delete-api-key.ts
│ │ │ │ │ │ ├── get-api-key.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── list-api-keys.ts
│ │ │ │ │ │ ├── update-api-key.ts
│ │ │ │ │ │ └── verify-api-key.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── bearer
│ │ │ │ │ ├── bearer.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── captcha
│ │ │ │ │ ├── captcha.test.ts
│ │ │ │ │ ├── constants.ts
│ │ │ │ │ ├── error-codes.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── utils.ts
│ │ │ │ │ └── verify-handlers
│ │ │ │ │ ├── captchafox.ts
│ │ │ │ │ ├── cloudflare-turnstile.ts
│ │ │ │ │ ├── google-recaptcha.ts
│ │ │ │ │ ├── h-captcha.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── custom-session
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── custom-session.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── device-authorization
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── device-authorization.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── schema.ts
│ │ │ │ ├── email-otp
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── email-otp.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── generic-oauth
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── generic-oauth.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── haveibeenpwned
│ │ │ │ │ ├── haveibeenpwned.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── jwt
│ │ │ │ │ ├── adapter.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── jwt.test.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── sign.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── last-login-method
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── custom-prefix.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── last-login-method.test.ts
│ │ │ │ ├── magic-link
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── magic-link.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── mcp
│ │ │ │ │ ├── authorize.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── mcp.test.ts
│ │ │ │ ├── multi-session
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── multi-session.test.ts
│ │ │ │ ├── oauth-proxy
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── oauth-proxy.test.ts
│ │ │ │ ├── oidc-provider
│ │ │ │ │ ├── authorize.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── oidc.test.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── ui.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── one-tap
│ │ │ │ │ ├── client.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── one-time-token
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── one-time-token.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── open-api
│ │ │ │ │ ├── generator.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── logo.ts
│ │ │ │ │ └── open-api.test.ts
│ │ │ │ ├── organization
│ │ │ │ │ ├── access
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── statement.ts
│ │ │ │ │ ├── adapter.ts
│ │ │ │ │ ├── call.ts
│ │ │ │ │ ├── client.test.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── error-codes.ts
│ │ │ │ │ ├── has-permission.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── organization-hook.test.ts
│ │ │ │ │ ├── organization.test.ts
│ │ │ │ │ ├── organization.ts
│ │ │ │ │ ├── permission.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── crud-access-control.test.ts
│ │ │ │ │ │ ├── crud-access-control.ts
│ │ │ │ │ │ ├── crud-invites.ts
│ │ │ │ │ │ ├── crud-members.test.ts
│ │ │ │ │ │ ├── crud-members.ts
│ │ │ │ │ │ ├── crud-org.test.ts
│ │ │ │ │ │ ├── crud-org.ts
│ │ │ │ │ │ └── crud-team.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── team.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── passkey
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── passkey.test.ts
│ │ │ │ ├── phone-number
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── phone-number-error.ts
│ │ │ │ │ └── phone-number.test.ts
│ │ │ │ ├── siwe
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── siwe.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── two-factor
│ │ │ │ │ ├── backup-codes
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── constant.ts
│ │ │ │ │ ├── error-code.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── otp
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── totp
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── two-factor.test.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── utils.ts
│ │ │ │ │ └── verify-two-factor.ts
│ │ │ │ └── username
│ │ │ │ ├── client.ts
│ │ │ │ ├── error-codes.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ └── username.test.ts
│ │ │ ├── social-providers
│ │ │ │ └── index.ts
│ │ │ ├── social.test.ts
│ │ │ ├── test-utils
│ │ │ │ ├── headers.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── state.ts
│ │ │ │ └── test-instance.ts
│ │ │ ├── types
│ │ │ │ ├── adapter.ts
│ │ │ │ ├── api.ts
│ │ │ │ ├── helper.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── models.ts
│ │ │ │ ├── plugins.ts
│ │ │ │ └── types.test.ts
│ │ │ └── utils
│ │ │ ├── await-object.ts
│ │ │ ├── boolean.ts
│ │ │ ├── clone.ts
│ │ │ ├── constants.ts
│ │ │ ├── date.ts
│ │ │ ├── ensure-utc.ts
│ │ │ ├── get-request-ip.ts
│ │ │ ├── hashing.ts
│ │ │ ├── hide-metadata.ts
│ │ │ ├── id.ts
│ │ │ ├── import-util.ts
│ │ │ ├── index.ts
│ │ │ ├── is-atom.ts
│ │ │ ├── is-promise.ts
│ │ │ ├── json.ts
│ │ │ ├── merger.ts
│ │ │ ├── middleware-response.ts
│ │ │ ├── misc.ts
│ │ │ ├── password.ts
│ │ │ ├── plugin-helper.ts
│ │ │ ├── shim.ts
│ │ │ ├── time.ts
│ │ │ ├── url.ts
│ │ │ └── wildcard.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ ├── vitest.config.ts
│ │ └── vitest.setup.ts
│ ├── cli
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── commands
│ │ │ │ ├── generate.ts
│ │ │ │ ├── info.ts
│ │ │ │ ├── init.ts
│ │ │ │ ├── login.ts
│ │ │ │ ├── mcp.ts
│ │ │ │ ├── migrate.ts
│ │ │ │ └── secret.ts
│ │ │ ├── generators
│ │ │ │ ├── auth-config.ts
│ │ │ │ ├── drizzle.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── kysely.ts
│ │ │ │ ├── prisma.ts
│ │ │ │ └── types.ts
│ │ │ ├── index.ts
│ │ │ └── utils
│ │ │ ├── add-svelte-kit-env-modules.ts
│ │ │ ├── check-package-managers.ts
│ │ │ ├── format-ms.ts
│ │ │ ├── get-config.ts
│ │ │ ├── get-package-info.ts
│ │ │ ├── get-tsconfig-info.ts
│ │ │ └── install-dependencies.ts
│ │ ├── test
│ │ │ ├── __snapshots__
│ │ │ │ ├── auth-schema-mysql-enum.txt
│ │ │ │ ├── auth-schema-mysql-number-id.txt
│ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt
│ │ │ │ ├── auth-schema-mysql-passkey.txt
│ │ │ │ ├── auth-schema-mysql.txt
│ │ │ │ ├── auth-schema-number-id.txt
│ │ │ │ ├── auth-schema-pg-enum.txt
│ │ │ │ ├── auth-schema-pg-passkey.txt
│ │ │ │ ├── auth-schema-sqlite-enum.txt
│ │ │ │ ├── auth-schema-sqlite-number-id.txt
│ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt
│ │ │ │ ├── auth-schema-sqlite-passkey.txt
│ │ │ │ ├── auth-schema-sqlite.txt
│ │ │ │ ├── auth-schema.txt
│ │ │ │ ├── migrations.sql
│ │ │ │ ├── schema-mongodb.prisma
│ │ │ │ ├── schema-mysql-custom.prisma
│ │ │ │ ├── schema-mysql.prisma
│ │ │ │ ├── schema-numberid.prisma
│ │ │ │ └── schema.prisma
│ │ │ ├── generate-all-db.test.ts
│ │ │ ├── generate.test.ts
│ │ │ ├── get-config.test.ts
│ │ │ ├── info.test.ts
│ │ │ └── migrate.test.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── core
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── api
│ │ │ │ └── index.ts
│ │ │ ├── async_hooks
│ │ │ │ └── index.ts
│ │ │ ├── context
│ │ │ │ ├── endpoint-context.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── transaction.ts
│ │ │ ├── db
│ │ │ │ ├── adapter
│ │ │ │ │ └── index.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── plugin.ts
│ │ │ │ ├── schema
│ │ │ │ │ ├── account.ts
│ │ │ │ │ ├── rate-limit.ts
│ │ │ │ │ ├── session.ts
│ │ │ │ │ ├── shared.ts
│ │ │ │ │ ├── user.ts
│ │ │ │ │ └── verification.ts
│ │ │ │ └── type.ts
│ │ │ ├── env
│ │ │ │ ├── color-depth.ts
│ │ │ │ ├── env-impl.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── logger.test.ts
│ │ │ │ └── logger.ts
│ │ │ ├── error
│ │ │ │ ├── codes.ts
│ │ │ │ └── index.ts
│ │ │ ├── index.ts
│ │ │ ├── oauth2
│ │ │ │ ├── client-credentials-token.ts
│ │ │ │ ├── create-authorization-url.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── oauth-provider.ts
│ │ │ │ ├── refresh-access-token.ts
│ │ │ │ ├── utils.ts
│ │ │ │ └── validate-authorization-code.ts
│ │ │ ├── social-providers
│ │ │ │ ├── apple.ts
│ │ │ │ ├── atlassian.ts
│ │ │ │ ├── cognito.ts
│ │ │ │ ├── discord.ts
│ │ │ │ ├── dropbox.ts
│ │ │ │ ├── facebook.ts
│ │ │ │ ├── figma.ts
│ │ │ │ ├── github.ts
│ │ │ │ ├── gitlab.ts
│ │ │ │ ├── google.ts
│ │ │ │ ├── huggingface.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── kakao.ts
│ │ │ │ ├── kick.ts
│ │ │ │ ├── line.ts
│ │ │ │ ├── linear.ts
│ │ │ │ ├── linkedin.ts
│ │ │ │ ├── microsoft-entra-id.ts
│ │ │ │ ├── naver.ts
│ │ │ │ ├── notion.ts
│ │ │ │ ├── paypal.ts
│ │ │ │ ├── polar.ts
│ │ │ │ ├── reddit.ts
│ │ │ │ ├── roblox.ts
│ │ │ │ ├── salesforce.ts
│ │ │ │ ├── slack.ts
│ │ │ │ ├── spotify.ts
│ │ │ │ ├── tiktok.ts
│ │ │ │ ├── twitch.ts
│ │ │ │ ├── twitter.ts
│ │ │ │ ├── vk.ts
│ │ │ │ └── zoom.ts
│ │ │ ├── types
│ │ │ │ ├── context.ts
│ │ │ │ ├── cookie.ts
│ │ │ │ ├── helper.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── init-options.ts
│ │ │ │ ├── plugin-client.ts
│ │ │ │ └── plugin.ts
│ │ │ └── utils
│ │ │ ├── error-codes.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── expo
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ └── index.ts
│ │ ├── test
│ │ │ └── expo.test.ts
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ └── tsdown.config.ts
│ ├── sso
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── index.ts
│ │ │ ├── oidc.test.ts
│ │ │ └── saml.test.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── stripe
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── hooks.ts
│ │ │ ├── index.ts
│ │ │ ├── schema.ts
│ │ │ ├── stripe.test.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ └── vitest.config.ts
│ └── telemetry
│ ├── package.json
│ ├── src
│ │ ├── detectors
│ │ │ ├── detect-auth-config.ts
│ │ │ ├── detect-database.ts
│ │ │ ├── detect-framework.ts
│ │ │ ├── detect-project-info.ts
│ │ │ ├── detect-runtime.ts
│ │ │ └── detect-system-info.ts
│ │ ├── index.ts
│ │ ├── project-id.ts
│ │ ├── telemetry.test.ts
│ │ ├── types.ts
│ │ └── utils
│ │ ├── hash.ts
│ │ ├── id.ts
│ │ ├── import-util.ts
│ │ └── package-json.ts
│ ├── tsconfig.json
│ └── tsdown.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── SECURITY.md
├── tsconfig.base.json
├── tsconfig.json
└── turbo.json
```
# Files
--------------------------------------------------------------------------------
/packages/better-auth/src/plugins/organization/routes/crud-access-control.test.ts:
--------------------------------------------------------------------------------
```typescript
import type { DBFieldAttribute } from "@better-auth/core/db";
import { describe, expect, expectTypeOf } from "vitest";
import { createAuthClient } from "../../../client";
import { parseSetCookieHeader } from "../../../cookies";
import { getTestInstance } from "../../../test-utils/test-instance";
import { createAccessControl } from "../../access";
import { adminAc, defaultStatements, memberAc, ownerAc } from "../access";
import { inferOrgAdditionalFields, organizationClient } from "../client";
import { ORGANIZATION_ERROR_CODES } from "../error-codes";
import { organization } from "../organization";
describe("dynamic access control", async (it) => {
const ac = createAccessControl({
project: ["create", "read", "update", "delete"],
sales: ["create", "read", "update", "delete"],
...defaultStatements,
});
const owner = ac.newRole({
project: ["create", "delete", "update", "read"],
sales: ["create", "read", "update", "delete"],
...ownerAc.statements,
});
const admin = ac.newRole({
project: ["create", "read", "delete", "update"],
sales: ["create", "read"],
...adminAc.statements,
});
const member = ac.newRole({
project: ["read"],
sales: ["read"],
...memberAc.statements,
});
const additionalFields = {
color: {
type: "string",
defaultValue: "#ffffff",
required: true,
},
serverOnlyValue: {
type: "string",
defaultValue: "server-only-value",
input: false,
required: true,
},
} satisfies Record<string, DBFieldAttribute>;
const { auth, customFetchImpl, sessionSetter, signInWithTestUser } =
await getTestInstance({
plugins: [
organization({
ac,
roles: {
admin,
member,
owner,
},
dynamicAccessControl: {
enabled: true,
},
schema: {
organizationRole: {
additionalFields,
},
},
}),
],
});
const authClient = createAuthClient({
baseURL: "http://localhost:3000",
plugins: [
organizationClient({
ac,
roles: {
admin,
member,
owner,
},
dynamicAccessControl: {
enabled: true,
},
schema: inferOrgAdditionalFields<typeof auth>(),
}),
],
fetchOptions: {
customFetchImpl,
},
});
const {
organization: { checkRolePermission, hasPermission, create },
} = authClient;
const { headers, user, session } = await signInWithTestUser();
async function createUser({ role }: { role: "admin" | "member" | "owner" }) {
const normalUserDetails = {
email: `some-test-user-${crypto.randomUUID()}@email.com`,
name: `some-test-user`,
password: `some-test-user-${crypto.randomUUID()}`,
};
const normalUser = await auth.api.signUpEmail({ body: normalUserDetails });
const member = await auth.api.addMember({
body: {
role: role || "member",
userId: normalUser.user.id,
organizationId: org.data?.id,
},
headers,
});
if (!member) throw new Error("Member not found");
let userHeaders = new Headers();
await authClient.signIn.email({
email: normalUserDetails.email,
password: normalUserDetails.password,
fetchOptions: {
onSuccess: (context) => {
const header = context.response.headers.get("set-cookie");
const cookies = parseSetCookieHeader(header || "");
const signedCookie = cookies.get("better-auth.session_token")?.value;
userHeaders.set(
"cookie",
`better-auth.session_token=${signedCookie}`,
);
},
},
});
await authClient.organization.setActive({
organizationId: org.data?.id,
fetchOptions: {
headers: userHeaders,
},
});
return { headers: userHeaders, user: normalUser, member };
}
const org = await create(
{
name: "test",
slug: "test",
metadata: {
test: "test",
},
},
{
onSuccess: sessionSetter(headers),
headers,
},
);
if (!org.data) throw new Error("Organization not created");
const memberInfo = await auth.api.getActiveMember({ headers });
if (!memberInfo) throw new Error("Member info not found");
// Create an admin user in the org.
const {
headers: adminHeaders,
user: adminUser,
member: adminMember,
} = await createUser({
role: "admin",
});
// Create normal users in the org.
const {
headers: normalHeaders,
user: normalUser,
member: normalMember,
} = await createUser({
role: "member",
});
/**
* The following test will:
* - Creation of a new role
* - Updating their own role to the newly created one (from owner to the new one)
* - Tests the `hasPermission` endpoint against the new role, for both a success and a failure case.
* - Additional fields passed in body, and correct return value & types.
*/
it("should successfully create a new role", async () => {
// Create a new "test" role with permissions to create a project.
const permission = {
project: ["create"],
};
const testRole = await authClient.organization.createRole(
{
role: "test",
permission,
additionalFields: {
color: "#000000",
},
},
{
headers,
},
);
expect(testRole.error).toBeNull();
expect(testRole.data?.success).toBe(true);
expect(testRole.data?.roleData.permission).toEqual(permission);
expect(testRole.data?.roleData.color).toBe("#000000");
expect(testRole.data?.roleData.serverOnlyValue).toBe("server-only-value");
expectTypeOf(testRole.data?.roleData.serverOnlyValue).toEqualTypeOf<
string | undefined
>();
expectTypeOf(testRole.data?.roleData.role).toEqualTypeOf<
string | undefined
>();
if (!testRole.data) return;
// Update the role to use the new one.
await auth.api.updateMemberRole({
body: { memberId: normalMember.id, role: testRole.data.roleData.role },
headers,
});
// Test against `hasPermission` endpoint
// Should fail because the user doesn't have the permission to delete a project.
const shouldFail = await auth.api.hasPermission({
body: {
organizationId: org.data?.id,
permissions: {
project: ["delete"],
},
},
headers: normalHeaders,
});
expect(shouldFail.success).toBe(false);
// Should pass because the user has the permission to create a project.
const shouldPass = await auth.api.hasPermission({
body: {
organizationId: org.data?.id,
permissions: {
project: ["create"],
},
},
headers: normalHeaders,
});
expect(shouldPass.success).toBe(true);
});
it("should not be allowed to create a role without the right ac resource permissions", async () => {
const testRole = await authClient.organization.createRole(
{
role: `test-${crypto.randomUUID()}`,
permission: {
project: ["create"],
},
additionalFields: {
color: "#000000",
},
},
{
headers: normalHeaders,
},
);
expect(testRole.data).toBeNull();
if (!testRole.error) throw new Error("Test role error not found");
expect(testRole.error.message).toEqual(
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE,
);
});
it("should not be allowed to create a role with higher permissions than the current role", async () => {
const testRole = await authClient.organization.createRole(
{
role: `test-${crypto.randomUUID()}`,
permission: {
sales: ["create", "delete", "create", "update", "read"], // Intentionally duplicate the "create" permission.
},
additionalFields: {
color: "#000000",
},
},
{
headers: adminHeaders,
},
);
expect(testRole.data).toBeNull();
if (testRole.data) throw new Error("Test role created");
expect(
testRole.error.message?.startsWith(
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE,
),
).toBe(true);
expect("missingPermissions" in testRole.error).toBe(true);
if (!("missingPermissions" in testRole.error)) return;
expect(testRole.error.missingPermissions).toEqual([
"sales:delete",
"sales:update",
]);
});
it("should not be allowed to create a role which is either predefined or already exists in DB", async () => {
const testRole = await authClient.organization.createRole(
{
role: "admin", // This is a predefined role.
permission: {
project: ["create"],
},
additionalFields: {
color: "#000000",
},
},
{
headers,
},
);
expect(testRole.data).toBeNull();
if (!testRole.error) throw new Error("Test role error not found");
expect(testRole.error.message).toEqual(
ORGANIZATION_ERROR_CODES.ROLE_NAME_IS_ALREADY_TAKEN,
);
const testRole2 = await authClient.organization.createRole(
{
role: "test", // This is a role that was created in the previous test.
permission: {
project: ["create"],
},
additionalFields: {
color: "#000000",
},
},
{
headers,
},
);
expect(testRole2.data).toBeNull();
if (!testRole2.error) throw new Error("Test role error not found");
expect(testRole2.error.message).toEqual(
ORGANIZATION_ERROR_CODES.ROLE_NAME_IS_ALREADY_TAKEN,
);
});
it("should delete a role by id", async () => {
const testRole = await authClient.organization.createRole(
{
role: `test-${crypto.randomUUID()}`,
permission: {
project: ["create"],
},
additionalFields: {
color: "#000000",
},
},
{
headers,
},
);
if (!testRole.data) throw testRole.error;
const roleId = testRole.data.roleData.id;
const res = await auth.api.deleteOrgRole({
body: { roleId },
headers,
});
expect(res).not.toBeNull();
});
it("should delete a role by name", async () => {
const testRole = await authClient.organization.createRole(
{
role: `test-${crypto.randomUUID()}`,
permission: {
project: ["create"],
},
additionalFields: {
color: "#000000",
},
},
{
headers,
},
);
if (!testRole.data) throw testRole.error;
const roleName = testRole.data.roleData.role;
const res = await auth.api.deleteOrgRole({
body: { roleName },
headers,
});
expect(res).not.toBeNull();
});
it("should not be allowed to delete a role without nessesary permissions", async () => {
const testRole = await authClient.organization.createRole(
{
role: `test-${crypto.randomUUID()}`,
permission: {
project: ["create"],
},
additionalFields: {
color: "#000000",
},
},
{
headers: adminHeaders,
},
);
if (!testRole.data) throw testRole.error;
expect(
auth.api.deleteOrgRole({
body: { roleName: testRole.data.roleData.role },
headers: normalHeaders,
}),
).rejects.toThrow(
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE,
);
});
it("should not be allowed to delete a role that doesn't exist", async () => {
try {
const res = await auth.api.deleteOrgRole({
body: { roleName: "non-existent-role" },
headers,
});
expect(res).toBeNull();
} catch (error: any) {
if ("body" in error && "message" in error.body) {
expect(error.body.message).toBe(
ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND,
);
} else {
throw error;
}
}
});
it("should list roles", async () => {
const permission = {
project: ["create"],
ac: ["read", "update", "create", "delete"],
};
await authClient.organization.createRole(
{
role: `list-test-role`,
permission,
additionalFields: {
color: "#123",
},
},
{
headers,
},
);
const res = await auth.api.listOrgRoles({ headers });
expect(res).not.toBeNull();
expect(res.length).toBeGreaterThan(0);
expect(typeof res[0]!.permission === "string").toBe(false);
const foundRole = res.find((x) => x.role === "list-test-role");
expect(foundRole).not.toBeNull();
expect(foundRole?.permission).toEqual(permission);
expect(foundRole?.color).toBe(`#123`);
expectTypeOf(foundRole?.color).toEqualTypeOf<string | undefined>();
expectTypeOf(foundRole?.serverOnlyValue).toEqualTypeOf<
string | undefined
>();
});
it("should not be allowed to list roles without nessesary permissions", async () => {
expect(auth.api.listOrgRoles({ headers: normalHeaders })).rejects.toThrow(
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE,
);
});
it("should get a role by id", async () => {
const testRole = await authClient.organization.createRole(
{
role: `read-test-role-${crypto.randomUUID()}`,
permission: {
project: ["create"],
},
additionalFields: {
color: "#000000",
},
},
{
headers,
},
);
if (!testRole.data) throw testRole.error;
const roleId = testRole.data.roleData.id;
const res = await auth.api.getOrgRole({
query: {
roleId,
organizationId: org.data?.id,
},
headers,
});
expect(res).not.toBeNull();
expect(res.role).toBe(testRole.data.roleData.role);
expect(res.permission).toEqual(testRole.data.roleData.permission);
expect(res.color).toBe("#000000");
expectTypeOf(res.color).toEqualTypeOf<string>();
});
it("should get a role by name", async () => {
const testRole = await authClient.organization.createRole(
{
role: `read-test-role-${crypto.randomUUID()}`,
permission: {
project: ["create"],
},
additionalFields: {
color: "#000000",
},
},
{
headers,
},
);
if (!testRole.data) throw testRole.error;
const roleName = testRole.data.roleData.role;
const res = await auth.api.getOrgRole({
query: {
roleName,
organizationId: org.data?.id,
},
headers,
});
expect(res).not.toBeNull();
expect(res.role).toBe(testRole.data.roleData.role);
expect(res.permission).toEqual(testRole.data.roleData.permission);
expect(res.color).toBe("#000000");
expectTypeOf(res.color).toEqualTypeOf<string>();
});
it("should update a role's permission by id", async () => {
const testRole = await authClient.organization.createRole(
{
role: `update-test-role-${crypto.randomUUID()}`,
permission: {
project: ["create"],
},
additionalFields: {
color: "#000000",
},
},
{
headers,
},
);
if (!testRole.data) throw testRole.error;
const roleId = testRole.data.roleData.id;
const res = await auth.api.updateOrgRole({
body: {
roleId,
data: { permission: { project: ["create", "delete"] } },
},
headers,
});
expect(res).not.toBeNull();
expect(res.roleData.role).toBe(testRole.data.roleData.role);
expect(res.roleData.permission).toEqual({ project: ["create", "delete"] });
});
it("should update a role's name by name", async () => {
const testRole = await authClient.organization.createRole(
{
role: `test-${crypto.randomUUID()}`,
permission: {
project: ["create"],
},
additionalFields: {
color: "#000000",
},
},
{ headers },
);
if (!testRole.data) throw testRole.error;
const roleName = testRole.data.roleData.role;
const res = await auth.api.updateOrgRole({
body: { roleName, data: { roleName: `updated-${roleName}` } },
headers,
});
expect(res).not.toBeNull();
expect(res.roleData.role).toBe(`updated-${roleName}`);
const res2 = await auth.api.getOrgRole({
query: {
roleName: `updated-${roleName}`,
organizationId: org.data?.id,
},
headers,
});
expect(res2).not.toBeNull();
expect(res2.role).toBe(`updated-${roleName}`);
});
it("should not be allowed to update a role without the right ac resource permissions", async () => {
const testRole = await authClient.organization.createRole(
{
role: `update-not-allowed-${crypto.randomUUID()}`,
permission: {
project: ["create"],
},
additionalFields: {
color: "#000000",
},
},
{ headers },
);
if (!testRole.data) throw testRole.error;
const roleId = testRole.data.roleData.id;
await expect(
auth.api.updateOrgRole({
body: {
roleId,
data: { roleName: `updated-${testRole.data.roleData.role}` },
},
headers: normalHeaders,
}),
).rejects.toThrow();
});
it("should be able to update additional fields", async () => {
const testRole = await authClient.organization.createRole(
{
role: `test-${crypto.randomUUID()}`,
permission: {
project: ["create"],
},
additionalFields: {
color: "#000000",
//@ts-expect-error - intentionally invalid key
someInvalidKey: "this would be ignored by zod",
},
},
{
headers,
},
);
if (!testRole.data) throw testRole.error;
const roleId = testRole.data.roleData.id;
const res = await auth.api.updateOrgRole({
body: { roleId, data: { color: "#111111" } },
headers,
});
expect(res).not.toBeNull();
expect(res.roleData.color).toBe("#111111");
//@ts-expect-error - intentionally invalid key
expect(res.roleData.someInvalidKey).toBeUndefined();
});
/**
* Security test cases for the privilege escalation vulnerability fix
* These tests verify that member queries properly filter by userId to prevent
* unauthorized privilege escalation where any member could gain admin permissions
*/
it("should not allow member to list roles using another member's permissions", async () => {
// Create a fresh member for this test to avoid role contamination
const {
headers: freshMemberHeaders,
user: freshMemberUser,
member: freshMember,
} = await createUser({
role: "member",
});
// Create a test role that only admin can read
const adminOnlyRole = await authClient.organization.createRole(
{
role: `admin-only-${crypto.randomUUID()}`,
permission: {
project: ["delete"],
},
additionalFields: {
color: "#ff0000",
},
},
{
headers,
},
);
if (!adminOnlyRole.data) throw adminOnlyRole.error;
// Try to list roles as a regular member - should succeed but with member permissions
const listAsMembers = await auth.api.listOrgRoles({
query: { organizationId: org.data?.id },
headers: freshMemberHeaders,
});
// Member should be able to list roles (they have ac:read permission)
expect(listAsMembers).toBeDefined();
expect(Array.isArray(listAsMembers)).toBe(true);
});
it("should not allow member to get role details using another member's permissions", async () => {
// Create a fresh member for this test to avoid role contamination
const {
headers: freshMemberHeaders,
user: freshMemberUser,
member: freshMember,
} = await createUser({
role: "member",
});
// Create a test role
const testRole = await authClient.organization.createRole(
{
role: `test-get-role-${crypto.randomUUID()}`,
permission: {
project: ["read"],
},
additionalFields: {
color: "#ff0000",
},
},
{
headers,
},
);
if (!testRole.data) throw testRole.error;
// Try to get role as a regular member - should succeed with member permissions
const getRoleAsMember = await auth.api.getOrgRole({
query: {
organizationId: org.data?.id,
roleId: testRole.data.roleData.id,
},
headers: freshMemberHeaders,
});
// Member should be able to read the role (they have ac:read permission)
expect(getRoleAsMember).toBeDefined();
expect(getRoleAsMember.id).toBe(testRole.data.roleData.id);
});
it("should not allow member to update roles without proper permissions (privilege escalation test)", async () => {
// Create a fresh member for this test to avoid role contamination
const {
headers: freshMemberHeaders,
user: freshMemberUser,
member: freshMember,
} = await createUser({
role: "member",
});
// Create a test role that the owner will create
const vulnerableRole = await authClient.organization.createRole(
{
role: `vulnerable-role-${crypto.randomUUID()}`,
permission: {
project: ["read"],
},
additionalFields: {
color: "#ff0000",
},
},
{
headers, // owner headers
},
);
if (!vulnerableRole.data) throw vulnerableRole.error;
// Regular member should NOT be able to update the role
// This tests the privilege escalation vulnerability fix
await expect(
auth.api.updateOrgRole({
body: {
roleId: vulnerableRole.data.roleData.id,
data: {
permission: {
ac: ["create", "update", "delete"], // Try to escalate privileges
organization: ["update", "delete"],
project: ["create", "read", "update", "delete"],
},
},
},
headers: freshMemberHeaders, // member headers
}),
).rejects.toThrow();
// Verify the role permissions haven't changed
const roleCheck = await auth.api.getOrgRole({
query: {
organizationId: org.data?.id,
roleId: vulnerableRole.data.roleData.id,
},
headers,
});
expect(roleCheck.permission).toEqual({
project: ["read"],
});
});
it("should properly identify the correct member when checking permissions", async () => {
// Create a fresh member for this test to avoid role contamination
const {
headers: freshMemberHeaders,
user: freshMemberUser,
member: freshMember,
} = await createUser({
role: "member",
});
// This test ensures that the member lookup uses both organizationId AND userId
// Create a role that only owner can update
const ownerOnlyRole = await authClient.organization.createRole(
{
role: `owner-only-update-${crypto.randomUUID()}`,
permission: {
sales: ["delete"],
},
additionalFields: {
color: "#ff0000",
},
},
{
headers, // owner headers
},
);
if (!ownerOnlyRole.data) throw ownerOnlyRole.error;
// Member should not be able to update (doesn't have ac:update)
await expect(
auth.api.updateOrgRole({
body: {
roleId: ownerOnlyRole.data.roleData.id,
data: {
roleName: "hijacked-role",
},
},
headers: freshMemberHeaders,
}),
).rejects.toThrow(
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE,
);
// Admin should be able to update (has ac:update)
const adminUpdate = await auth.api.updateOrgRole({
body: {
roleId: ownerOnlyRole.data.roleData.id,
data: {
roleName: `admin-updated-${ownerOnlyRole.data.roleData.role}`,
},
},
headers: adminHeaders,
});
expect(adminUpdate).toBeDefined();
expect(adminUpdate.roleData.role).toContain("admin-updated");
});
it("should not allow cross-organization privilege escalation", async () => {
// Create a fresh member for this test to avoid role contamination
const {
headers: freshMemberHeaders,
user: freshMemberUser,
member: freshMember,
} = await createUser({
role: "member",
});
// Create a second organization
const org2 = await authClient.organization.create(
{
name: "second-org",
slug: `second-org-${crypto.randomUUID()}`,
},
{
onSuccess: sessionSetter(headers),
headers,
},
);
if (!org2.data) throw new Error("Second organization not created");
// Try to list roles from org1 while active in org2 - should fail
await authClient.organization.setActive({
organizationId: org2.data.id,
fetchOptions: {
headers: freshMemberHeaders,
},
});
// This should fail because the member is not in org2
await expect(
auth.api.listOrgRoles({
query: { organizationId: org2.data.id },
headers: freshMemberHeaders,
}),
).rejects.toThrow("You are not a member of this organization");
// Switch back to org1
await authClient.organization.setActive({
organizationId: org.data?.id,
fetchOptions: {
headers: freshMemberHeaders,
},
});
});
});
```
--------------------------------------------------------------------------------
/packages/better-auth/src/plugins/organization/routes/crud-org.ts:
--------------------------------------------------------------------------------
```typescript
import { createAuthEndpoint } from "@better-auth/core/api";
import { APIError } from "better-call";
import * as z from "zod";
import { getSessionFromCtx, requestOnlySessionMiddleware } from "../../../api";
import { setSessionCookie } from "../../../cookies";
import {
type InferAdditionalFieldsFromPluginOptions,
toZodSchema,
} from "../../../db";
import { getOrgAdapter } from "../adapter";
import { orgMiddleware, orgSessionMiddleware } from "../call";
import { ORGANIZATION_ERROR_CODES } from "../error-codes";
import { hasPermission } from "../has-permission";
import type {
InferInvitation,
InferMember,
InferOrganization,
Member,
Team,
TeamMember,
} from "../schema";
import type { OrganizationOptions } from "../types";
export const createOrganization = <O extends OrganizationOptions>(
options?: O,
) => {
const additionalFieldsSchema = toZodSchema({
fields: options?.schema?.organization?.additionalFields || {},
isClientSide: true,
});
const baseSchema = z.object({
name: z.string().min(1).meta({
description: "The name of the organization",
}),
slug: z.string().min(1).meta({
description: "The slug of the organization",
}),
userId: z.coerce
.string()
.meta({
description:
'The user id of the organization creator. If not provided, the current user will be used. Should only be used by admins or when called by the server. server-only. Eg: "user-id"',
})
.optional(),
logo: z
.string()
.meta({
description: "The logo of the organization",
})
.optional(),
metadata: z
.record(z.string(), z.any())
.meta({
description: "The metadata of the organization",
})
.optional(),
keepCurrentActiveOrganization: z
.boolean()
.meta({
description:
"Whether to keep the current active organization active after creating a new one. Eg: true",
})
.optional(),
});
type Body = InferAdditionalFieldsFromPluginOptions<"organization", O> &
z.infer<typeof baseSchema>;
return createAuthEndpoint(
"/organization/create",
{
method: "POST",
body: z.object({
...baseSchema.shape,
...additionalFieldsSchema.shape,
}),
use: [orgMiddleware],
metadata: {
$Infer: {
body: {} as Body,
},
openapi: {
description: "Create an organization",
responses: {
"200": {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
description: "The organization that was created",
$ref: "#/components/schemas/Organization",
},
},
},
},
},
},
},
},
async (ctx) => {
const session = await getSessionFromCtx(ctx);
if (!session && (ctx.request || ctx.headers)) {
throw new APIError("UNAUTHORIZED");
}
let user = session?.user || null;
if (!user) {
if (!ctx.body.userId) {
throw new APIError("UNAUTHORIZED");
}
user = await ctx.context.internalAdapter.findUserById(ctx.body.userId);
}
if (!user) {
return ctx.json(null, {
status: 401,
});
}
const options = ctx.context.orgOptions;
const canCreateOrg =
typeof options?.allowUserToCreateOrganization === "function"
? await options.allowUserToCreateOrganization(user)
: options?.allowUserToCreateOrganization === undefined
? true
: options.allowUserToCreateOrganization;
if (!canCreateOrg) {
throw new APIError("FORBIDDEN", {
message:
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION,
});
}
const adapter = getOrgAdapter<O>(ctx.context, options as O);
const userOrganizations = await adapter.listOrganizations(user.id);
const hasReachedOrgLimit =
typeof options.organizationLimit === "number"
? userOrganizations.length >= options.organizationLimit
: typeof options.organizationLimit === "function"
? await options.organizationLimit(user)
: false;
if (hasReachedOrgLimit) {
throw new APIError("FORBIDDEN", {
message:
ORGANIZATION_ERROR_CODES.YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS,
});
}
const existingOrganization = await adapter.findOrganizationBySlug(
ctx.body.slug,
);
if (existingOrganization) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_ALREADY_EXISTS,
});
}
let {
keepCurrentActiveOrganization: _,
userId: __,
...orgData
} = ctx.body;
if (options.organizationCreation?.beforeCreate) {
const response = await options.organizationCreation.beforeCreate(
{
organization: {
...orgData,
createdAt: new Date(),
},
user,
},
ctx.request,
);
if (response && typeof response === "object" && "data" in response) {
orgData = {
...ctx.body,
...response.data,
};
}
}
if (options?.organizationHooks?.beforeCreateOrganization) {
const response =
await options?.organizationHooks.beforeCreateOrganization({
organization: orgData,
user,
});
if (response && typeof response === "object" && "data" in response) {
orgData = {
...ctx.body,
...response.data,
};
}
}
const organization = await adapter.createOrganization({
organization: {
...orgData,
createdAt: new Date(),
},
});
let member:
| (Member & InferAdditionalFieldsFromPluginOptions<"member", O, false>)
| undefined;
let teamMember: TeamMember | null = null;
let data = {
userId: user.id,
organizationId: organization.id,
role: ctx.context.orgOptions.creatorRole || "owner",
};
if (options?.organizationHooks?.beforeAddMember) {
const response = await options?.organizationHooks.beforeAddMember({
member: {
userId: user.id,
organizationId: organization.id,
role: ctx.context.orgOptions.creatorRole || "owner",
},
user,
organization,
});
if (response && typeof response === "object" && "data" in response) {
data = {
...data,
...response.data,
};
}
}
member = await adapter.createMember(data);
if (options?.organizationHooks?.afterAddMember) {
await options?.organizationHooks.afterAddMember({
member,
user,
organization,
});
}
if (
options?.teams?.enabled &&
options.teams.defaultTeam?.enabled !== false
) {
let teamData = {
organizationId: organization.id,
name: `${organization.name}`,
createdAt: new Date(),
};
if (options?.organizationHooks?.beforeCreateTeam) {
const response = await options?.organizationHooks.beforeCreateTeam({
team: {
organizationId: organization.id,
name: `${organization.name}`,
},
user,
organization,
});
if (response && typeof response === "object" && "data" in response) {
teamData = {
...teamData,
...response.data,
};
}
}
const defaultTeam =
(await options.teams.defaultTeam?.customCreateDefaultTeam?.(
organization,
ctx.request,
)) || (await adapter.createTeam(teamData));
teamMember = await adapter.findOrCreateTeamMember({
teamId: defaultTeam.id,
userId: user.id,
});
if (options?.organizationHooks?.afterCreateTeam) {
await options?.organizationHooks.afterCreateTeam({
team: defaultTeam,
user,
organization,
});
}
}
if (options.organizationCreation?.afterCreate) {
await options.organizationCreation.afterCreate(
{
organization,
user,
member,
},
ctx.request,
);
}
if (options?.organizationHooks?.afterCreateOrganization) {
await options?.organizationHooks.afterCreateOrganization({
organization,
user,
member,
});
}
if (ctx.context.session && !ctx.body.keepCurrentActiveOrganization) {
await adapter.setActiveOrganization(
ctx.context.session.session.token,
organization.id,
ctx,
);
}
if (
teamMember &&
ctx.context.session &&
!ctx.body.keepCurrentActiveOrganization
) {
await adapter.setActiveTeam(
ctx.context.session.session.token,
teamMember.teamId,
ctx,
);
}
return ctx.json({
...organization,
metadata:
organization.metadata && typeof organization.metadata === "string"
? JSON.parse(organization.metadata)
: organization.metadata,
members: [member],
});
},
);
};
export const checkOrganizationSlug = <O extends OrganizationOptions>(
options: O,
) =>
createAuthEndpoint(
"/organization/check-slug",
{
method: "POST",
body: z.object({
slug: z.string().meta({
description: 'The organization slug to check. Eg: "my-org"',
}),
}),
use: [requestOnlySessionMiddleware, orgMiddleware],
},
async (ctx) => {
const orgAdapter = getOrgAdapter<O>(ctx.context, options);
const org = await orgAdapter.findOrganizationBySlug(ctx.body.slug);
if (!org) {
return ctx.json({
status: true,
});
}
throw new APIError("BAD_REQUEST", {
message: "slug is taken",
});
},
);
export const updateOrganization = <O extends OrganizationOptions>(
options?: O,
) => {
const additionalFieldsSchema = toZodSchema({
fields: options?.schema?.organization?.additionalFields || {},
isClientSide: true,
});
type Body = {
data: {
name?: string;
slug?: string;
logo?: string;
metadata?: Record<string, any>;
} & Partial<InferAdditionalFieldsFromPluginOptions<"organization", O>>;
organizationId?: string | undefined;
};
return createAuthEndpoint(
"/organization/update",
{
method: "POST",
body: z.object({
data: z
.object({
...additionalFieldsSchema.shape,
name: z
.string()
.min(1)
.meta({
description: "The name of the organization",
})
.optional(),
slug: z
.string()
.min(1)
.meta({
description: "The slug of the organization",
})
.optional(),
logo: z
.string()
.meta({
description: "The logo of the organization",
})
.optional(),
metadata: z
.record(z.string(), z.any())
.meta({
description: "The metadata of the organization",
})
.optional(),
})
.partial(),
organizationId: z
.string()
.meta({
description: 'The organization ID. Eg: "org-id"',
})
.optional(),
}),
requireHeaders: true,
use: [orgMiddleware],
metadata: {
$Infer: {
body: {} as Body,
},
openapi: {
description: "Update an organization",
responses: {
"200": {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
description: "The updated organization",
$ref: "#/components/schemas/Organization",
},
},
},
},
},
},
},
},
async (ctx) => {
const session = await ctx.context.getSession(ctx);
if (!session) {
throw new APIError("UNAUTHORIZED", {
message: "User not found",
});
}
const organizationId =
ctx.body.organizationId || session.session.activeOrganizationId;
if (!organizationId) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
});
}
const adapter = getOrgAdapter<O>(ctx.context, options);
const member = await adapter.findMemberByOrgId({
userId: session.user.id,
organizationId: organizationId,
});
if (!member) {
throw new APIError("BAD_REQUEST", {
message:
ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION,
});
}
const canUpdateOrg = await hasPermission(
{
permissions: {
organization: ["update"],
},
role: member.role,
options: ctx.context.orgOptions,
organizationId,
},
ctx,
);
if (!canUpdateOrg) {
throw new APIError("FORBIDDEN", {
message:
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION,
});
}
// Check if slug is being updated and validate uniqueness
if (typeof ctx.body.data.slug === "string") {
const existingOrganization = await adapter.findOrganizationBySlug(
ctx.body.data.slug,
);
if (
existingOrganization &&
existingOrganization.id !== organizationId
) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_SLUG_ALREADY_TAKEN,
});
}
}
if (options?.organizationHooks?.beforeUpdateOrganization) {
const response =
await options.organizationHooks.beforeUpdateOrganization({
organization: ctx.body.data,
user: session.user,
member,
});
if (response && typeof response === "object" && "data" in response) {
ctx.body.data = {
...ctx.body.data,
...response.data,
};
}
}
const updatedOrg = await adapter.updateOrganization(
organizationId,
ctx.body.data,
);
if (options?.organizationHooks?.afterUpdateOrganization) {
await options.organizationHooks.afterUpdateOrganization({
organization: updatedOrg,
user: session.user,
member,
});
}
return ctx.json(updatedOrg);
},
);
};
export const deleteOrganization = <O extends OrganizationOptions>(
options: O,
) => {
return createAuthEndpoint(
"/organization/delete",
{
method: "POST",
body: z.object({
organizationId: z.string().meta({
description: "The organization id to delete",
}),
}),
requireHeaders: true,
use: [orgMiddleware],
metadata: {
openapi: {
description: "Delete an organization",
responses: {
"200": {
description: "Success",
content: {
"application/json": {
schema: {
type: "string",
description: "The organization id that was deleted",
},
},
},
},
},
},
},
},
async (ctx) => {
const disableOrganizationDeletion =
ctx.context.orgOptions.organizationDeletion?.disabled ||
ctx.context.orgOptions.disableOrganizationDeletion;
if (disableOrganizationDeletion) {
if (ctx.context.orgOptions.organizationDeletion?.disabled) {
ctx.context.logger.info(
"`organizationDeletion.disabled` is deprecated. Use `disableOrganizationDeletion` instead",
);
}
throw new APIError("NOT_FOUND", {
message: "Organization deletion is disabled",
});
}
const session = await ctx.context.getSession(ctx);
if (!session) {
throw new APIError("UNAUTHORIZED", { status: 401 });
}
const organizationId = ctx.body.organizationId;
if (!organizationId) {
return ctx.json(null, {
status: 400,
body: {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
},
});
}
const adapter = getOrgAdapter<O>(ctx.context, options);
const member = await adapter.findMemberByOrgId({
userId: session.user.id,
organizationId: organizationId,
});
if (!member) {
throw new APIError("BAD_REQUEST", {
message:
ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION,
});
}
const canDeleteOrg = await hasPermission(
{
role: member.role,
permissions: {
organization: ["delete"],
},
organizationId,
options: ctx.context.orgOptions,
},
ctx,
);
if (!canDeleteOrg) {
throw new APIError("FORBIDDEN", {
message:
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION,
});
}
if (organizationId === session.session.activeOrganizationId) {
/**
* If the organization is deleted, we set the active organization to null
*/
await adapter.setActiveOrganization(session.session.token, null, ctx);
}
const org = await adapter.findOrganizationById(organizationId);
if (!org) {
throw new APIError("BAD_REQUEST");
}
if (options?.organizationHooks?.beforeDeleteOrganization) {
await options.organizationHooks.beforeDeleteOrganization({
organization: org,
user: session.user,
});
}
await adapter.deleteOrganization(organizationId);
if (options?.organizationHooks?.afterDeleteOrganization) {
await options.organizationHooks.afterDeleteOrganization({
organization: org,
user: session.user,
});
}
return ctx.json(org);
},
);
};
export const getFullOrganization = <O extends OrganizationOptions>(
options: O,
) =>
createAuthEndpoint(
"/organization/get-full-organization",
{
method: "GET",
query: z.optional(
z.object({
organizationId: z
.string()
.meta({
description: "The organization id to get",
})
.optional(),
organizationSlug: z
.string()
.meta({
description: "The organization slug to get",
})
.optional(),
membersLimit: z
.number()
.or(z.string().transform((val) => parseInt(val)))
.meta({
description:
"The limit of members to get. By default, it uses the membershipLimit option which defaults to 100.",
})
.optional(),
}),
),
requireHeaders: true,
use: [orgMiddleware, orgSessionMiddleware],
metadata: {
openapi: {
description: "Get the full organization",
responses: {
"200": {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
description: "The organization",
$ref: "#/components/schemas/Organization",
},
},
},
},
},
},
},
},
async (ctx) => {
const session = ctx.context.session;
const organizationId =
ctx.query?.organizationSlug ||
ctx.query?.organizationId ||
session.session.activeOrganizationId;
// return null if no organization is found to avoid erroring since this is a usual scenario
if (!organizationId) {
return ctx.json(null, {
status: 200,
});
}
const adapter = getOrgAdapter<O>(ctx.context, options);
const organization = await adapter.findFullOrganization({
organizationId,
isSlug: !!ctx.query?.organizationSlug,
includeTeams: ctx.context.orgOptions.teams?.enabled,
membersLimit: ctx.query?.membersLimit,
});
if (!organization) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
});
}
const isMember = await adapter.checkMembership({
userId: session.user.id,
organizationId: organization.id,
});
if (!isMember) {
await adapter.setActiveOrganization(session.session.token, null, ctx);
throw new APIError("FORBIDDEN", {
message:
ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION,
});
}
type OrganizationReturn = O["teams"] extends { enabled: true }
? {
members: InferMember<O>[];
invitations: InferInvitation<O>[];
teams: Team[];
} & InferOrganization<O>
: {
members: InferMember<O>[];
invitations: InferInvitation<O>[];
} & InferOrganization<O>;
return ctx.json(organization as unknown as OrganizationReturn);
},
);
export const setActiveOrganization = <O extends OrganizationOptions>(
options: O,
) => {
return createAuthEndpoint(
"/organization/set-active",
{
method: "POST",
body: z.object({
organizationId: z
.string()
.meta({
description:
'The organization id to set as active. It can be null to unset the active organization. Eg: "org-id"',
})
.nullable()
.optional(),
organizationSlug: z
.string()
.meta({
description:
'The organization slug to set as active. It can be null to unset the active organization if organizationId is not provided. Eg: "org-slug"',
})
.optional(),
}),
use: [orgSessionMiddleware, orgMiddleware],
metadata: {
openapi: {
description: "Set the active organization",
responses: {
"200": {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
description: "The organization",
$ref: "#/components/schemas/Organization",
},
},
},
},
},
},
},
},
async (ctx) => {
const adapter = getOrgAdapter<O>(ctx.context, options);
const session = ctx.context.session;
let organizationId = ctx.body.organizationId;
let organizationSlug = ctx.body.organizationSlug;
if (organizationId === null) {
const sessionOrgId = session.session.activeOrganizationId;
if (!sessionOrgId) {
return ctx.json(null);
}
const updatedSession = await adapter.setActiveOrganization(
session.session.token,
null,
ctx,
);
await setSessionCookie(ctx, {
session: updatedSession,
user: session.user,
});
return ctx.json(null);
}
if (!organizationId && !organizationSlug) {
const sessionOrgId = session.session.activeOrganizationId;
if (!sessionOrgId) {
return ctx.json(null);
}
organizationId = sessionOrgId;
}
if (organizationSlug && !organizationId) {
const organization =
await adapter.findOrganizationBySlug(organizationSlug);
if (!organization) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
});
}
organizationId = organization.id;
}
if (!organizationId) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
});
}
const isMember = await adapter.checkMembership({
userId: session.user.id,
organizationId,
});
if (!isMember) {
await adapter.setActiveOrganization(session.session.token, null, ctx);
throw new APIError("FORBIDDEN", {
message:
ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION,
});
}
let organization = await adapter.findOrganizationById(organizationId);
if (!organization) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
});
}
const updatedSession = await adapter.setActiveOrganization(
session.session.token,
organization.id,
ctx,
);
await setSessionCookie(ctx, {
session: updatedSession,
user: session.user,
});
type OrganizationReturn = O["teams"] extends { enabled: true }
? {
members: InferMember<O>[];
invitations: InferInvitation<O>[];
teams: Team[];
} & InferOrganization<O>
: {
members: InferMember<O>[];
invitations: InferInvitation<O>[];
} & InferOrganization<O>;
return ctx.json(organization as unknown as OrganizationReturn);
},
);
};
export const listOrganizations = <O extends OrganizationOptions>(options: O) =>
createAuthEndpoint(
"/organization/list",
{
method: "GET",
use: [orgMiddleware, orgSessionMiddleware],
metadata: {
openapi: {
description: "List all organizations",
responses: {
"200": {
description: "Success",
content: {
"application/json": {
schema: {
type: "array",
items: {
$ref: "#/components/schemas/Organization",
},
},
},
},
},
},
},
},
},
async (ctx) => {
const adapter = getOrgAdapter<O>(ctx.context, options);
const organizations = await adapter.listOrganizations(
ctx.context.session.user.id,
);
return ctx.json(organizations);
},
);
```
--------------------------------------------------------------------------------
/docs/components/builder/index.tsx:
--------------------------------------------------------------------------------
```typescript
import { useAtom } from "jotai";
import { Moon, PlusIcon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { useState } from "react";
import { cn } from "@/lib/utils";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "../ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import { Label } from "../ui/label";
import { ScrollArea } from "../ui/scroll-area";
import { Separator } from "../ui/separator";
import { Switch } from "../ui/switch";
import CodeTabs from "./code-tabs";
import SignIn from "./sign-in";
import { SignUp } from "./sign-up";
import { socialProviders } from "./social-provider";
import { optionsAtom } from "./store";
import { AuthTabs } from "./tabs";
const frameworks = [
{
title: "Next.js",
description: "The React Framework for Production",
Icon: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="2em"
height="2em"
viewBox="0 0 15 15"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M0 7.5a7.5 7.5 0 1 1 11.698 6.216L4.906 4.21A.5.5 0 0 0 4 4.5V12h1V6.06l5.83 8.162A7.5 7.5 0 0 1 0 7.5M10 10V4h1v6z"
clipRule="evenodd"
></path>
</svg>
),
},
{
title: "Nuxt",
description: "The Intuitive Vue Framework",
Icon: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="2em"
height="2em"
viewBox="0 0 256 256"
>
<g fill="none">
<rect width="256" height="256" fill="#242938" rx="60"></rect>
<path
fill="#00DC82"
d="M138.787 189.333h68.772c2.184.001 4.33-.569 6.222-1.652a12.4 12.4 0 0 0 4.554-4.515a12.24 12.24 0 0 0-.006-12.332l-46.185-79.286a12.4 12.4 0 0 0-4.553-4.514a12.53 12.53 0 0 0-12.442 0a12.4 12.4 0 0 0-4.553 4.514l-11.809 20.287l-23.09-39.67a12.4 12.4 0 0 0-4.555-4.513a12.54 12.54 0 0 0-12.444 0a12.4 12.4 0 0 0-4.555 4.513L36.67 170.834a12.24 12.24 0 0 0-.005 12.332a12.4 12.4 0 0 0 4.554 4.515a12.5 12.5 0 0 0 6.222 1.652h43.17c17.104 0 29.718-7.446 38.397-21.973l21.072-36.169l11.287-19.356l33.873 58.142h-45.16zm-48.88-19.376l-30.127-.007l45.16-77.518l22.533 38.759l-15.087 25.906c-5.764 9.426-12.312 12.86-22.48 12.86"
></path>
</g>
</svg>
),
},
{
title: "SvelteKit",
description: "Web development for the rest of us",
Icon: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="2em"
height="2em"
viewBox="0 0 256 256"
>
<g fill="none">
<rect width="256" height="256" fill="#FF3E00" rx="60"></rect>
<g clipPath="url(#skillIconsSvelte0)">
<path
fill="#fff"
d="M193.034 61.797c-16.627-23.95-49.729-30.966-73.525-15.865L77.559 72.78c-11.44 7.17-19.372 18.915-21.66 32.186c-1.984 11.136-.306 22.576 5.033 32.492c-3.66 5.491-6.102 11.593-7.17 18c-2.44 13.576.764 27.61 8.696 38.745c16.78 23.95 49.728 30.966 73.525 15.865l41.949-26.695c11.441-7.17 19.373-18.915 21.661-32.187c1.983-11.135.305-22.576-5.034-32.491c3.661-5.492 6.102-11.593 7.17-18c2.593-13.729-.61-27.763-8.695-38.898"
></path>
<path
fill="#FF3E00"
d="M115.39 196.491a33.25 33.25 0 0 1-35.695-13.271c-4.881-6.712-6.712-15.101-5.34-23.339c.306-1.373.611-2.593.916-3.966l.763-2.44L78.169 155a55.6 55.6 0 0 0 16.475 8.237l1.525.458l-.152 1.525c-.153 2.136.458 4.424 1.678 6.255c2.441 3.508 6.712 5.186 10.83 4.118c.916-.305 1.831-.61 2.594-1.068l41.796-26.695c2.136-1.372 3.509-3.355 3.966-5.796s-.152-5.034-1.525-7.017c-2.441-3.509-6.712-5.034-10.831-3.966c-.915.305-1.83.61-2.593 1.068l-16.017 10.22c-2.593 1.678-5.491 2.898-8.542 3.661a33.25 33.25 0 0 1-35.695-13.271c-4.729-6.712-6.712-15.102-5.186-23.339c1.372-7.932 6.254-15.102 13.118-19.373l41.949-26.695c2.593-1.678 5.492-2.898 8.543-3.814a33.25 33.25 0 0 1 35.695 13.272c4.881 6.712 6.711 15.101 5.339 23.339c-.306 1.373-.611 2.593-1.068 3.966l-.763 2.44l-2.136-1.525a55.6 55.6 0 0 0-16.474-8.237l-1.526-.458l.153-1.525c.153-2.136-.458-4.424-1.678-6.255c-2.441-3.508-6.712-5.034-10.83-3.966c-.916.305-1.831.61-2.594 1.068l-41.796 26.695c-2.136 1.373-3.509 3.356-3.966 5.797s.152 5.034 1.525 7.017c2.441 3.508 6.712 5.033 10.831 3.966c.915-.305 1.83-.611 2.593-1.068l16.017-10.22c2.593-1.678 5.491-2.899 8.542-3.814a33.25 33.25 0 0 1 35.695 13.271c4.881 6.712 6.712 15.102 5.339 23.339c-1.373 7.932-6.254 15.102-13.119 19.373l-41.949 26.695c-2.593 1.678-5.491 2.898-8.542 3.813"
></path>
</g>
<defs>
<clipPath id="skillIconsSvelte0">
<path fill="#fff" d="M53 38h149.644v180H53z"></path>
</clipPath>
</defs>
</g>
</svg>
),
},
{
title: "SolidStart",
description: "Fine-grained reactivity goes fullstack",
Icon: () => (
<svg
data-hk="00000010210"
width="2em"
height="2em"
viewBox="0 0 500 500"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="presentation"
>
<path
d="M233.205 430.856L304.742 425.279C304.742 425.279 329.208 421.295 343.569 397.659L293.041 385.443L233.205 430.856Z"
fill="url(#paint0_linear_1_2)"
></path>
<path
d="M134.278 263.278C113.003 264.341 73.6443 268.059 73.6443 268.059L245.173 392.614L284.265 402.44L343.569 397.925L170.977 273.105C170.977 273.105 157.148 263.278 137.203 263.278C136.139 263.278 135.342 263.278 134.278 263.278Z"
fill="url(#paint1_linear_1_2)"
></path>
<path
d="M355.536 238.58L429.2 234.065C429.2 234.065 454.464 230.348 468.825 206.977L416.435 193.964L355.536 238.58Z"
fill="url(#paint2_linear_1_2)"
></path>
<path
d="M251.289 68.6128C229.217 69.4095 188.795 72.5964 188.795 72.5964L367.503 200.072L407.926 210.429L469.09 206.712L289.318 78.9702C289.318 78.9702 274.426 68.6128 253.417 68.6128C252.885 68.6128 252.087 68.6128 251.289 68.6128Z"
fill="url(#paint3_linear_1_2)"
></path>
<path
d="M31.0946 295.679C30.8287 295.945 30.8287 296.21 30.8287 296.475L77.8993 330.469L202.623 420.764C228.95 439.62 264.586 431.653 282.67 402.44L187.465 333.921L110.077 277.62C100.504 270.715 89.8663 267.528 79.2289 267.528C60.6134 267.528 42.2639 277.354 31.0946 295.679Z"
fill="url(#paint4_linear_1_2)"
></path>
<path
d="M147.043 99.9505C147.043 100.216 146.776 100.482 146.511 100.747L195.442 135.538L244.374 170.062L325.751 227.957C353.142 247.345 389.841 239.642 407.925 210.695L358.461 175.374L308.997 140.318L228.153 82.6881C218.047 75.5177 206.611 72.0652 195.442 72.0652C176.561 72.3308 158.212 81.8915 147.043 99.9505Z"
fill="url(#paint5_linear_1_2)"
></path>
<path
d="M112.471 139.255L175.497 208.305C178.423 212.289 181.614 216.006 185.337 219.193L308.199 354.105L369.364 350.387C387.448 321.439 380.002 282.135 352.611 262.748L271.234 204.852L222.568 170.328L173.636 135.538L112.471 139.255Z"
fill="url(#paint6_linear_1_2)"
></path>
<path
d="M111.939 140.052C94.1213 168.734 101.567 207.509 128.427 226.629L209.005 283.994L258.735 319.049L308.199 354.105C326.283 325.158 318.836 285.852 291.445 266.465L112.471 139.255C112.471 139.521 112.204 139.787 111.939 140.052Z"
fill="url(#paint7_linear_1_2)"
></path>
<defs>
<linearGradient
id="paint0_linear_1_2"
x1="359.728"
y1="56.8062"
x2="265.623"
y2="521.28"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#1593F5"></stop>
<stop offset="1" stopColor="#0084CE"></stop>
</linearGradient>
<linearGradient
id="paint1_linear_1_2"
x1="350.496"
y1="559.872"
x2="-44.0802"
y2="-73.2062"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#1593F5"></stop>
<stop offset="1" stopColor="#0084CE"></stop>
</linearGradient>
<linearGradient
id="paint2_linear_1_2"
x1="610.25"
y1="570.526"
x2="372.635"
y2="144.034"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white"></stop>
<stop offset="1" stopColor="#15ABFF"></stop>
</linearGradient>
<linearGradient
id="paint3_linear_1_2"
x1="188.808"
y1="-180.608"
x2="390.515"
y2="281.703"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white"></stop>
<stop offset="1" stopColor="#79CFFF"></stop>
</linearGradient>
<linearGradient
id="paint4_linear_1_2"
x1="415.84"
y1="-4.74684"
x2="95.1922"
y2="439.83"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#0057E5"></stop>
<stop offset="1" stopColor="#0084CE"></stop>
</linearGradient>
<linearGradient
id="paint5_linear_1_2"
x1="343.141"
y1="-21.5427"
x2="242.301"
y2="256.708"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white"></stop>
<stop offset="1" stopColor="#15ABFF"></stop>
</linearGradient>
<linearGradient
id="paint6_linear_1_2"
x1="469.095"
y1="533.421"
x2="-37.6939"
y2="-135.731"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white"></stop>
<stop offset="1" stopColor="#79CFFF"></stop>
</linearGradient>
<linearGradient
id="paint7_linear_1_2"
x1="380.676"
y1="-89.0869"
x2="120.669"
y2="424.902"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white"></stop>
<stop offset="1" stopColor="#79CFFF"></stop>
</linearGradient>
</defs>
</svg>
),
},
];
export function Builder() {
const [currentStep, setCurrentStep] = useState(0);
const [options, setOptions] = useAtom(optionsAtom);
const { setTheme, resolvedTheme } = useTheme();
return (
<Dialog>
<DialogTrigger asChild>
<button className="bg-stone-950 no-underline group cursor-pointer relative p-px text-xs font-semibold leading-6 text-white md:inline-block hidden">
<span className="absolute inset-0 overflow-hidden rounded-sm">
<span className="absolute inset-0 rounded-sm bg-[image:radial-gradient(75%_100%_at_50%_0%,rgba(56,189,248,0.6)_0%,rgba(56,189,248,0)_75%)] opacity-0 transition-opacity duration-500 group-hover:opacity-100"></span>
</span>
<div className="relative flex space-x-2 items-center z-10 rounded-none bg-zinc-950 py-2 px-4 ring-1 ring-white/10 ">
<PlusIcon size={14} />
<span>Create Sign in Box</span>
</div>
<span className="absolute -bottom-0 left-[1.125rem] h-px w-[calc(100%-2.25rem)] bg-gradient-to-r from-emerald-400/0 via-stone-800/90 to-emerald-400/0 transition-opacity duration-500 group-hover:opacity-40"></span>
</button>
</DialogTrigger>
<DialogContent className="max-w-7xl h-5/6 overflow-clip !rounded-none">
<DialogHeader>
<DialogTitle>Create Sign in Box</DialogTitle>
<DialogDescription>
Configure the sign in box to your liking and copy the code to your
application.
</DialogDescription>
</DialogHeader>
<div className="flex gap-4 md:gap-12 flex-col md:flex-row items-center md:items-start">
<div className={cn("w-4/12")}>
<div
className="overflow-scroll h-[580px] relative"
style={{
scrollbarWidth: "none",
scrollbarColor: "transparent transparent",
//@ts-expect-error
"&::-webkit-scrollbar": {
display: "none",
},
}}
>
{options.signUp ? (
<AuthTabs
tabs={[
{
title: "Sign In",
value: "sign-in",
content: <SignIn />,
},
{
title: "Sign Up",
value: "sign-up",
content: <SignUp />,
},
]}
/>
) : (
<SignIn />
)}
</div>
</div>
<ScrollArea
className="w-[45%] flex-grow"
style={{
scrollbarWidth: "none",
scrollbarColor: "transparent transparent",
//@ts-expect-error
"&::-webkit-scrollbar": {
display: "none",
},
}}
>
<div className="h-[580px]">
{currentStep === 0 ? (
<Card className="rounded-none flex-grow h-full">
<CardHeader className="flex flex-row justify-between">
<CardTitle>Configuration</CardTitle>
<div
className="cursor-pointer"
onClick={() => {
if (resolvedTheme === "dark") {
setTheme("light");
} else {
setTheme("dark");
}
}}
>
{resolvedTheme === "dark" ? (
<Moon onClick={() => setTheme("light")} size={18} />
) : (
<Sun onClick={() => setTheme("dark")} size={18} />
)}
</div>
</CardHeader>
<CardContent className="max-h-[400px] overflow-scroll">
<div className="flex flex-col gap-2">
<div>
<Label>Email & Password</Label>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center">
<Label
className="cursor-pointer"
htmlFor="email-provider-email"
>
Enabled
</Label>
</div>
<Switch
id="email-provider-email"
checked={options.email}
onCheckedChange={(checked) => {
setOptions((prev) => ({
...prev,
email: checked,
magicLink: checked ? false : prev.magicLink,
signUp: checked,
}));
}}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Label
className="cursor-pointer"
htmlFor="email-provider-remember-me"
>
Remember Me
</Label>
</div>
<Switch
id="email-provider-remember-me"
checked={options.rememberMe}
onCheckedChange={(checked) => {
setOptions((prev) => ({
...prev,
rememberMe: checked,
}));
}}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Label
className="cursor-pointer"
htmlFor="email-provider-forget-password"
>
Forget Password
</Label>
</div>
<Switch
id="email-provider-forget-password"
checked={options.requestPasswordReset}
onCheckedChange={(checked) => {
setOptions((prev) => ({
...prev,
requestPasswordReset: checked,
}));
}}
/>
</div>
</div>
<div className="flex flex-col gap-2 mt-4">
<div>
<Label>Social Providers</Label>
</div>
<Separator />
{Object.entries(socialProviders).map(
([provider, { Icon }]) => (
<div
className="flex items-center justify-between"
key={provider}
>
<div className="flex items-center gap-2">
<Icon />
<Label
className="cursor-pointer"
htmlFor={"social-provider".concat(
"-",
provider,
)}
>
{provider.charAt(0).toUpperCase() +
provider.slice(1)}
</Label>
</div>
<Switch
id={"social-provider".concat("-", provider)}
checked={options.socialProviders.includes(
provider,
)}
onCheckedChange={(checked) => {
setOptions((prev) => ({
...prev,
socialProviders: checked
? [...prev.socialProviders, provider]
: prev.socialProviders.filter(
(p) => p !== provider,
),
}));
}}
/>
</div>
),
)}
</div>
<div className="flex flex-col gap-2 mt-4">
<div>
<Label>Plugins</Label>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M5 20q-.825 0-1.412-.587T3 18v-.8q0-.85.438-1.562T4.6 14.55q1.55-.775 3.15-1.162T11 13q.35 0 .7.013t.7.062q.275.025.437.213t.163.462q.05 1.175.575 2.213t1.4 1.762q.175.125.275.313t.1.412V19q0 .425-.288.713T14.35 20zm6-8q-1.65 0-2.825-1.175T7 8t1.175-2.825T11 4t2.825 1.175T15 8t-1.175 2.825T11 12m7.5 2q.425 0 .713-.288T19.5 13t-.288-.712T18.5 12t-.712.288T17.5 13t.288.713t.712.287m.15 8.65l-1-1q-.05-.05-.15-.35v-4.45q-1.1-.325-1.8-1.237T15 13.5q0-1.45 1.025-2.475T18.5 10t2.475 1.025T22 13.5q0 1.125-.638 2t-1.612 1.25l.9.9q.15.15.15.35t-.15.35l-.8.8q-.15.15-.15.35t.15.35l.8.8q.15.15.15.35t-.15.35l-1.3 1.3q-.15.15-.35.15t-.35-.15"
></path>
</svg>
<Label
className="cursor-pointer"
htmlFor="plugin-passkey"
>
Passkey
</Label>
</div>
<Switch
id="plugin-passkey"
checked={options.passkey}
onCheckedChange={(checked) => {
setOptions((prev) => ({
...prev,
passkey: checked,
}));
}}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<g fill="none">
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"></path>
<path
fill="currentColor"
d="M17.5 3a4.5 4.5 0 0 1 4.495 4.288L22 7.5V15a2 2 0 0 1-1.85 1.995L20 17h-3v3a1 1 0 0 1-1.993.117L15 20v-3H4a2 2 0 0 1-1.995-1.85L2 15V7.5a4.5 4.5 0 0 1 4.288-4.495L6.5 3zm-11 2A2.5 2.5 0 0 0 4 7.5V15h5V7.5A2.5 2.5 0 0 0 6.5 5M7 8a1 1 0 0 1 .117 1.993L7 10H6a1 1 0 0 1-.117-1.993L6 8z"
></path>
</g>
</svg>
<Label
className="cursor-pointer"
htmlFor="plugin-otp-magic-link"
>
Magic Link
</Label>
</div>
<Switch
id="plugin-otp-magic-link"
checked={options.magicLink}
onCheckedChange={(checked) => {
setOptions((prev) => ({
...prev,
magicLink: checked,
email: checked ? false : prev.email,
signUp: checked ? false : prev.signUp,
}));
}}
/>
</div>
</div>
<div className="mt-4">
<Separator />
<div className="flex items-center justify-between mt-2">
<Label
className="cursor-pointer"
htmlFor="label-powered-by"
>
Show Built with label
</Label>
<Switch
id="label-powered-by"
checked={options.label}
onCheckedChange={(checked) => {
setOptions((prev) => ({
...prev,
label: checked,
}));
}}
/>
</div>
</div>
</CardContent>
<CardFooter>
<button
className="bg-stone-950 no-underline group cursor-pointer relative shadow-2xl shadow-zinc-900 rounded-sm p-px text-xs font-semibold leading-6 text-white inline-block w-full"
onClick={() => {
setCurrentStep(currentStep + 1);
}}
>
<span className="absolute inset-0 overflow-hidden rounded-sm">
<span className="absolute inset-0 rounded-sm bg-[image:radial-gradient(75%_100%_at_50%_0%,rgba(56,189,248,0.6)_0%,rgba(56,189,248,0)_75%)] opacity-0 transition-opacity duration-500 group-hover:opacity-100"></span>
</span>
<div className="relative flex space-x-2 items-center z-10 rounded-none bg-zinc-950 py-2 px-4 ring-1 ring-white/10 justify-center">
<span>Continue</span>
</div>
<span className="absolute -bottom-0 left-[1.125rem] h-px w-[calc(100%-2.25rem)] bg-gradient-to-r from-emerald-400/0 via-stone-800/90 to-emerald-400/0 transition-opacity duration-500 group-hover:opacity-40"></span>
</button>
</CardFooter>
</Card>
) : currentStep === 1 ? (
<Card className="rounded-none flex-grow h-full">
<CardHeader>
<CardTitle>Choose Framework</CardTitle>
<p
className="text-blue-400 hover:underline mt-1 text-sm cursor-pointer"
onClick={() => {
setCurrentStep(0);
}}
>
Go Back
</p>
</CardHeader>
<CardContent className="flex items-start gap-2 flex-wrap justify-between">
{frameworks.map((fm) => (
<div
onClick={() => {
if (fm.title === "Next.js") {
setCurrentStep(currentStep + 1);
}
}}
className={cn(
"flex flex-col items-center gap-4 border p-6 rounded-md w-5/12 flex-grow h-44 relative",
fm.title !== "Next.js"
? "opacity-55"
: "hover:ring-1 transition-all ring-border hover:bg-background duration-200 ease-in-out cursor-pointer",
)}
key={fm.title}
>
{fm.title !== "Next.js" && (
<span className="absolute top-4 right-4 text-xs">
Coming Soon
</span>
)}
<fm.Icon />
<Label className="text-2xl">{fm.title}</Label>
<p className="text-sm">{fm.description}</p>
</div>
))}
</CardContent>
</Card>
) : (
<Card className="rounded-none w-full overflow-y-hidden h-full overflow-auto">
<CardHeader>
<div className="flex flex-col -mb-2 items-start">
<CardTitle>Code</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-2 items-baseline">
<p>
Copy the code below and paste it in your application to
get started.
</p>
<p
className="text-blue-400 hover:underline mt-1 text-sm cursor-pointer"
onClick={() => {
setCurrentStep(0);
}}
>
Go Back
</p>
</div>
<div>
<CodeTabs />
</div>
</CardContent>
</Card>
)}
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
);
}
```
--------------------------------------------------------------------------------
/packages/better-auth/src/plugins/email-otp/email-otp.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, expect, it, vi } from "vitest";
import { createAuthClient } from "../../client";
import { getTestInstance } from "../../test-utils/test-instance";
import { bearer } from "../bearer";
import { emailOTP } from ".";
import { emailOTPClient } from "./client";
import { splitAtLastColon } from "./utils";
describe("email-otp", async () => {
const otpFn = vi.fn();
let otp = "";
const { client, testUser, auth } = await getTestInstance(
{
plugins: [
bearer(),
emailOTP({
async sendVerificationOTP({ email, otp: _otp, type }) {
otp = _otp;
otpFn(email, _otp, type);
},
sendVerificationOnSignUp: true,
}),
],
emailVerification: {
autoSignInAfterVerification: true,
},
},
{
clientOptions: {
plugins: [emailOTPClient()],
},
},
);
it("should verify email with otp", async () => {
const res = await client.emailOtp.sendVerificationOtp({
email: testUser.email,
type: "email-verification",
});
expect(res.data?.success).toBe(true);
expect(otp.length).toBe(6);
expect(otpFn).toHaveBeenCalledWith(
testUser.email,
otp,
"email-verification",
);
const verifiedUser = await client.emailOtp.verifyEmail({
email: testUser.email,
otp,
});
expect(verifiedUser.data?.status).toBe(true);
});
it("should sign-in with otp", async () => {
const res = await client.emailOtp.sendVerificationOtp({
email: testUser.email,
type: "sign-in",
});
expect(res.data?.success).toBe(true);
expect(otp.length).toBe(6);
expect(otpFn).toHaveBeenCalledWith(testUser.email, otp, "sign-in");
const verifiedUser = await client.signIn.emailOtp(
{
email: testUser.email,
otp,
},
{
onSuccess: (ctx) => {
const header = ctx.response.headers.get("set-cookie");
expect(header).toContain("better-auth.session_token");
},
},
);
expect(verifiedUser.data?.token).toBeDefined();
});
it("should sign-up with otp", async () => {
const testUser2 = {
email: "[email protected]",
};
await client.emailOtp.sendVerificationOtp({
email: testUser2.email,
type: "sign-in",
});
const newUser = await client.signIn.emailOtp(
{
email: testUser2.email,
otp,
},
{
onSuccess: (ctx) => {
const header = ctx.response.headers.get("set-cookie");
expect(header).toContain("better-auth.session_token");
},
},
);
expect(newUser.data?.token).toBeDefined();
});
it("should send verification otp on sign-up", async () => {
const testUser2 = {
email: "[email protected]",
password: "password",
name: "test",
};
await client.signUp.email(testUser2);
expect(otpFn).toHaveBeenCalledWith(
testUser2.email,
otp,
"email-verification",
);
});
it("should send forget password otp", async () => {
await client.emailOtp.sendVerificationOtp({
email: testUser.email,
type: "forget-password",
});
});
it("should reset password", async () => {
await client.emailOtp.resetPassword({
email: testUser.email,
otp,
password: "changed-password",
});
const { data } = await client.signIn.email({
email: testUser.email,
password: "changed-password",
});
expect(data?.user).toBeDefined();
});
it("should call onPasswordReset callback when resetting password", async () => {
const onPasswordResetMock = vi.fn();
const { client, testUser } = await getTestInstance(
{
plugins: [
bearer(),
emailOTP({
async sendVerificationOTP({ email, otp: _otp, type }) {
otp = _otp;
otpFn(email, _otp, type);
},
sendVerificationOnSignUp: true,
}),
],
emailAndPassword: {
enabled: true,
onPasswordReset: onPasswordResetMock,
},
},
{
clientOptions: {
plugins: [emailOTPClient()],
},
},
);
await client.emailOtp.sendVerificationOtp({
email: testUser.email,
type: "forget-password",
});
await client.emailOtp.resetPassword({
email: testUser.email,
otp,
password: "new-password",
});
expect(onPasswordResetMock).toHaveBeenCalledWith(
{ user: expect.objectContaining({ email: testUser.email }) },
expect.any(Object),
);
});
it("should reset password and create credential account", async () => {
const testUser2 = {
email: "[email protected]",
};
await client.emailOtp.sendVerificationOtp({
email: testUser2.email,
type: "sign-in",
});
await client.signIn.emailOtp(
{
email: testUser2.email,
otp,
},
{
onSuccess: (ctx) => {
const header = ctx.response.headers.get("set-cookie");
expect(header).toContain("better-auth.session_token");
},
},
);
await client.emailOtp.sendVerificationOtp({
email: testUser2.email,
type: "forget-password",
});
await client.emailOtp.resetPassword({
email: testUser2.email,
otp,
password: "password",
});
const res = await client.signIn.email({
email: testUser2.email,
password: "password",
});
expect(res.data?.token).toBeDefined();
});
it("should fail on invalid email", async () => {
const res = await client.emailOtp.sendVerificationOtp({
email: "invalid-email",
type: "email-verification",
});
expect(res.error?.status).toBe(400);
expect(res.error?.code).toBe("INVALID_EMAIL");
});
it("should fail on expired otp", async () => {
await client.emailOtp.sendVerificationOtp({
email: testUser.email,
type: "email-verification",
});
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(1000 * 60 * 6);
const res = await client.emailOtp.verifyEmail({
email: testUser.email,
otp,
});
expect(res.error?.status).toBe(400);
expect(res.error?.code).toBe("OTP_EXPIRED");
});
it("should not fail on time elapsed", async () => {
await client.emailOtp.sendVerificationOtp({
email: testUser.email,
type: "email-verification",
});
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(1000 * 60 * 4);
const res = await client.emailOtp.verifyEmail({
email: testUser.email,
otp,
});
const session = await client.getSession({
fetchOptions: {
headers: {
Authorization: `Bearer ${res.data?.token}`,
},
},
});
expect(res.data?.status).toBe(true);
expect(session.data?.user.emailVerified).toBe(true);
});
it("should create verification otp on server", async () => {
otp = await auth.api.createVerificationOTP({
body: {
type: "sign-in",
email: "[email protected]",
},
});
otp = await auth.api.createVerificationOTP({
body: {
type: "sign-in",
email: "[email protected]",
},
});
expect(otp.length).toBe(6);
});
it("should get verification otp on server", async () => {
const res = await auth.api.getVerificationOTP({
query: {
email: "[email protected]",
type: "sign-in",
},
});
});
it("should work with custom options", async () => {
const { client, testUser, auth } = await getTestInstance(
{
plugins: [
bearer(),
emailOTP({
async sendVerificationOTP({ email, otp: _otp, type }) {
otp = _otp;
otpFn(email, _otp, type);
},
sendVerificationOnSignUp: true,
expiresIn: 10,
otpLength: 8,
}),
],
emailVerification: {
autoSignInAfterVerification: true,
},
},
{
clientOptions: {
plugins: [emailOTPClient()],
},
},
);
await client.emailOtp.sendVerificationOtp({
type: "email-verification",
email: testUser.email,
});
expect(otp.length).toBe(8);
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(11 * 1000);
const verifyRes = await client.emailOtp.verifyEmail({
email: testUser.email,
otp,
});
expect(verifyRes.error?.code).toBe("OTP_EXPIRED");
});
});
describe("email-otp-verify", async () => {
const otpFn = vi.fn();
const otp = [""];
const { client, testUser, auth } = await getTestInstance(
{
plugins: [
emailOTP({
async sendVerificationOTP({ email, otp: _otp, type }) {
otp.push(_otp);
otpFn(email, _otp, type);
},
sendVerificationOnSignUp: true,
disableSignUp: true,
}),
],
},
{
clientOptions: {
plugins: [emailOTPClient()],
},
},
);
it("should return USER_NOT_FOUND error when disableSignUp and user not registered", async () => {
const response = await client.emailOtp.sendVerificationOtp({
email: "[email protected]",
type: "email-verification",
});
expect(response.error?.message).toBe("User not found");
// Existing user should still succeed
const successRes = await client.emailOtp.sendVerificationOtp({
email: testUser.email,
type: "email-verification",
});
expect(successRes.error).toBeFalsy();
});
it("should verify email with last otp", async () => {
await client.emailOtp.sendVerificationOtp({
email: testUser.email,
type: "email-verification",
});
await client.emailOtp.sendVerificationOtp({
email: testUser.email,
type: "email-verification",
});
await client.emailOtp.sendVerificationOtp({
email: testUser.email,
type: "email-verification",
});
});
it("should block after exceeding allowed attempts", async () => {
await client.emailOtp.sendVerificationOtp({
email: testUser.email,
type: "email-verification",
});
for (let i = 0; i < 3; i++) {
const res = await client.emailOtp.verifyEmail({
email: testUser.email,
otp: "wrong-otp",
});
expect(res.error?.status).toBe(400);
expect(res.error?.message).toBe("Invalid OTP");
}
//Try one more time - should be blocked
const res = await client.emailOtp.verifyEmail({
email: testUser.email,
otp: "000000",
});
expect(res.error?.status).toBe(403);
expect(res.error?.message).toBe("Too many attempts");
});
it("should block reset password after exceeding allowed attempts", async () => {
await client.emailOtp.sendVerificationOtp({
email: testUser.email,
type: "forget-password",
});
for (let i = 0; i < 3; i++) {
const res = await client.emailOtp.resetPassword({
email: testUser.email,
otp: "wrong-otp",
password: "new-password",
});
expect(res.error?.status).toBe(400);
expect(res.error?.message).toBe("Invalid OTP");
}
// Try one more time - should be blocked
const res = await client.emailOtp.resetPassword({
email: testUser.email,
otp: "000000",
password: "new-password",
});
expect(res.error?.status).toBe(403);
expect(res.error?.message).toBe("Too many attempts");
});
});
describe("custom rate limiting storage", async () => {
const { client, testUser } = await getTestInstance({
rateLimit: {
enabled: true,
},
plugins: [
emailOTP({
async sendVerificationOTP(data, request) {},
}),
],
});
it.each([
{
path: "/email-otp/send-verification-otp",
body: {
email: "[email protected]",
type: "sign-in",
},
},
{
path: "/sign-in/email-otp",
body: {
email: "[email protected]",
otp: "12312",
},
},
{
path: "/email-otp/verify-email",
body: {
email: "[email protected]",
otp: "12312",
},
},
])("should rate limit send verification endpoint", async ({ path, body }) => {
for (let i = 0; i < 10; i++) {
const response = await client.$fetch(path, {
method: "POST",
body,
});
if (i >= 3) {
expect(response.error?.status).toBe(429);
}
}
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(60 * 1000);
const response = await client.$fetch(path, {
method: "POST",
body,
});
expect(response.error?.status).not.toBe(429);
});
});
describe("custom generate otpFn", async () => {
const { client, testUser } = await getTestInstance(
{
plugins: [
emailOTP({
async sendVerificationOTP(data, request) {},
generateOTP(data, request) {
return "123456";
},
}),
],
},
{
clientOptions: {
plugins: [emailOTPClient()],
},
},
);
it("should generate otp", async () => {
const res = await client.emailOtp.sendVerificationOtp({
email: testUser.email,
type: "email-verification",
});
expect(res.data?.success).toBe(true);
});
it("should verify email with otp", async () => {
const res = await client.emailOtp.verifyEmail({
email: testUser.email,
otp: "123456",
});
expect(res.data?.status).toBe(true);
});
});
describe("custom storeOTP", async () => {
// Testing hashed OTPs.
describe("hashed", async () => {
let sendVerificationOtpFn = async (data: {
email: string;
otp: string;
type: "sign-in" | "email-verification" | "forget-password";
}) => {};
function getTheSentOTP() {
let gotOtp: string | null = null;
let sub = (otp: string) => {};
sendVerificationOtpFn = async (data) => {
gotOtp = data.otp;
sub(data.otp);
};
return {
get: () =>
new Promise<string>((resolve) => {
if (gotOtp) {
resolve(gotOtp);
} else {
sub = (otp) => {
gotOtp = otp;
resolve(otp);
};
}
}),
};
}
const { client, testUser, auth } = await getTestInstance(
{
plugins: [
emailOTP({
sendVerificationOTP: async (d) => {
await sendVerificationOtpFn(d);
},
storeOTP: "hashed",
}),
],
},
{
clientOptions: {
plugins: [emailOTPClient()],
},
},
);
const authCtx = await auth.$context;
const userEmail1 = `${crypto.randomUUID()}@email.com`;
let validOTP = "";
it("should create a hashed otp", async () => {
const { get } = getTheSentOTP();
await client.emailOtp.sendVerificationOtp({
email: userEmail1,
type: "sign-in",
});
const verificationValue =
await authCtx.internalAdapter.findVerificationValue(
`sign-in-otp-${userEmail1}`,
);
const storedOtp = verificationValue?.value || "";
const otp = await get();
validOTP = otp;
expect(storedOtp.length !== 0).toBe(true);
expect(splitAtLastColon(storedOtp)[0]).not.toBe(otp);
expect(storedOtp.endsWith(":0")).toBe(true);
});
it("should not be allowed to get otp if storeOTP is hashed", async () => {
try {
await auth.api.getVerificationOTP({
query: {
email: userEmail1,
type: "sign-in",
},
});
} catch (error: any) {
expect(error.statusCode).toBe(400);
expect(error.status).toBe("BAD_REQUEST");
expect(error.body.code).toBe(
"OTP_IS_HASHED_CANNOT_RETURN_THE_PLAIN_TEXT_OTP",
);
return;
}
// Should not reach here given the above should throw and thus return.
expect(true).toBe(false);
});
it("should be able to sign in with normal otp", async () => {
const res = await client.signIn.emailOtp({
email: userEmail1,
otp: validOTP,
});
expect(res.data?.user.email).toBe(userEmail1);
expect(res.data?.token).toBeDefined();
});
});
// Testing encrypted OTPs.
describe("encrypted", async () => {
let sendVerificationOtpFn = async (data: {
email: string;
otp: string;
type: "sign-in" | "email-verification" | "forget-password";
}) => {};
function getTheSentOTP() {
let gotOtp: string | null = null;
let sub = (otp: string) => {};
sendVerificationOtpFn = async (data) => {
gotOtp = data.otp;
sub(data.otp);
};
return {
get: () =>
new Promise<string>((resolve) => {
if (gotOtp) {
resolve(gotOtp);
} else {
sub = (otp) => {
gotOtp = otp;
resolve(otp);
};
}
}),
};
}
const { client, testUser, auth } = await getTestInstance(
{
plugins: [
emailOTP({
sendVerificationOTP: async (d) => {
await sendVerificationOtpFn(d);
},
storeOTP: "encrypted",
}),
],
},
{
clientOptions: {
plugins: [emailOTPClient()],
},
},
);
const authCtx = await auth.$context;
const userEmail1 = `${crypto.randomUUID()}@email.com`;
let encryptedOtp = "";
let validOTP = "";
it("should create an encrypted otp", async () => {
const { get } = getTheSentOTP();
await client.emailOtp.sendVerificationOtp({
email: userEmail1,
type: "sign-in",
});
const verificationValue =
await authCtx.internalAdapter.findVerificationValue(
`sign-in-otp-${userEmail1}`,
);
const storedOtp = verificationValue?.value || "";
const otp = await get();
expect(storedOtp.length !== 0).toBe(true);
expect(splitAtLastColon(storedOtp)[0]).not.toBe(otp);
expect(storedOtp.endsWith(":0")).toBe(true);
encryptedOtp = storedOtp;
validOTP = otp;
});
it("should be allowed to get otp if storeOTP is encrypted", async () => {
try {
const res = await auth.api.getVerificationOTP({
query: {
email: userEmail1,
type: "sign-in",
},
});
if (!res.otp) {
expect(true).toBe(false);
return;
}
expect(res.otp).toEqual(validOTP);
expect(res.otp.length).toBe(6);
} catch (error: any) {
expect(error).not.toBeDefined();
}
});
it("should be able to sign in with encrypted otp", async () => {
const res = await client.signIn.emailOtp({
email: userEmail1,
otp: validOTP,
});
expect(res.data?.user.email).toBe(userEmail1);
expect(res.data?.token).toBeDefined();
});
});
describe("custom encryptor", async () => {
let sendVerificationOtpFn = async (data: {
email: string;
otp: string;
type: "sign-in" | "email-verification" | "forget-password";
}) => {};
function getTheSentOTP() {
let gotOtp: string | null = null;
let sub = (otp: string) => {};
sendVerificationOtpFn = async (data) => {
gotOtp = data.otp;
sub(data.otp);
};
return {
get: () =>
new Promise<string>((resolve) => {
if (gotOtp) {
resolve(gotOtp);
} else {
sub = (otp) => {
gotOtp = otp;
resolve(otp);
};
}
}),
};
}
const { client, testUser, auth } = await getTestInstance(
{
plugins: [
emailOTP({
sendVerificationOTP: async (d) => {
await sendVerificationOtpFn(d);
},
storeOTP: {
encrypt: async (otp) => {
return otp + "encrypted";
},
decrypt: async (otp) => {
return otp.replace("encrypted", "");
},
},
}),
],
},
{
clientOptions: {
plugins: [emailOTPClient()],
},
},
);
const authCtx = await auth.$context;
let validOTP = "";
let userEmail1 = `${crypto.randomUUID()}@email.com`;
it("should create a custom encryptor otp", async () => {
const { get } = getTheSentOTP();
await client.emailOtp.sendVerificationOtp({
email: userEmail1,
type: "sign-in",
});
const verificationValue =
await authCtx.internalAdapter.findVerificationValue(
`sign-in-otp-${userEmail1}`,
);
const storedOtp = verificationValue?.value || "";
const otp = await get();
expect(storedOtp.length !== 0).toBe(true);
expect(splitAtLastColon(storedOtp)[0]).not.toBe(otp);
expect(storedOtp.endsWith(":0")).toBe(true);
validOTP = otp;
});
it("should be allowed to get otp if storeOTP is custom encryptor", async () => {
try {
const res = await auth.api.getVerificationOTP({
query: {
email: userEmail1,
type: "sign-in",
},
});
if (!res.otp) {
expect(true).toBe(false);
return;
}
expect(res.otp).toEqual(validOTP);
expect(res.otp.length).toBe(6);
} catch (error: any) {
console.error(error);
expect(error).not.toBeDefined();
}
});
it("should be able to sign in with custom encryptor otp", async () => {
const res = await client.signIn.emailOtp({
email: userEmail1,
otp: validOTP,
});
expect(res.data?.user.email).toBe(userEmail1);
expect(res.data?.token).toBeDefined();
});
});
describe("custom hasher", async () => {
let sendVerificationOtpFn = async (data: {
email: string;
otp: string;
type: "sign-in" | "email-verification" | "forget-password";
}) => {};
function getTheSentOTP() {
let gotOtp: string | null = null;
let sub = (otp: string) => {};
sendVerificationOtpFn = async (data) => {
gotOtp = data.otp;
sub(data.otp);
};
return {
get: () =>
new Promise<string>((resolve) => {
if (gotOtp) {
resolve(gotOtp);
} else {
sub = (otp) => {
gotOtp = otp;
resolve(otp);
};
}
}),
};
}
const { client, testUser, auth } = await getTestInstance(
{
plugins: [
emailOTP({
sendVerificationOTP: async (d) => {
await sendVerificationOtpFn(d);
},
storeOTP: {
hash: async (otp) => {
return otp + "hashed";
},
},
}),
],
},
{
clientOptions: {
plugins: [emailOTPClient()],
},
},
);
const authCtx = await auth.$context;
let validOTP = "";
let userEmail1 = `${crypto.randomUUID()}@email.com`;
it("should create a custom hasher otp", async () => {
const { get } = getTheSentOTP();
await client.emailOtp.sendVerificationOtp({
email: userEmail1,
type: "sign-in",
});
const verificationValue =
await authCtx.internalAdapter.findVerificationValue(
`sign-in-otp-${userEmail1}`,
);
const storedOtp = verificationValue?.value || "";
const otp = await get();
expect(storedOtp.length !== 0).toBe(true);
expect(splitAtLastColon(storedOtp)[0]).not.toBe(otp);
expect(storedOtp.endsWith(":0")).toBe(true);
validOTP = otp;
});
it("should be allowed to get otp if storeOTP is custom hasher", async () => {
try {
const result = await auth.api.getVerificationOTP({
query: {
email: userEmail1,
type: "sign-in",
},
});
} catch (error: any) {
expect(error.statusCode).toBe(400);
expect(error.status).toBe("BAD_REQUEST");
expect(error.body.code).toBe(
"OTP_IS_HASHED_CANNOT_RETURN_THE_PLAIN_TEXT_OTP",
);
return;
}
// Should not reach here given the above should throw and thus return.
expect(true).toBe(false);
});
it("should be able to sign in with custom hasher otp", async () => {
const res = await client.signIn.emailOtp({
email: userEmail1,
otp: validOTP,
});
expect(res.data?.user.email).toBe(userEmail1);
expect(res.data?.token).toBeDefined();
});
});
});
describe("override default email verification", async () => {
let otp = "";
const { cookieSetter, customFetchImpl } = await getTestInstance({
emailAndPassword: {
enabled: true,
},
emailVerification: {
sendOnSignUp: true,
},
plugins: [
emailOTP({
async sendVerificationOTP(data, request) {
otp = data.otp;
},
overrideDefaultEmailVerification: true,
}),
],
});
const client = createAuthClient({
plugins: [emailOTPClient()],
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
},
});
const headers = new Headers();
it("should send verification email on sign up", async () => {
await client.signUp.email(
{
email: "[email protected]",
password: "password",
name: "Test User",
},
{
onSuccess: cookieSetter(headers),
},
);
expect(otp.length).toBe(6);
});
it("should verify email with otp", async () => {
const res = await client.emailOtp.verifyEmail({
email: "[email protected]",
otp,
});
expect(res.data?.status).toBe(true);
expect(res.data?.user.emailVerified).toBe(true);
});
it("should by default not override default email verification", async () => {
const sendVerificationOTP = vi.fn();
const { client } = await getTestInstance({
emailAndPassword: {
enabled: true,
},
emailVerification: {
sendOnSignUp: true,
async sendVerificationEmail(data, request) {
sendVerificationOTP(data, request);
},
},
plugins: [
emailOTP({
async sendVerificationOTP(data, request) {
//
},
}),
],
});
await client.signUp.email(
{
email: "[email protected]",
password: "password",
name: "Test User",
},
{
onSuccess: cookieSetter(headers),
},
);
expect(sendVerificationOTP).toHaveBeenCalled();
});
});
```