🕌

Next13でNextAuthを使いつつログインフォームを作ってみた

2023/10/06に公開

今回やること

前回Next13の新機能ServerActionsを使ってユーザー登録を作ったので、ログインフォームを作っていく。
https://zenn.dev/nl_kambara/articles/a54612136a4cda
認証部分は(初めてだけど)NextAuthを使う。
Nextで実装できる箇所についてはNext側で実装していくためNextAuthはログインでの認証のみ実装。

作ってみた

流れと実装方法

実装の流れ

  1. NextAuthでログインの処理
  2. ログインしているかどうかでヘッダーの内容を変更
  3. セッションからユーザーのIDを取得しServer側でユーザーデータを取得する

本編とは逸れるのでここに記載するが今回からNextUIとReactIconsを導入。
導入方法は割愛するので各ドキュメント等参照。
https://nextui.org/docs/guide/installation
https://react-icons.github.io/react-icons

NextAuthの準備

NextAuthについて。
認証機能をNextに実装できるライブラリ。
https://next-auth.js.org/getting-started/example

インストール

まずはドキュメントに合わせてインストールしていく。
https://authjs.dev/guides/providers/credentials

pnpm add next-auth

NextAuthで指定するoptionを作成

認証でどんな事するかの設定を書いていく。
今回emailとpasswordでの認証を行うのでcredentialsを使用。
https://authjs.dev/guides/providers/credentials

lib/auth.ts
import { PrismaAdapter } from '@next-auth/prisma-adapter'
import { compareSync } from 'bcrypt'
import type { NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import prisma from '~/lib/prisma'

export const authOptions: NextAuthOptions = {
  session: {
    strategy: 'jwt',
  },
  adapter: PrismaAdapter(prisma),
  providers: [
    CredentialsProvider({
      name: 'Signin',
      credentials: {
        email: {
          label: 'Email',
          type: 'email',
          placeholder: 'example@example.com',
        },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) return null

        const user = await prisma.user.findUnique({
          where: {
            email: credentials?.email,
          },
        })
        if (!user) return null

        const isValidPassword = compareSync(credentials?.password, user.password)
        if (!isValidPassword) return null

        console.log('last')
        return user
      },
    }),
  ],
  callbacks: {
    jwt: async ({ token, user, account }) => {
      if (user) {
        token.user = user
        token.id = user.id
      }
      if (account) {
        token.accessToken = account.access_token
      }
      return token
    },
    session: ({ session, token }) => {
      return {
        ...session,
        user: {
          ...session.user,
          id: token.id,
        },
      }
    },
  },
}
  1. セッションからユーザーのIDを取得しServer側でユーザーデータを取得する

上記の目的のためidがほしいのでcallbacksでsessionにidを突っ込む処理を追加。
あとはprismaでの認証の流れを記述して完了とする。

API作成

If you're using Next.js 13.2 or above with the new App Router (app/), you can initialize the configuration using the new Route Handlers by following our guide.

App directoryでの作成になるので公式ドキュメントで言及されている通りapp/api/auth/[...nextauth]/route.tsに作成。

app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth/next'

import { authOptions } from '~/lib/auth'

const handler = NextAuth(authOptions)

export { handler as GET, handler as POST }

providerを挿入

useSessionを使用するためSessionProviderをlayoutに記述する。

app/layout.tsx
'use client'
import './global.css'
import { SessionProvider } from 'next-auth/react'
import { ReactNode } from 'react'
import Header from '~/features/AppHeader'
import NextuiProviders from '~/providers/nextui'

const RootLayout = ({ children }: { children: ReactNode }) => {
  return (
    <html lang='ja'>
      <body className='min-h-screen w-screen'>
        <NextuiProviders>
          <SessionProvider>
            <Header />
            <main className='h-screen w-full bg-white pt-[56px] text-zinc-900'>{children}</main>
          </SessionProvider>
        </NextuiProviders>
      </body>
    </html>
  )
}

export default RootLayout

これでNextAuthの設定は一通り終了。

ログインフォーム作成


ログインページ

app/login/page.tsx
'use client'
import { Button } from '@nextui-org/react'
import { NextPage } from 'next'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import LoginForm from '~/features/user/LoginForm'

const LoginPage: NextPage = () => {
  const { data } = useSession()
  const router = useRouter()
  if (data) router.push('/account')

  return (
    <div className='p-4'>
      <LoginForm />
      <div className='mt-8 flex items-center justify-center gap-4'>
        <p>登録してない方は</p>
        <Link href='/signup'>
          <Button radius='sm' className='bg-rose-400 font-bold'>
            Signup
          </Button>
        </Link>
      </div>
    </div>
  )
}

export default LoginPage

フォームの部分

features/user/LoginForm.tsx
'use client'
import { yupResolver } from '@hookform/resolvers/yup'
import { Button, Input } from '@nextui-org/react'
import { useRouter } from 'next/navigation'
import { signIn } from 'next-auth/react'
import { FC } from 'react'
import { useForm } from 'react-hook-form'
import { loginSchema } from '~/schemas/user'

const LoginForm: FC = () => {
  const router = useRouter()
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors },
  } = useForm({
    mode: 'onSubmit',
    resolver: yupResolver(loginSchema),
  })

  const onSubmit = handleSubmit((data) => {
    signIn('credentials', {
      redirect: false,
      ...data,
    })
      .then((res) => {
        if (res?.error) {
          setError('email', { type: 'login' })
          setError('password', { type: 'login', message: 'emailかpassworが違います' })
          return
        }
        router.push('/account')
      })
      .catch((err) => {
        console.error(err)
      })
  })

  return (
    <form className='mx-auto flex max-w-md flex-col gap-4' onSubmit={onSubmit}>
      <section className='flex flex-col'>
        <Input
          {...register('email')}
          label={'email'}
          isInvalid={!!errors.email}
          className='max-w-md'
        />
        {errors.email && <span className='text-xs text-red-500'>{errors.email.message}</span>}
      </section>
      <section className='flex flex-col'>
        <Input
          {...register('password')}
          label={'password'}
          isInvalid={!!errors.password}
          className='max-w-md'
        />
        {errors.password && <span className='text-xs text-red-500'>{errors.password.message}</span>}
      </section>
      <div className='flex justify-center'>
        <Button type='submit' radius='sm' className='bg-amber-500 font-bold'>
          Login
        </Button>
      </div>
    </form>
  )
}

export default LoginForm

ログイン処理

データを入力してsubmitしたときにNextAuthのsignIn関数を使用。
問題なければaccountに遷移する。
ここで注意したいのがsignInはサーバー側でできないこと。
公式にも書いてあるので処理はクライアント側でやるように注意する。(前回の流れでサーバー側でやろうとして20分悩んだ)

Client Side: Yes
Server Side: No

https://next-auth.js.org/getting-started/client#signin

エラーの処理も.thenの方に書いているが、認証の処理の中でnullを返しているとres.errorで返ってくるのでこのような実装になった。
認証でエラーがある場合react-hook-formでsetErrorする。

const onSubmit = handleSubmit((data) => {
   signIn('credentials', {
     redirect: false,
     ...data,
   })
     .then((res) => {
       if (res?.error) {
         setError('email', { type: 'login' })
         setError('password', { type: 'login', message: 'emailかpassworが違います' })
         return
       }
       router.push('/account')
     })
     .catch((err) => {
       console.error(err)
     })
 })

エラー時。

これでログインフォーム完成。

Headerについて

現状ログイン機能を実装したのでログインしているかしていないかで、アイコンの出し分けを作る。
といってもセッションの有無で表示を切り替えるだけの簡単な実装。
ログイン前(トップページ)

ログイン後(トップページ)

features/appHeader/index.tsx
'use client'
import Link from 'next/link'
import { useSession } from 'next-auth/react'
import { BiLogIn, BiUser } from 'react-icons/bi'

const Header: React.FC = () => {
  const { data } = useSession()
  return (
    <header className='fixed top-0 flex w-full items-center justify-between px-4 py-3 shadow-md'>
      <div className='text-2xl font-bold text-amber-600'>
        <Link href='/'>App</Link>
      </div>
      <div className='text-2xl text-amber-600'>
        {!data && (
          <Link href='/login' className=''>
            <BiLogIn />
          </Link>
        )}
        {data && (
          <Link href='/account'>
            <BiUser />
          </Link>
        )}
      </div>
    </header>
  )
}
export default Header

app直下のlayoutでheaderを呼び出す。

アカウントのページ

ひとまずアカウントのデータを表示させるだけのページ + ログアウトボタン。
sessionからidを取得してprismaでuser情報を取得する。
(hoge)/hoge/page.tsxなどのルーティングについてはNext13のRoute Groupsを参照。
https://nextjs.org/docs/app/building-your-application/routing/route-groups

app/(auth)/account/page.tsx
import { NextPage } from 'next'
import { getServerSession } from 'next-auth'
import UserProfile, { User } from '~/features/user/component/UserProfile'
import { authOptions } from '~/lib/auth'

const Userpage: NextPage = async () => {
  const session = await getServerSession(authOptions)
  const user = await prisma.user.findUnique({
    where: {
      id: (session?.user as User)?.id,
    },
  })
  if (!user) return null

  return (
    <div className='mt-8'>
      <h1 className='text-center text-xl'>Welcome</h1>
      <div className='mt-4 flex justify-center'>
        <UserProfile user={{ ...user }} />
      </div>
    </div>
  )
}

export default Userpage
features/user/component/UserProfile.tsx
'use client'
import { Button } from '@nextui-org/react'
import { signOut } from 'next-auth/react'
import { FC } from 'react'

export type User = {
  id: string
  name: string
  email: string
}
type Props = {
  user: User
}

const UserProfile: FC<Props> = ({ user }) => {
  return (
    <div>
      <p className='text-center'>{user.id}</p>
      <p className='text-center'>{user.name}</p>
      <p className='text-center'>{user.email}</p>
      <div className='mt-16 flex justify-center'>
        <Button onClick={() => signOut()} className='mx-auto bg-sky-600 font-bold text-white'>
          Logout
        </Button>
      </div>
    </div>
  )
}

export default UserProfile

レイアウト

(auth)下のページはセッションがない場合にloginページに遷移させたいのでレイアウトに記述。
Route GroupsができてAuthなどのレイアウト作成は直感的にわかりやすくなった。

app/(auth)/layout.tsx
import { redirect } from 'next/navigation'
import { getServerSession } from 'next-auth'
import { ReactNode } from 'react'
import { authOptions } from '~/lib/auth'

const AuthLayout = async ({ children }: { children: ReactNode }) => {
  const session = await getServerSession(authOptions)
  if (!session) redirect('/login')

  return <>{children}</>
}

export default AuthLayout

これで実装したい機能を一通り実装。

やってみて

今回始めてNextAuthを使ったが、最低限の実装はできた。
セッションの取得も楽なので、セッションの有無でヘッダーなどのコンテンツを切り替える場合はかなり簡単に実装できる。
今回は実装しなかったがリフレッシュトークンつけたい場合など公式ドキュメントになにかと載っているし、NextAuthを使っている先駆者がたくさんいるので記事を漁ってみるのもいいと思う。
ただ、Next13以前の実装方法だとserver・clientの違いで手こずる部分があるかもしれないので、そのときは大人しくドキュメントを見た方がいい(Next13での変更は大体載っている)。

ドキュメントを見ていてもまだ触っていない部分がたくさんあるので、NextAuthをメインで使っていくようになったらまた深く掘り下げたいと思う。
今回は型指定まで掘り下げる時間がなく禁断のasを使用した箇所もあるので、次使用するときは型に重きをおいてしっかり作りたい。

Discussion