はじめに
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をインストールします。
npm install @supabase/supabase-js
ついでに、Supabase CLIもインストールしておきます。これがあると、ローカルでコンテナによる開発環境を立ち上げたり、DBのマイグレーションを管理したりできます。今回は、DBの各テーブルから型を取得するために使います。公式のドキュメントにはsupabase start
と書かれていますが、supabase link
を使えばDockerを立ち上げずとも型情報だけを取ってこれます。
brew install supabase/tap/supabase
supabase login #Access Tokenを貼る
supabase init
supabase link --project-ref <プロジェクトのReference ID> #
また、SupabaseのプロジェクトURLとAPIKEYをコピペします。ダッシュボード左下の歯車マークのProject SettingsのAPIという項目に載っているのでコピーします。
それを.envファイルに記述します
NEXT_PUBLIC_SUPABASE_URL=/*コピーしたプロジェクトURL*/
NEXT_PUBLIC_SUPABASE_ANON_KEY=/*コピーしたAPIKEY*/
そして、supabase-jsを使用するために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です。
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を用意します。ログインしていないときに認証必要なページにアクセスしたときにログインページに飛ばす処理です。
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ページを作成します。
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>
)
}
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に先ほど作った認証処理をそっと包みます。
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を通さないよう条件分岐しています。
これで、作成できました。あとはdashboardページを適当に作成してみれば、認証処理がちゃんとできていることを確認できると思います。
参考文献
Supabase公式リファレンス https://www.supabase.jp/docs/
SupabaseのTS用の型情報を自動生成する https://zenn.dev/k_kind/articles/supabase-type-generate