React+Firebase Cloud Functionsでログイン画面を作成する

ReactプロジェクトにCloud Functions for Firebaseを使って、ユーザー情報の新規登録とログイン機能を実装する方法を紹介します。これからログイン画面を導入したい方に向けて手助けになれば幸いです。

はじめに

今回はReactとFirebaseの環境構築が完了している前提で進めていきます。ReactとFirebaseの環境構築手順は別の記事にまとめてありますので参考にしてみてください。

React×Typescriptの開発環境をViteで構築する

React+Firebaseの環境構築する

【開発環境】

React 18.2.0

Node.js 20.9.0

Typescript 5.3.3

Vite 5.0.8

Firebase 13.0.3

emotion/react 11.11.3

新規登録・ログインに必要な機能と画面の作成

Cloud Functionsの設定をする

Firebaseの料金プランの変更

Cloud Functions for Firebase(以下Cloud Functions)を使用するため、プランを従量制に変更しておきましょう。

プランを変更せずにデプロイを行うとエラーが発生します。(エラー時に表示されたURLからプラン変更することも可能です)

Cloud Functionsを初期化する

以下のコマンドを実行しCloud Functionsの設定を行います。

$ firebase init functions

? Please select an option: (Use arrow keys)
❯ Use an existing project
  Create a new project
  Add Firebase to an existing Google Cloud Platform project
  Don't set up a default project

=== Project Setup

First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add,
but for now we'll just set up a default project.

? Please select an option: Use an existing project
? Select a default Firebase project for this directory:
❯ react-project-8bcd1 (react-project)
(Move up and down to reveal more choices)

=== Functions Setup
Let's create a new codebase for your functions.
A directory corresponding to the codebase will be created in your project
with sample code pre-configured.

See https://firebase.google.com/docs/functions/organize-functions for
more information on organizing your functions using codebases.

Functions can be deployed with firebase deploy.

? What language would you like to use to write Cloud Functions?
  JavaScript
❯ TypeScript
  Python

? Do you want to use ESLint to catch probable bugs and enforce style? No
✔  Wrote functions/package.json
✔  Wrote functions/tsconfig.json
✔  Wrote functions/src/index.ts
✔  Wrote functions/.gitignore
? Do you want to install dependencies with npm now? Yes

added 517 packages, and audited 518 packages in 50s

47 packages are looking for funding
  run `npm fund` for details

25 moderate severity vulnerabilities

To address issues that do not require attention, run:
  npm audit fix

To address all issues (including breaking changes), run:
  npm audit fix --force

Run `npm audit` for details.

i  Writing configuration info to firebase.json...
i  Writing project information to .firebaserc...

✔  Firebase initialization complete!

無事設定が完了するとfunctionsディレクトリが作成されるので、以下のように変更します。

vite-project
├── functions
            ├── node_modules
            ├── src
            │        ├── auth
            │        │        ├── createUser.ts
            │        │        └── index.ts
            │        └── index.ts
            ├── .gitignore
            ├── package-lock.json
            ├── package.json
            ├── tsconfig.json

新規登録するための関数を作成する

関数の作成

以下の処理では、メールアドレスとパスワードを使ってAuthenticationにユーザー情報の登録を行います。その後、Firestoreのusersコレクションに登録されたユーザー情報を保存します。

functions/src/auth/createUser.ts

import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'

admin.initializeApp()

export const createUser = functions
  .region('asia-northeast1')
  .https.onCall(async data => {
    try {
      const userAuth = await admin.auth().createUser({
        email: data.email,
        password: data.password
      })
      if (!userAuth) {
        throw new functions.https.HttpsError(
          'unauthenticated',
          'User not authenticated'
        )
      }

      const uid = userAuth.uid

      // Firestoreへユーザー情報を登録する
      const userDocRef = admin.firestore().doc(`users/${uid}`)
      await userDocRef.set({
        id: uid,
        email: data.email,
        createdAt: admin.firestore.FieldValue.serverTimestamp()
      })

      return { success: true }
    } catch (error) {
      console.log(error)
      throw new functions.https.HttpsError(
        'internal',
        'An error occurred while creating the user.'
      )
    }
  })

functions/src/auth/index.ts

export { createUser } from './createUser'

functions/src/index.ts

export * from './auth'

関数のデプロイ

以下のコマンドを実行して、Cloud Functionsに関数をデプロイします。

$ firebase deploy --only functions

=== Deploying to 'react-project-8bcd1'...

i  deploying functions
Running command: npm --prefix "$RESOURCE_DIR" run build

> build
> tsc

✔  functions: Finished running predeploy script.
i  functions: preparing codebase default for deployment
i  functions: ensuring required API cloudfunctions.googleapis.com is enabled...
i  functions: ensuring required API cloudbuild.googleapis.com is enabled...
i  artifactregistry: ensuring required API artifactregistry.googleapis.com is enabled...
✔  functions: required API cloudbuild.googleapis.com is enabled
✔  functions: required API cloudfunctions.googleapis.com is enabled
✔  artifactregistry: required API artifactregistry.googleapis.com is enabled
i  functions: Loading and analyzing source code for codebase default to determine what to deploy
Serving at port 8623

i  functions: preparing functions directory for uploading...
i  functions: packaged /Users/tetsuoohtaguchi/ghq/github.com/TetsuoOhtaguchi/react-project-8bcd1/functions (71.78 KB) for uploading
✔  functions: functions folder uploaded successfully
i  functions: creating Node.js 18 (1st Gen) function createUser(asia-northeast1)...
✔  functions[createUser(asia-northeast1)] Successful create operation.
i  functions: cleaning up build files...

✔  Deploy complete!

デプロイが無事完了するとCloud Functionsに作成した関数が登録されます。

コンポーネントを作成する

ディレクトリ

新規登録、ログイン画面で使用するコンポーネントを作成します。今回、ディレクトリは以下のような構造にします。

vite-project
├── functions
├── node_modules
├── public
├── src
            ├── components
            │        ├── pages
            │        │        ├── LoginPage.tsx
            │        │        └── SignupPage.tsx
            │        └── ui
            │                    ├── button
            │                    │        └── Button.tsx
            │                    ├── input
            │                    │        └── Input.tsx
            │                    └── index.ts
            ├── App.css
            ├── App.tsx
            ├── firebase.ts
            ├── index.css
            ├── main.tsx
            └── vite-env.d.ts

Button

src/components/ui/button/Button.tsx

/** @jsxImportSource @emotion/react */
import React, { MouseEventHandler } from 'react'
import { css } from '@emotion/react'

const button = css`
  padding: 0;
  outline: none;
  font: inherit;
  color: inherit;
  background: none;
  cursor: pointer;
  border: solid 1px;
  width: 100%;
  height: 36px;
  font-size: 14px;
  font-weight: 600;
`

interface ButtonProps {
  onClick: MouseEventHandler<HTMLButtonElement>
  child: React.ReactNode
}

const Button: React.FC<ButtonProps> = ({ onClick, child }) => {
  return (
    <button css={button} onClick={onClick}>
      {child}
    </button>
  )
}

export default Button

Input

src/components/ui/input/Input.tsx

/** @jsxImportSource @emotion/react */
import React, { ChangeEvent } from 'react'
import { css } from '@emotion/react'

interface InputProps {
  modelValue: string | number
  type:
    | 'text'
    | 'password'
    | 'checkbox'
    | 'radio'
    | 'submit'
    | 'reset'
    | 'button'
    | 'file'
    | 'hidden'
    | 'date'
    | 'email'
    | 'number'
    | 'tel'
    | 'url'
    | 'search'
  label: string
  onUpdateModelValue: (event: ChangeEvent<HTMLInputElement>) => void
}

const inputWrapper = css`
  display: flex;
  flex-direction: column;
`

const labelStyle = css`
  font-size: 14px;
  font-weight: 600;
`

const input = css`
  margin: 0;
  padding: 0 8px;
  background: none;
  border: none;
  border-radius: 0;
  outline: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  border-bottom: solid 1px;
  height: 36px;
`

const Input: React.FC<InputProps> = ({
  modelValue,
  type,
  label,
  onUpdateModelValue
}) => {
  return (
    <div css={inputWrapper}>
      <label css={labelStyle} htmlFor={label}>
        {label}
      </label>
      <input
        css={input}
        type={type}
        id={label}
        value={modelValue}
        onChange={onUpdateModelValue}
        autoComplete='off'
      />
    </div>
  )
}

export default Input

新規登録画面

rc/components/pages/SignupPage.tsx

/** @jsxImportSource @emotion/react */
import React, { ChangeEvent, MouseEventHandler, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { css } from '@emotion/react'
import Button from '../ui/button/Button'
import Input from '../ui/input/Input'
import { functions } from '../../firebase'
import { httpsCallable } from 'firebase/functions'

const signupSection = css`
  display: grid;
  place-items: center;
  height: 100vh;
`

const flexBox = css`
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 40px;
`

const title = css`
  font-size: 36px;
  font-weight: 600;
`

const loginLink = css`
  text-decoration: none;
  font-size: 14px;
  font-weight: 600;
  color: #000;
  cursor: pointer;
  text-align: center;
`

const SignupPage: React.FC = () => {
  const navigate = useNavigate()
  const [email, setEmail] = useState<string>('')
  const [password, setPassword] = useState<string>('')

  const emailUpdate = (event: ChangeEvent<HTMLInputElement>) => {
    setEmail(event.target.value)
  }

  const passwordUpdate = (event: ChangeEvent<HTMLInputElement>) => {
    setPassword(event.target.value)
  }

  // 新規登録処理を実行する
  const signupHandler: MouseEventHandler<HTMLButtonElement> = async event => {
    event.preventDefault()
    try {
      // Firebase Cloud Functionsを呼び出す
      // Authにユーザー情報を登録し、Firestoreにユーザー情報を保存する
      const createUserFunction = httpsCallable(functions, 'createUser')
      const result = await createUserFunction({ email, password })
      const data = result.data as { success: boolean }

      if (data.success) {
        // 新規登録が成功した場合、ログインページにリダイレクト
        navigate('/')
        setEmail('')
        setPassword('')
      }
    } catch (error) {
      alert('Error')
      setEmail('')
      setPassword('')
    }
  }

  return (
    <>
      <form css={signupSection}>
        <div css={flexBox}>
          <h2 css={title}>Signup</h2>
          <Input
            modelValue={email}
            type='text'
            label='Email'
            onUpdateModelValue={emailUpdate}
          />
          <Input
            modelValue={password}
            type='password'
            label='Password'
            onUpdateModelValue={passwordUpdate}
          />
          <Button onClick={signupHandler} child='Signup' />
          <Link css={loginLink} to={'/'}>
            Login
          </Link>
        </div>
      </form>
    </>
  )
}

export default SignupPage

ログイン画面

src/components/pages/LoginPage.tsx

/** @jsxImportSource @emotion/react */
import React, { ChangeEvent, MouseEventHandler, useState } from 'react'
import { Link } from 'react-router-dom'
import { css } from '@emotion/react'
import Button from '../ui/button/Button'
import Input from '../ui/input/Input'
import { signInWithEmailAndPassword, UserCredential } from 'firebase/auth'
import { auth } from '../../firebase'

const loginSection = css`
  display: grid;
  place-items: center;
  height: 100vh;
`

const flexBox = css`
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 40px;
`

const title = css`
  font-size: 36px;
  font-weight: 600;
`

const signupLink = css`
  text-decoration: none;
  font-size: 14px;
  font-weight: 600;
  color: #000;
  cursor: pointer;
  text-align: center;
`

const LoginPage: React.FC = () => {
  const [email, setEmail] = useState<string>('')
  const [password, setPassword] = useState<string>('')

  const emailUpdate = (event: ChangeEvent<HTMLInputElement>) => {
    setEmail(event.target.value)
  }

  const passwordUpdate = (event: ChangeEvent<HTMLInputElement>) => {
    setPassword(event.target.value)
  }

  const loginHandler: MouseEventHandler<HTMLButtonElement> = async event => {
      event.preventDefault()
    try {
      const credential: UserCredential = await signInWithEmailAndPassword(
        auth,
        email,
        password
      )
      const user = credential.user

      if (user) {
        alert('Success')
        setEmail('')
        setPassword('')
      }
    } catch (error) {
      alert('Error')
      setEmail('')
      setPassword('')
    }
  }

  return (
    <>
      <form css={loginSection}>
        <div css={flexBox}>
          <h2 css={title}>Login</h2>
          <Input
            modelValue={email}
            type='text'
            label='Email'
            onUpdateModelValue={emailUpdate}
          />
          <Input
            modelValue={password}
            type='password'
            label='Password'
            onUpdateModelValue={passwordUpdate}
          />
          <Button onClick={loginHandler} child='Login' />
          <Link css={signupLink} to={'/signup'}>
            Signup
          </Link>
        </div>
      </form>
    </>
  )
}

export default LoginPage

ルーティング設定を行う

以下のコマンドを実行し、react-router-domをインストールします。

$ npm i react-router-dom

react-router-domのインストールが完了したら、App.tsxを以下のように変更します。

src/App.tsx

import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import LoginPage from './components/pages/LoginPage'
import SignupPage from './components/pages/SignupPage'

function App () {
  return (
    <>
      <Router>
        <Routes>
          <Route path='/' element={<LoginPage />} />
          <Route path='/signup' element={<SignupPage />} />
        </Routes>
      </Router>
    </>
  )
}

export default App

ついでにindex.cssの中身も変更しておきます。

src/index.css

:root {
  max-width: 390px;
  margin: 0 auto;
  padding: 0 16px;
}

以下のコマンドを実行しlocalhostにログイン画面が表示されれば、一通りの作業は終了です。

$ npm run dev

新規登録してみる

ログイン画面のSignupをクリックし、新規登録画面に遷移します。

メールアドレスとパスワード(6桁以上の半角英数字)を入力し、Signupボタンをクリックします。

新規登録が完了するとログイン画面に遷移します。

AuthenticationとFirestoreにユーザー情報が登録されているか確認してみましょう。

無事ユーザー情報が登録されています。

ログインしてみる

ログイン画面で、先ほど登録したメールアドレスとパスワードを入力しLoginをクリックします。

無事ログインが成功するとAuthenticationのユーザー情報にログイン日が登録されます。

無事にログインできました。

まとめ

今回は、ReactプロジェクトにCloud Functionsを使って、ユーザーの新規登録とログイン画面を作成する方法をご紹介しました。Firebaseを使用することで、比較的簡単にログイン周りの機能を実装することが出来るため、ログイン画面の導入を検討されている方のお役に立てれば幸いです。