SupabaseとNext.jsを使って認証画面を作る

## はじめに
Next.jsなどのフロントエンドのフレームワークを利用していて、どうしても認証画面が必要になってくることがあります。そこで認証にSupabaseのAuth機能を使ってみることにします。なぜFirebaseにしないかというと、Auth機能だけとれば別に問題はないですが、肝心のDBを使いたいとき、SupabaseだとPostgreSQLを使用することができ、しかもRLS(Row Level Security)でAuthとテーブルを紐づけすることが容易なので、今回はSupabaseを使ってみることにします。

なお、Supabaseのプロジェクトの始め方は割愛します。またCSSの装飾として、TailwindCSSを用います。Next.jsはCSR(SPA)として使います。SSRしないのであればNext.jsを用意しなくてもよさそうですが、ルーティンがファイルベースでとても便利なので今回はNext.jsを使います。
## Supabase関連の用意
Supabaseを使うにあたって必要な、supabase-jsをインストールします。
```bash
npm install @supabase/supabase-js
```
ついでに、Supabase CLIもインストールしておきます。これがあると、ローカルでコンテナによる開発環境を立ち上げたり、DBのマイグレーションを管理したりできます。今回は、DBの各テーブルから型を取得するために使います。公式のドキュメントには`supabase start`と書かれていますが、`supabase link`を使えばDockerを立ち上げずとも型情報だけを取ってこれます。
```bash
brew install supabase/tap/supabase
supabase login #Access Tokenを貼る
supabase init
supabase link --project-ref <プロジェクトのReference ID> #
```
また、SupabaseのプロジェクトURLとAPIKEYをコピペします。ダッシュボード左下の歯車マークのProject SettingsのAPIという項目に載っているのでコピーします。![スクリーンショット 2023-01-07 173927.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267174/e417347b-76e8-4783-4217-fc9681ffdc60.png)
それを.envファイルに記述します
```:.env
NEXT_PUBLIC_SUPABASE_URL=/*コピーしたプロジェクトURL*/
NEXT_PUBLIC_SUPABASE_ANON_KEY=/*コピーしたAPIKEY*/
```

そして、supabase-jsを使用するためにsupabase.tsというファイルを作成します。

```typescript:utils/supabase.ts
import { createClient } from "@supabase/supabase-js";
import type { Database } from "../types/DatabaseDefinitions";

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;

export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey);

```

## 認証処理
useContextでAuthProviderを作成します。認証関連の処理をまとめたProviderです。
```tsx:components/AuthProvider.tsx
import { supabase } from "../utils/supabase"
import { ReactNode, useContext, createContext, useEffect, useState } from "react"
import { Session } from "@supabase/supabase-js"
import { useRouter } from "next/router"

type AuthCtx = {
  session: Session
  loading: boolean
  setLoading: any
  login: ({ email, password }: { email: string; password: string }) => Promise<void>
  signup: ({ email, password }: { email: string; password: string }) => Promise<void>
  logout: () => Promise<void>
}
const AuthContext = createContext<AuthCtx>(null)
const useAuth = () => useContext(AuthContext)

const AuthProvider = ({ children }: { children: ReactNode }) => {
  const auth = supabase.auth

  const [loading, setLoading] = useState<boolean>(true)
  const [session, setSession] = useState<Session>(null)

//session処理の実行中は画面を表示しないようにする
  useEffect(() => {
    let mounted = true
    ;(async () => {
      const { data: { session }} = await auth.getSession()
      if (mounted) {
        if (session) {
          setSession(session)
        }
        setLoading(false)
      }
    })()
    const {data: { subscription },
    } = auth.onAuthStateChange((_event, session) => {
      setSession(session)
      if (_event === "SIGNED_OUT") {
        setSession(null)
      }
    })
    return () => {
      mounted = false
      subscription?.unsubscribe()
    }
  }, [])
  const login = async ({ email, password }: { email: string; password: string }) => {
    await auth.signInWithPassword({ email: email, password: password })
  }

  const signup = async ({ email, password }: { email: string; password: string }) => {
    await auth.signUp({ email: email, password: password })
  }

  const logout =  () => {
    auth.signOut()
  }

  const exposed = {
    session,
    loading,
    setLoading,
    signup,
    login,
    logout,
  }
  return <AuthContext.Provider value={exposed}>{!loading && children}</AuthContext.Provider>
}

export { useAuth, AuthProvider }
```
`login` `logout` `signup`のメソッドはそのままですが、認証関連の処理をすべてAuthProviderにまとめておきたいため、入れました。useEffectの中が若干複雑ですが、こうすることによってログインしていないときにログイン後の画面を一瞬表示されるのを防いでいます。

次に、ProtectedRouteを用意します。ログインしていないときに認証必要なページにアクセスしたときにログインページに飛ばす処理です。

```tsx:components/ProtectedRoute.tsx
import { useRouter } from "next/router"
import React, { useEffect } from "react"
import { useAuth } from "./AuthProvider"

const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
  const { session, loading } = useAuth()
  const router = useRouter()
  useEffect(() => {
    ;(async () => {
      if (!session && !loading) {
        router.push("/login")
        return
      }
    })()
  }, [])
  return <>{session && children}</>
}

export default ProtectedRoute
```

## 画面作成
login,signup,そして認証の必要なdashboardページを作成します。
```tsx:pages/login.tsx
import { useRouter } from "next/router"
import Link from "next/link"
import { useAuth } from "../components/AuthProvider"
import { useForm } from "react-hook-form"
export default function Login() {
  const { session, login } = useAuth()
  const { register,handleSubmit,formState: { errors } } = useForm()
  const router = useRouter()
  if (session) {
    router.push("/dashboard")
  }
  const onSubmit = async (val) => {
    await login({ email: val.email, password: val.password }).then(()=>
      router.push('/dashboard')
    )
  }

  return (
    <div className="min-h-screen  grid place-items-center">
      <form
        onSubmit={handleSubmit(onSubmit)}
        className="bg-white w-1/4 shadow-md rounded px-8 py-6"
      >
        <h1 className="my-2 text-xl">LOGIN</h1>
        <div className="mb-4">
          <label className="block text-gray-700 text-sm font-bold mb-2">メールアドレス</label>
          <input
            {...register("email", { required: true })}
            className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
            type="text"
          />
          {errors.email && errors.email.message}
        </div>
        <div className="mb-4">
          <label className="block text-gray-700 text-sm font-bold mb-2">パスワード</label>
          <input
            {...register("password", { required: true })}
            className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
            type="password"
          />
          {errors.password && errors.password.message}
        </div>

        <div className="flex justify-between">
          <button
            type="button"
            className="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
          >
            戻る
          </button>
          <button
            type="submit"
            className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
          >
            送信
          </button>
        </div>
        <div className="flex justify-end mt-4 text-blue-700">  
          <Link href="/signup">Signup</Link>
        </div>
      </form>
    </div>
  )
}
```

```tsx:pages/signup.tsx
import { useRouter } from "next/router"
import { useAuth } from "../components/AuthProvider"
import { useForm } from "react-hook-form"
export default function Login() {
  const { session,signup } = useAuth()
  const {
    register,
    handleSubmit,
    getValues,
    formState: { errors },
  } = useForm()

  const router = useRouter()

  if (session) {
    router.push("/dashboard")
  }
  const onSubmit = async (val) => {
    await signup({ email: val.email, password: val.password })
      .then(()=>{
        router.push('/login')
        alert("メールを送信した。ご確認ください。")
      })
      .catch(e=>console.log(e))
  }

  return (
    <div className="min-h-screen grid place-items-center">
      <form
        onSubmit={handleSubmit(onSubmit)}
        className="bg-white w-1/4 shadow-md rounded px-8 py-6"
      >
        <h1 className="my-2 text-xl">SignUp</h1>
        <div className="mb-4">
          <label className="block text-gray-700 text-sm font-bold mb-2">メールアドレス</label>
          <input
            {...register("email", { required: "入力してください" })}
            className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
            type="text"
          />
          {errors.email && errors.email.message}
        </div>
        <div className="mb-4">
          <label className="block text-gray-700 text-sm font-bold mb-2">パスワード</label>
          <input
            {...register("password", { required: "入力してください" })}
            className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
            type="password"
          />
          {errors.password && errors.password.message}
        </div>

        <div className="mb-4">
          <label className="block text-gray-700 text-sm font-bold mb-2">パスワード（確認）</label>
          <input
            {...register("passwordConf", { required: "入力してください",validate: (value) => {
                return (
                  value === getValues("password") || "メールアドレスが一致しません"
                );
              } })}
            className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
            type="password"
          />
          {errors.passwordConf && errors.passwordConf.message}
        </div>

        <div className="flex justify-between">
          <button onClick={()=>router.push("/login")}
            type="submit"
            className="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
          >
            戻る
          </button>
          <button
            type="submit"
            className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
          >
            送信
          </button>
        </div>
      </form>
    </div>
  )
}
```

フォーム処理にはReact Hook Formを使っています。
そして、_app.tsxに先ほど作った認証処理をそっと包みます。

```tsx:_app.tsx
import { AppProps } from "next/app"
import { NextPage } from "next"
import { ReactNode, ReactElement, Suspense } from "react"
import { AuthProvider } from "../components/AuthProvider"
import { useRouter } from "next/router"
import ProtectedRoute from "../components/ProtectedRouter"
import "../styles/globals.css"

type NextPageWithLayout = NextPage & {
  getLayout?: (page: ReactElement) => ReactNode
}

type AppPropsWithLayout = AppProps & {
  Component: NextPageWithLayout
}

const noAuthRequired = ["/", "/login","/signup"]

export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
  const router = useRouter()
  return (
    <AuthProvider>
      {noAuthRequired.includes(router.pathname) ? (
        <Component {...pageProps} />
      ) : (
        <ProtectedRoute>
          <Component {...pageProps} />
        </ProtectedRoute>
      )}
    </AuthProvider>
  )
}

```
noAuthRequiredは認証不要なページです。そのページへのアクセスであればProtectedRouteを通さないよう条件分岐しています。
![localhost_3000_login.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267174/185a7ffe-ae74-511e-f20f-10e25d4da5f4.png)
![localhost_3000_signup.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267174/4c30ac5e-435a-f898-a157-547529aad29b.png)

これで、作成できました。あとはdashboardページを適当に作成してみれば、認証処理がちゃんとできていることを確認できると思います。

## 参考文献
Supabase公式リファレンス　https://www.supabase.jp/docs/
SupabaseのTS用の型情報を自動生成する　https://zenn.dev/k_kind/articles/supabase-type-generate