refactor: use betterauth instead of workos

This commit is contained in:
2025-10-05 20:21:45 +00:00
parent b654f50ddd
commit 483aa19351
23 changed files with 4141 additions and 273 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@
* @module
*/
import { anyApi } from "convex/server";
import { anyApi, componentsGeneric } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
@@ -20,3 +20,4 @@ import { anyApi } from "convex/server";
*/
export const api = anyApi;
export const internal = anyApi;
export const components = componentsGeneric();

View File

@@ -10,6 +10,7 @@
import {
ActionBuilder,
AnyComponents,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
@@ -18,9 +19,15 @@ import {
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
FunctionReference,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
type GenericCtx =
| GenericActionCtx<DataModel>
| GenericMutationCtx<DataModel>
| GenericQueryCtx<DataModel>;
/**
* Define a query in this Convex app's public API.
*

View File

@@ -16,6 +16,7 @@ import {
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
componentsGeneric,
} from "convex/server";
/**

View File

@@ -1,20 +1,8 @@
const clientId = process.env.WORKOS_CLIENT_ID
const authConfig = {
providers: [
{
type: "customJwt",
issuer: `https://api.workos.com/`,
algorithm: "RS256",
jwks: `https://api.workos.com/sso/jwks/${clientId}`,
applicationID: clientId,
},
{
type: "customJwt",
issuer: `https://api.workos.com/user_management/${clientId}`,
algorithm: "RS256",
jwks: `https://api.workos.com/sso/jwks/${clientId}`,
applicationID: clientId,
domain: process.env.CONVEX_SITE_URL,
applicationID: "convex",
},
],
}

47
packages/convex/auth.ts Normal file
View File

@@ -0,0 +1,47 @@
import { createClient, type GenericCtx } from "@convex-dev/better-auth"
import { convex, crossDomain } from "@convex-dev/better-auth/plugins"
import { betterAuth } from "better-auth"
import { components } from "./_generated/api"
import type { DataModel } from "./_generated/dataModel"
import { query } from "./_generated/server"
const siteUrl = process.env.SITE_URL!
// The component client has methods needed for integrating Convex with Better Auth,
// as well as helper methods for general use.
export const authComponent = createClient<DataModel>(components.betterAuth)
export const createAuth = (
ctx: GenericCtx<DataModel>,
{ optionsOnly } = { optionsOnly: false },
) => {
return betterAuth({
// disable logging when createAuth is called just to generate options.
// this is not required, but there's a lot of noise in logs without it.
logger: {
disabled: optionsOnly,
},
trustedOrigins: [siteUrl],
database: authComponent.adapter(ctx),
// Configure simple, non-verified email/password to get started
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
},
plugins: [
// The cross domain plugin is required for client side frameworks
crossDomain({ siteUrl }),
// The Convex plugin is required for Convex compatibility
convex(),
],
})
}
// Example function for getting the current user
// Feel free to edit, omit, etc.
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
return authComponent.getAuthUser(ctx)
},
})

View File

@@ -0,0 +1,7 @@
import betterAuth from "@convex-dev/better-auth/convex.config"
import { defineApp } from "convex/server"
const app = defineApp()
app.use(betterAuth)
export default app

7
packages/convex/http.ts Normal file
View File

@@ -0,0 +1,7 @@
import { httpRouter } from "convex/server"
import { authComponent, createAuth } from "./auth"
const http = httpRouter()
// CORS handling is required for client side frameworks
authComponent.registerRoutes(http, createAuth, { cors: true })
export default http

View File

@@ -7,6 +7,8 @@
},
"peerDependencies": {
"typescript": "^5",
"convex": "^1.27.0"
"better-auth": "1.3.8",
"convex": "^1.27.0",
"@convex-dev/better-auth": "^0.8.9"
}
}

View File

@@ -10,12 +10,13 @@
"format": "biome format --write"
},
"dependencies": {
"@convex-dev/workos": "^0.0.1",
"@convex-dev/better-auth": "^0.8.9",
"@fileone/convex": "workspace:*",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -23,7 +24,7 @@
"@tanstack/react-router": "^1.131.41",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-devtools": "^1.131.42",
"@workos-inc/authkit-react": "^0.12.0",
"better-auth": "1.3.8",
"bun-plugin-tailwind": "latest",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -0,0 +1,12 @@
import {
convexClient,
crossDomainClient,
} from "@convex-dev/better-auth/client/plugins"
import { createAuthClient } from "better-auth/react"
export type AuthErrorCode = keyof typeof authClient.$ERROR_CODES
export const authClient = createAuthClient({
baseURL: process.env.BUN_PUBLIC_CONVEX_SITE_URL,
plugins: [convexClient(), crossDomainClient()],
})

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,242 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors) {
return null
}
if (errors?.length === 1 && errors[0]?.message) {
return errors[0].message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -1,26 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import type * as React from "react"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
)
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -1,6 +1,5 @@
import { api } from "@fileone/convex/_generated/api"
import { Link, useLocation } from "@tanstack/react-router"
import { useAuth } from "@workos-inc/authkit-react"
import { useQuery as useConvexQuery } from "convex/react"
import { useAtomValue } from "jotai"
import {
@@ -138,11 +137,7 @@ function BackgroundTaskProgressItem() {
}
function UserMenu() {
const { signOut } = useAuth()
function handleSignOut() {
signOut()
}
function handleSignOut() {}
return (
<DropdownMenu>

View File

@@ -9,6 +9,7 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SignUpRouteImport } from './routes/sign-up'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
@@ -18,6 +19,11 @@ import { Route as AuthenticatedSidebarLayoutHomeRouteImport } from './routes/_au
import { Route as AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport } from './routes/_authenticated/_sidebar-layout/directories.$directoryId'
import { Route as AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRouteImport } from './routes/_authenticated/_sidebar-layout/trash.directories.$directoryId'
const SignUpRoute = SignUpRouteImport.update({
id: '/sign-up',
path: '/sign-up',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
@@ -63,6 +69,7 @@ const AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute =
export interface FileRoutesByFullPath {
'/login': typeof LoginRoute
'/sign-up': typeof SignUpRoute
'/login/callback': typeof LoginCallbackRoute
'/': typeof AuthenticatedIndexRoute
'/home': typeof AuthenticatedSidebarLayoutHomeRoute
@@ -71,6 +78,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/sign-up': typeof SignUpRoute
'/login/callback': typeof LoginCallbackRoute
'/': typeof AuthenticatedIndexRoute
'/home': typeof AuthenticatedSidebarLayoutHomeRoute
@@ -81,6 +89,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute
'/sign-up': typeof SignUpRoute
'/_authenticated/_sidebar-layout': typeof AuthenticatedSidebarLayoutRouteWithChildren
'/login_/callback': typeof LoginCallbackRoute
'/_authenticated/': typeof AuthenticatedIndexRoute
@@ -92,6 +101,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/login'
| '/sign-up'
| '/login/callback'
| '/'
| '/home'
@@ -100,6 +110,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
| '/sign-up'
| '/login/callback'
| '/'
| '/home'
@@ -109,6 +120,7 @@ export interface FileRouteTypes {
| '__root__'
| '/_authenticated'
| '/login'
| '/sign-up'
| '/_authenticated/_sidebar-layout'
| '/login_/callback'
| '/_authenticated/'
@@ -120,11 +132,19 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
LoginRoute: typeof LoginRoute
SignUpRoute: typeof SignUpRoute
LoginCallbackRoute: typeof LoginCallbackRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/sign-up': {
id: '/sign-up'
path: '/sign-up'
fullPath: '/sign-up'
preLoaderRoute: typeof SignUpRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
@@ -221,6 +241,7 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
AuthenticatedRoute: AuthenticatedRouteWithChildren,
LoginRoute: LoginRoute,
SignUpRoute: SignUpRoute,
LoginCallbackRoute: LoginCallbackRoute,
}
export const routeTree = rootRouteImport

View File

@@ -1,12 +1,12 @@
import "@/styles/globals.css"
import { ConvexProviderWithAuthKit } from "@convex-dev/workos"
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { createRootRoute, Outlet } from "@tanstack/react-router"
import { AuthKitProvider, useAuth } from "@workos-inc/authkit-react"
import { ConvexReactClient } from "convex/react"
import { toast } from "sonner"
import { formatError } from "@/lib/error"
import { useKeyboardModifierListener } from "@/lib/keyboard"
import { authClient } from "../auth-client"
export const Route = createRootRoute({
component: RootLayout,
@@ -30,17 +30,12 @@ function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<AuthKitProvider
clientId={process.env.BUN_PUBLIC_WORKOS_CLIENT_ID!}
redirectUri={process.env.BUN_PUBLIC_WORKOS_REDIRECT_URI!}
<ConvexBetterAuthProvider
client={convexClient}
authClient={authClient}
>
<ConvexProviderWithAuthKit
client={convexClient}
useAuth={useAuth}
>
<Outlet />
</ConvexProviderWithAuthKit>
</AuthKitProvider>
<Outlet />
</ConvexBetterAuthProvider>
</QueryClientProvider>
)
}

View File

@@ -4,7 +4,6 @@ import {
Outlet,
useLocation,
} from "@tanstack/react-router"
import { useAuth } from "@workos-inc/authkit-react"
import {
Authenticated,
AuthLoading,
@@ -12,6 +11,7 @@ import {
useConvexAuth,
} from "convex/react"
import { useEffect, useState } from "react"
import { authClient } from "@/auth-client"
import { LoadingSpinner } from "@/components/ui/loading-spinner"
export const Route = createFileRoute("/_authenticated")({
@@ -21,7 +21,7 @@ export const Route = createFileRoute("/_authenticated")({
function AuthenticatedLayout() {
const { search } = useLocation()
const { isLoading } = useConvexAuth()
const { isLoading: authKitLoading } = useAuth()
const { isPending: sessionLoading } = authClient.useSession()
const [hasProcessedAuth, setHasProcessedAuth] = useState(false)
// Check if we're in the middle of processing an auth code
@@ -29,17 +29,17 @@ function AuthenticatedLayout() {
// Track when auth processing is complete
useEffect(() => {
if (!authKitLoading && !isLoading) {
if (!sessionLoading && !isLoading) {
// Delay to ensure auth state is fully synchronized
const timer = setTimeout(() => {
setHasProcessedAuth(true)
}, 500)
return () => clearTimeout(timer)
}
}, [authKitLoading, isLoading])
}, [sessionLoading, isLoading])
// Show loading during auth code processing or while auth state is syncing
if (hasAuthCode || authKitLoading || isLoading || !hasProcessedAuth) {
if (hasAuthCode || sessionLoading || isLoading || !hasProcessedAuth) {
return (
<div className="flex h-screen w-full items-center justify-center">
<LoadingSpinner className="size-10" />

View File

@@ -1,5 +1,4 @@
import { createFileRoute } from "@tanstack/react-router"
import { useAuth } from "@workos-inc/authkit-react"
import { Button } from "../components/ui/button"
export const Route = createFileRoute("/login")({
@@ -7,11 +6,9 @@ export const Route = createFileRoute("/login")({
})
function RouteComponent() {
const { signIn } = useAuth()
return (
<div className="flex h-screen w-full items-center justify-center">
<Button onClick={() => signIn()}>Login</Button>
<Button onClick={() => {}}>Login</Button>
</div>
)
}

View File

@@ -0,0 +1,195 @@
import { useMutation } from "@tanstack/react-query"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { GalleryVerticalEnd } from "lucide-react"
import type React from "react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { type AuthErrorCode, authClient } from "../auth-client"
export const Route = createFileRoute("/sign-up")({
component: SignupPage,
})
type SignUpParams = {
displayName: string
email: string
password: string
confirmPassword: string
}
class PasswordMismatchError extends Error {
constructor() {
super("Passwords do not match")
}
}
class BetterAuthError extends Error {
constructor(public readonly errorCode: AuthErrorCode) {
super(`better-auth error: ${errorCode}`)
}
}
function SignupPage() {
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-xl flex-col gap-6">
<a
href="#"
className="flex items-center gap-2 self-center font-medium text-xl"
>
<div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
<GalleryVerticalEnd className="size-4" />
</div>
Drexa
</a>
<SignUpFormContainer>
<SignupForm />
</SignUpFormContainer>
</div>
</div>
)
}
function SignUpFormContainer({ children }: React.PropsWithChildren) {
return (
<div className="flex flex-col gap-6">
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">
Create your account
</CardTitle>
<CardDescription>
Enter your email below to create your account
</CardDescription>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
</div>
)
}
function SignupForm() {
const navigate = useNavigate()
const {
mutate: signUp,
isPending,
error: signUpError,
} = useMutation({
mutationFn: async (data: SignUpParams) => {
if (data.password !== data.confirmPassword) {
throw new PasswordMismatchError()
}
const { data: signUpData, error } = await authClient.signUp.email({
name: data.displayName,
email: data.email,
password: data.password,
})
if (error) {
throw new BetterAuthError(error.code as AuthErrorCode)
}
return signUpData
},
onSuccess: () => {
navigate({
to: "/",
replace: true,
})
},
})
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formData = new FormData(event.currentTarget)
signUp({
displayName: formData.get("displayName") as string,
email: formData.get("email") as string,
password: formData.get("password") as string,
confirmPassword: formData.get("confirmPassword") as string,
})
}
return (
<form onSubmit={handleSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="name">Your Name</FieldLabel>
<Input
name="displayName"
type="text"
placeholder="John Doe"
required
/>
<FieldDescription>
This is how you will be referred to on Drexa. You can
change this later.
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
name="email"
type="email"
placeholder="m@example.com"
required
/>
{signUpError instanceof BetterAuthError &&
signUpError.errorCode ===
authClient.$ERROR_CODES.INVALID_EMAIL ? (
<FieldError>Invalid email address</FieldError>
) : null}
</Field>
<Field>
<Field className="grid grid-rows-2 md:grid-rows-1 md:grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input name="password" type="password" required />
</Field>
<Field>
<FieldLabel htmlFor="confirm-password">
Confirm Password
</FieldLabel>
<Input
name="confirmPassword"
type="password"
required
/>
</Field>
</Field>
{signUpError instanceof PasswordMismatchError ? (
<FieldError>Passwords do not match</FieldError>
) : (
<FieldDescription>
Must be at least 8 characters long.
</FieldDescription>
)}
</Field>
<Field>
<Button
type="submit"
loading={isPending}
disabled={isPending}
>
{isPending ? "Creating account…" : "Create Account"}
</Button>
<FieldDescription className="text-center">
Already have an account? <a href="#">Sign in</a>
</FieldDescription>
</Field>
</FieldGroup>
</form>
)
}