🎉
【TanStack Start】TanStack Start【#7 Better Auth】
【#7 Better Auth】
YouTube: https://youtube.com/live/5rPhFGAzJKY
今回はBetter Authの設定を行います。
npm install better-auth
npm i react-hook-form
npm i @hookform/resolvers
src/routes/api/auth.$.ts
import { auth } from '@/lib/auth'
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/api/auth/$')({
server: {
handlers: {
GET: async ({ request }: { request: Request }) => {
return await auth.handler(request)
},
POST: async ({ request }: { request: Request }) => {
return await auth.handler(request)
},
},
},
})
src/lib/auth.server.ts
import { createServerFn } from '@tanstack/react-start'
import { getRequestHeaders } from '@tanstack/react-start/server'
import { auth } from '@/lib/auth'
export const getSession = createServerFn({ method: 'GET' }).handler(
async () => {
const headers = getRequestHeaders()
const session = await auth.api.getSession({ headers })
return session
},
)
export const ensureSession = createServerFn({ method: 'GET' }).handler(
async () => {
const headers = getRequestHeaders()
const session = await auth.api.getSession({ headers })
if (!session) {
throw new Error('Unauthorized')
}
return session
},
)
src/lib/auth.ts
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { tanstackStartCookies } from 'better-auth/tanstack-start/solid'
import { db } from '@/db'
import * as schema from '@/db/schema'
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
schema: {
user: schema.user,
session: schema.session,
account: schema.account,
verification: schema.verification,
},
}),
emailAndPassword: {
enabled: true,
},
plugins: [tanstackStartCookies()], // make sure this is the last plugin in the array
})
src/lib/auth-client.ts
import { createAuthClient } from 'better-auth/react'
export const authClient = createAuthClient({})
export const { signIn, signUp, signOut, useSession } = authClient
src/lib/auth-guards.ts
import { redirect } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
import { auth } from '@/lib/auth'
// 未ログインならサインインページへリダイレクト(beforeLoad / プロテクトページ用)
export const requireAuth = createServerFn({ method: 'GET' }).handler(
async () => {
const request = getRequest()
const session = await auth.api.getSession({ headers: request.headers })
if (!session) throw redirect({ to: '/sign-in' })
return { session }
},
)
// ログイン済みならトップページへリダイレクト(beforeLoad / サインインページ用)
export const requireGuest = createServerFn({ method: 'GET' }).handler(
async () => {
const request = getRequest()
const session = await auth.api.getSession({ headers: request.headers })
if (session) throw redirect({ to: '/' })
},
)
src/routes/_layout/_public/sign-in.tsx
import { useState } from 'react'
import { createFileRoute, useNavigate, Link } from '@tanstack/react-router'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { signIn } from '@/lib/auth-client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Field,
FieldLabel,
FieldError,
FieldGroup,
} from '@/components/ui/field'
import {
Card,
CardHeader,
CardContent,
CardFooter,
CardTitle,
CardDescription,
} from '@/components/ui/card'
import { Alert, AlertTitle } from '@/components/ui/alert'
import { OctagonAlertIcon } from 'lucide-react'
export const Route = createFileRoute('/_layout/_public/sign-in')({
component: SignInPage,
})
const Formschema = z.object({
email: z.string().email('有効なメールアドレスを入力してください'),
password: z.string().min(8, 'パスワードは8文字以上で入力してください'),
})
type FormValues = z.infer<typeof Formschema>
function SignInPage() {
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()
const form = useForm<FormValues>({
resolver: zodResolver(Formschema),
defaultValues: {
email: '',
password: '',
},
})
const onSubmit = async (values: FormValues) => {
await signIn.email(
{
email: values.email,
password: values.password,
callbackURL: '/',
},
{
onSuccess: () => {
navigate({ to: '/' })
},
onError: ({ error }) => {
setError(error.message)
},
},
)
}
const isSubmitting = form.formState.isSubmitting
return (
<div className="flex h-full items-center justify-center p-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">サインイン</CardTitle>
<CardDescription className="text-muted-foreground">
アカウントにサインインしてください
</CardDescription>
</CardHeader>
<form id="sif-form" onSubmit={form.handleSubmit(onSubmit)}>
<CardContent className="mt-2">
<FieldGroup>
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="sif-email">メールアドレス</FieldLabel>
<Input
{...field}
id="sif-email"
type="email"
placeholder="mail@example.com"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name="password"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="sif-password">パスワード</FieldLabel>
<Input
{...field}
id="sif-password"
type="password"
placeholder="••••••••"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
{!!error && (
<Alert className="bg-rose-100 border-none text-rose-500 mt-2">
<OctagonAlertIcon className="size-4" />
<AlertTitle>{error}</AlertTitle>
</Alert>
)}
</CardContent>
<CardFooter className="flex-col gap-3 mt-4">
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? 'サインイン中...' : 'サインイン'}
</Button>
<p className="text-sm text-muted-foreground">
アカウントをお持ちでない方は{' '}
<Link
to="/sign-up"
className="underline underline-offset-4 hover:text-primary"
>
サインアップ
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
)
}
src/routes/_layout/_public/sign-up.tsx
import { useState } from 'react'
import { createFileRoute, useNavigate, Link } from '@tanstack/react-router'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { signUp } from '@/lib/auth-client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Field,
FieldLabel,
FieldError,
FieldGroup,
} from '@/components/ui/field'
import {
Card,
CardHeader,
CardContent,
CardFooter,
CardTitle,
CardDescription,
} from '@/components/ui/card'
import { Alert, AlertTitle } from '@/components/ui/alert'
import { OctagonAlertIcon } from 'lucide-react'
export const Route = createFileRoute('/_layout/_public/sign-up')({
component: SignUpPage,
})
const Formschema = z
.object({
name: z.string().min(1, '名前を入力してください'),
email: z.string().email('有効なメールアドレスを入力してください'),
password: z.string().min(8, 'パスワードは8文字以上で入力してください'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'パスワードが一致しません',
path: ['confirmPassword'],
})
type FormValues = z.infer<typeof Formschema>
function SignUpPage() {
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()
const form = useForm<FormValues>({
resolver: zodResolver(Formschema),
defaultValues: {
name: '',
email: '',
password: '',
confirmPassword: '',
},
})
const onSubmit = async (values: FormValues) => {
await signUp.email(
{
name: values.name,
email: values.email,
password: values.password,
callbackURL: '/',
},
{
onSuccess: () => {
navigate({ to: '/' })
},
onError: ({ error }) => {
setError(error.message)
},
},
)
}
const isSubmitting = form.formState.isSubmitting
return (
<div className="flex h-full items-center justify-center p-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">サインアップ</CardTitle>
<CardDescription className="text-muted-foreground">
新しいアカウントを作成してください
</CardDescription>
</CardHeader>
<form id="suf-form" onSubmit={form.handleSubmit(onSubmit)}>
<CardContent>
<FieldGroup>
<Controller
name="name"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="suf-name">名前</FieldLabel>
<Input
{...field}
id="suf-name"
type="text"
placeholder="John Due"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="suf-email">メールアドレス</FieldLabel>
<Input
{...field}
id="suf-email"
type="email"
placeholder="mail@example.com"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name="password"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="suf-password">パスワード</FieldLabel>
<Input
{...field}
id="suf-password"
type="password"
placeholder="••••••••"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name="confirmPassword"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="suf-confirm-password">
パスワード(確認)
</FieldLabel>
<Input
{...field}
id="suf-confirm-password"
type="password"
placeholder="••••••••"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
{!!error && (
<Alert className="bg-rose-100 border-none text-rose-500 mt-2">
<OctagonAlertIcon className="size-4" />
<AlertTitle>{error}</AlertTitle>
</Alert>
)}
</CardContent>
<CardFooter className="flex-col gap-3 mt-4">
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? '作成中...' : 'アカウントを作成'}
</Button>
<p className="text-sm text-muted-foreground">
アカウントをお持ちの方は{' '}
<Link
to="/sign-in"
className="underline underline-offset-4 hover:text-primary"
>
サインイン
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
)
}
src/trpc/init.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { auth } from '@/lib/auth'
export const createContext = async (req: Request) => {
const session = await auth.api.getSession({ headers: req.headers })
return { session }
}
type Context = Awaited<ReturnType<typeof createContext>>
const t = initTRPC.context<Context>().create()
export const router = t.router
// 認証不要のプロシージャ
export const publicProcedure = t.procedure
// 認証必須のプロシージャ(未ログインなら UNAUTHORIZED エラー)
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
return next({ ctx: { ...ctx, session: ctx.session } })
})
src/routes/api/trpc.$.ts
import { createFileRoute } from '@tanstack/react-router'
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { createContext } from '@/trpc/init'
import { appRouter } from '@/trpc/router'
function handleRequest(request: Request) {
return fetchRequestHandler({
endpoint: '/api/trpc',
req: request,
router: appRouter,
createContext: ({ req }) => createContext(req),
})
}
export const Route = createFileRoute('/api/trpc/$')({
server: {
handlers: {
GET: ({ request }) => handleRequest(request),
POST: ({ request }) => handleRequest(request),
},
},
})
Discussion