React(Next.js) × Firebaseで認証まわりをまるっと開発する
概要
タイトルのままですが、React(Next.js) × Firebaseの環境で、認証まわりをまるっと開発したので、記事に残します。
アラート等のコンポーネントはMaterial UIを使用しています。
やったこと
以下の4つです。
- ユーザー登録
- ログイン
- ログアウト
- アクセスコントロール
- 未ログイン時に特定のページに入ろうとしたらアラート & 遷移
- ログイン時にログインページなどに行ったらアラート & 遷移
前提
- react: 17.0.2
- next: 12.0.9
- typescript: 4.5.5
- firebase: 9.6.5
- emotion: 11.0.0
また、あらかじめfirebase.tsというところで以下の設定をしています。
import { initializeApp } from "firebase/app"
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGE_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
}
export const app = initializeApp(firebaseConfig)
ユーザー登録
とりあえずフォームを設けましょう。えい。
<form onSubmit={handleSubmit}>
<div
css={css`
display: flex;
justify-content: center;
align-items: center;
`}
>
<InputLabel>メールアドレス</InputLabel>
<TextField
name="email"
type="email"
size="small"
onChange={handleChangeEmail}
css={css`
padding-left: 12px;
`}
/>
</div>
<div
css={css`
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 16px;
`}
>
<InputLabel>パスワード</InputLabel>
<TextField
name="password"
type="password"
size="small"
onChange={handleChangePassword}
css={css`
padding-left: 12px;
`}
/>
</div>
<div
css={css`
display: flex;
justify-content: flex-end;
margin-top: 16px;
`}
>
<Button type="submit" variant="outlined">
登録
</Button>
</div>
<div
css={css`
display: flex;
justify-content: flex-end;
margin-top: 24px;
`}
>
<Link href={"/login"}>
<a>すでに登録している人はこちら</a>
</Link>
</div>
</form>
InputLabel, TextField, ButtonはMaterial UIで、LinkはNextのものです。
importはよしなにお願いします。
またCSSは適当です。
handleSubmit, handleChangeEmail, handleChangePasswordが未定義ですね。用途は名前の通りなので、それぞれ実装していきましょう。
import { app } from "../src/firebase"
import { getAuth, createUserWithEmailAndPassword } from "firebase/auth"
const router = useRouter()
const auth = getAuth(app)
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
await createUserWithEmailAndPassword(auth, email, password)
router.push("/")
}
const handleChangeEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.currentTarget.value)
}
const handleChangePassword = (e: React.ChangeEvent<HTMLInputElement>) => {
setPassword(e.currentTarget.value)
}
handleChangeEmail, handleChangePasswordでやっていることはstateのsetのみです。文字が入力される都度それぞれのstateに値を保存します。
そして、保存したemail, passwordをFirebaseにぶん投げ、新規ユーザーを登録します。
それを行っているのが、handleSubmit内のauth.createUserWithEmailAndPassword(email, password)
です。
この一行でユーザー登録ができるので、Firebaseは強いですね。
ユーザー登録後は、ルートのページに遷移させます。ルートに飛ばしていますが、もちろんここはどこでも大丈夫です。
新規登録でやることは以上になります。
コード全文はこちらです。
Signup.tsx
import React, { useState } from "react"
import { useRouter } from "next/router"
import Link from "next/link"
import { Alert, Button, InputLabel, Snackbar, TextField } from "@mui/material"
import { css } from "@emotion/react"
import { getAuth, createUserWithEmailAndPassword } from "firebase/auth"
import { useAuthContext } from "../src/context/AuthContext"
import { app } from "../src/firebase"
const Signup = () => {
const router = useRouter()
const { user } = useAuthContext()
const auth = getAuth(app)
const isLoggedIn = !!user
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
await createUserWithEmailAndPassword(auth, email, password)
router.push("/")
}
const handleClose = async () => {
await router.push("/")
}
const handleChangeEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.currentTarget.value)
}
const handleChangePassword = (e: React.ChangeEvent<HTMLInputElement>) => {
setPassword(e.currentTarget.value)
}
return (
<div
css={css`
display: flex;
justify-content: space-between;
align-items: center;
flex-flow: column;
`}
>
<Snackbar
open={isLoggedIn}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
autoHideDuration={3000}
key={"top" + "center"}
onClose={handleClose}
>
<Alert onClose={handleClose} severity="warning">
すでにログインしています
</Alert>
</Snackbar>
<h2>ユーザー登録</h2>
<form onSubmit={handleSubmit}>
<div
css={css`
display: flex;
justify-content: center;
align-items: center;
`}
>
<InputLabel>メールアドレス</InputLabel>
<TextField
name="email"
type="email"
size="small"
onChange={handleChangeEmail}
css={css`
padding-left: 12px;
`}
/>
</div>
<div
css={css`
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 16px;
`}
>
<InputLabel>パスワード</InputLabel>
<TextField
name="password"
type="password"
size="small"
onChange={handleChangePassword}
css={css`
padding-left: 12px;
`}
/>
</div>
<div
css={css`
display: flex;
justify-content: flex-end;
margin-top: 16px;
`}
>
<Button type="submit" variant="outlined">
登録
</Button>
</div>
<div
css={css`
display: flex;
justify-content: flex-end;
margin-top: 24px;
`}
>
<Link href={"/login"}>
<a>すでに登録している人はこちら</a>
</Link>
</div>
</form>
</div>
)
}
export default Signup
下記のアクセスコントロールでやる内容も少し混ぜちゃっているので、SnackbarやuseAuthContext()など、上で説明していない内容はいったんスルーしていただけると幸いです。
また、完成形のUIはこちらになります。
なんてことないですね。次に行きましょう!
ログイン
ロジックはほぼ全くユーザー登録と同じです。
実質的にログイン処理を行うのは、ユーザー登録と同様一行だけです。
特筆することがないので、さっそくコード全文を載せてしまいます。
Login.tsx
import React, { useState } from "react"
import { useRouter } from "next/router"
import Link from "next/link"
import { Alert, Button, InputLabel, Snackbar, TextField } from "@mui/material"
import { css } from "@emotion/react"
import { getAuth, signInWithEmailAndPassword } from "firebase/auth"
import { useAuthContext } from "../src/context/AuthContext"
import { app } from "../src/firebase"
const Login = () => {
const { user } = useAuthContext()
const isLoggedIn = !!user
const router = useRouter()
const auth = getAuth(app)
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
await signInWithEmailAndPassword(auth, email, password)
router.push("/")
}
const handleChangeEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.currentTarget.value)
}
const handleChangePassword = (e: React.ChangeEvent<HTMLInputElement>) => {
setPassword(e.currentTarget.value)
}
const handleClose = async () => {
await router.push("/")
}
return (
<div
css={css`
display: flex;
justify-content: space-between;
align-items: center;
flex-flow: column;
`}
>
<Snackbar
open={isLoggedIn}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
autoHideDuration={3000}
key={"top" + "center"}
onClose={handleClose}
>
<Alert onClose={handleClose} severity="warning">
すでにログインしています
</Alert>
</Snackbar>
<Snackbar
open={!isLoggedIn}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
autoHideDuration={3000}
key={"top" + "center"}
>
<Alert severity="warning">ログインしてください</Alert>
</Snackbar>
<h2>ログイン</h2>
<form onSubmit={handleSubmit}>
<div
css={css`
display: flex;
justify-content: center;
align-items: center;
`}
>
<InputLabel>メールアドレス</InputLabel>
<TextField
name="email"
type="email"
size="small"
onChange={handleChangeEmail}
css={css`
padding-left: 12px;
`}
/>
</div>
<div
css={css`
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 16px;
`}
>
<InputLabel>パスワード</InputLabel>
<TextField
name="password"
type="password"
size="small"
onChange={handleChangePassword}
css={css`
padding-left: 12px;
`}
/>
</div>
<div
css={css`
display: flex;
justify-content: flex-end;
margin-top: 16px;
`}
>
<Button type="submit" variant="outlined">
ログイン
</Button>
</div>
<div
css={css`
display: flex;
justify-content: flex-end;
margin-top: 24px;
`}
>
ユーザ登録は
<Link href={"/signup"}>
<a>こちら</a>
</Link>
から
</div>
</form>
</div>
)
}
export default Login
await auth.signInWithEmailAndPassword(email, password)
の一行でログイン処理を行っています。メソッド名もわかりやすくていいですね。
こちらもユーザー登録同様、アクセスコントロールでやる内容が混ざっているので、内容はざっと見で問題ありません。
一応、完成形のUIはこちらになります。
ログアウト
以下のhandleLogputを、ログアウト用ボタンに仕込むだけです!
import { app } from "../firebase"
import { getAuth, signOut } from "firebase/auth"
import { useRouter } from "next/router"
const router = useRouter()
const auth = getAuth(app)
const handleLogout = async () => {
await signOut(auth)
await router.push("/about")
}
私の場合は、本記事では登場していない、ハンバーガーメニューの中のボタンに仕込みました。
とりあえずやることは上記の関数をonClickに入れるだけなので、こちらの全文・UIについては省略します。
それにしても、おおよその認証処理が一行で終わるの、ほんとに簡単ですごいですね。
アクセスコントロール
未ログイン時
未ログイン時、特定のページに入ろうとしたら、ログインページに強制遷移させた後に「ログインしていないからだめだよ〜」とアラートを出します。
特定と言っていますが、ほぼ全ページでこの処理は行いたいです。
なので、全てのpage componentに干渉できる、_app.tsxに何か追記しましょう。
追記をしたあとの_app.tsxのコード全文は、こんな感じです。
import { css } from "@emotion/react"
import Header from "../src/components/header"
import type { AppProps } from "next/app"
import { AuthProvider } from "../src/context/AuthContext"
const App = ({ Component, pageProps }: AppProps) => {
return (
<AuthProvider>
<Header />
<Component
{...pageProps}
/>
</AuthProvider>
)
}
export default App
Headerはただのヘッダーで、ハンバーガーメニューの表示に入れているだけなのですが、AuthProviderが何奴という感じですね。
AuthProviderはざっくり、現在ログインしているかどうかを監視するためのコンポーネントです。
監視により、していたら〇〇、していなかったら××という処理をする、ということにつなげることができます。
ログインの監視は、onAuthStateChangedというメソッドにより実現可能です。
このメソッドは例えば以下のようにして使います。
onAuthStateChanged((user) => {
if (user) {
// ログイン時の処理
} else {
// 未ログイン時の処理
}
});
ログインをしていた場合は、userにはユーザー情報が入っており、していなかったらnullが入っています。
この仕様を使って、ログイン時と未ログイン時の処理をそれぞれ実現できるというわけです。
このonAuthStateChangedを、AuthProviderの中で使います。
今回の要件は、「未ログイン時に特定のページに入ったら、ログインページに強制遷移させた後にアラートを出す」なので、どこかのページに入るたびに「ログインしているかどうか」「特定のページにいるかどうか」をチェックしたいです。
こういうのはuseEffectで実装しましょう。
というわけで、AuthContextは以下のように実装しています。
AuthContext.tsx
import { ReactNode, createContext, useState, useContext, useEffect } from "react"
import { getAuth, onAuthStateChanged } from "firebase/auth"
import type { User } from "firebase/auth"
import { useRouter } from "next/router"
import { app } from "../firebase"
export type UserType = User | null
export type AuthContextProps = {
user: UserType
}
export type AuthProps = {
children: ReactNode
}
const AuthContext = createContext<Partial<AuthContextProps>>({})
export const useAuthContext = () => {
return useContext(AuthContext)
}
export const AuthProvider = ({ children }: AuthProps) => {
const router = useRouter()
const auth = getAuth(app)
const [user, setUser] = useState<UserType>(null)
const isAvailableForViewing =
router.pathname === "/about" ||
router.pathname === "/login" ||
router.pathname === "/signup"
const value = {
user,
}
useEffect(() => {
const authStateChanged = onAuthStateChanged(auth, async (user) => {
setUser(user)
!user && !isAvailableForViewing && (await router.push("/login"))
})
return () => {
authStateChanged()
}
}, [])
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
!user && !isAvailableForViewing && (await router.push("/login"))
のところで、「現在未ログインで、閲覧可能のページに入っていた場合は、ログインページに遷移させています。
また、「現在ユーザーがログインしているかどうか」という値は他のコンポーネントでも横断して使用したいので、useContextでそれを実現しています。
これで、「未ログイン時に特定のページに入ったら、ログインページに強制遷移させた後にアラートを出す」の半分はできました。
あとはログインページでアラートを出しましょう。
const Login = () => {
const { user } = useAuthContext()
const isLoggedIn = !!user
return (
...(中略)...
<Snackbar
open={!isLoggedIn}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
autoHideDuration={3000}
key={"top" + "center"}
>
<Alert severity="warning">ログインしてください</Alert>
</Snackbar>
...(中略)...
)
}
既に載っけていますが、必要な箇所だけ抜き出してLogin.tsxを再掲します。
useAuthContext().userが、一つ上でローカル全体でアクセスできるようになった、「ログインしていたらユーザー情報が入っているプロパティ」です。
それをisLoggedInとしてboolに変換したあと、falseであればログインしていないということなので、アラートとなるSnackbarを表示させています。
Material UIのAlertをSnackbarに入れているのは、Alertには表示させるか否かのpropsがないためです。公式をまねて書きました。
これでなんやかんやと実装できました。
アラートのUIはこんな感じです。
ログイン時
ログインページ・新規登録ページに行ったら、「すでにログインしているよ〜」とアラートを出し、強制遷移させます。
こちらの処理も、既にLogin.tsx, Signup.tsx全文で載っけていますが、必要な箇所だけ再掲します。
const Login = () => {
const { user } = useAuthContext()
const isLoggedIn = !!user
const handleClose = async () => {
await router.push("/")
}
return (
...(中略)...
<Snackbar
open={isLoggedIn}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
autoHideDuration={3000}
key={"top" + "center"}
onClose={handleClose}
>
<Alert onClose={handleClose} severity="warning">
すでにログインしています
</Alert>
</Snackbar>
...(中略)...
)
}
Signup.tsxの一部を載せていますが、Login.tsxにも同様のことが書かれています。
未ログイン時のアクセスコントロールでも触れた通り、useAuthContext().userが「ログインしていたらユーザー情報が入っているプロパティ」で、isLoggedInがそのboolです。
なので、ログインしている状態で新規登録ページ、ログインページに入ると、まずこのアラートが表示されます。
そして、onCloseでhandleCloseを発火させ、ルートのページに強制移動させるようにしています。
これにてアクセスコントロールも完了です!🙏
一応、ログイン時のアラートのUIも載せておきます。
所感
いろいろな開発者向けサービスを併せて新しいアプリケーションを作るとき、たまに「FirebaseはAuthだけ使用する」というケースが散見されます。
その理由が少しわかったような気がしました。Firebase Authentication、かんたんですげ〜
参考記事
Discussion
compat は互換性のために残されている古いモジュールなので、今新しく作る場合は最新の v9 に対応した書き方にするのがよいと思います!
ご返信が遅れました🙇♂️
v8 -> v9の書き方に変更いたしました!
ご指摘いただき、ありがとうございました。