【後編】Next.jsでログインフォームを実装する 〜バリデーション、トリガー編〜

# 概要
2記事に渡って、Next.jsのログインフォームについて実装、解説してきましたが、この記事が最後の記事になります！
前回の記事で、auth周りの最低限の機能を実装しました。今回の記事は、フォームの入力チェックやauth周りのエラーハンドリング、~~authトリガーの実装~~の2点を実装、説明していきたいと思います
前編 chakra-ui×react-hook-form編

https://zenn.dev/ko_hei/articles/28e7709e187c0a

中編 firebase authentication編

https://zenn.dev/ko_hei/articles/4f47868cad4021

~~後編 バリデーションとトリガー編~~
後編 バリデーションとエラーハンドリング編

それでは実装に移っていきましょう。

# フォームのバリデーション
react-hook-formにはフォームの入力チェックをする機能があらかじめ用意されています。
その機能を使って、フォームのチェックをして、ルールに乗っていない場合、エラーが表示されて処理を実行できないようしましょう。
今回は、よくあるフォームチェックを実装していきます！
- 必須項目
- Emailは半角英数字
- Emailの形式(XXX@XXX.XXX）
- パスワードは8文字以上の半角英数字
- パスワードに大文字を1つ含む
- パスワードと確認用のパスワードが一致している

以上6つのルールをログインフォームに追加していきます！

## ログイン画面
```typescript: signin.tsx
'use client'
import NextLink from 'next/link'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { useForm } from 'react-hook-form'

import {
  Button,
  Flex,
  FormControl,
  FormErrorMessage,
  FormLabel,
  Heading,
  Input,
  InputGroup,
  InputRightElement,
  useToast,
  VStack,
} from '@/common/design'
import { signInWithEmail } from '@/lib/firebase/apis/auth'

// フォームで使用する変数の型を定義
type formInputs = {
  email: string
  password: string
}

/** サインイン画面
 * @screenname SignInScreen
 * @description ユーザのサインインを行う画面
 */
export default function SignInScreen() {
  const toast = useToast()
  const router = useRouter()
  const {
    handleSubmit,
    register,
    formState: { errors, isSubmitting },
  } = useForm<formInputs>()

  const [show, setShow] = useState<boolean>(false)

  const onSubmit = handleSubmit(async (data) => {
    // バリデーションチェック
    await signInWithEmail({
      email: data.email,
      password: data.password,
    }).then((res: boolean) => {
      if (res) {
        console.log('ログイン成功')
      } else {
        console.log('ログイン失敗')
      }
    })
  })
  return (
    <Flex
      flexDirection='column'
      width='100%'
      height='100vh'
      justifyContent='center'
      alignItems='center'
    >
      <VStack spacing='5'>
        <Heading>ログイン</Heading>
        <form onSubmit={onSubmit}>
          <VStack spacing='4' alignItems='left'>
            <FormControl isInvalid={Boolean(errors.email)}>
              <FormLabel htmlFor='email' textAlign='start'>
                メールアドレス
              </FormLabel>
              <Input
                id='email'
                {...register('email', {
                  required: '必須項目です',
                  maxLength: {
                    value: 50,
                    message: '50文字以内で入力してください',
                  },
                })}
              />
	      // エラーが表示される
              <FormErrorMessage>
                {errors.email && errors.email.message}
              </FormErrorMessage>
            </FormControl>

            <FormControl isInvalid={Boolean(errors.password)}>
              <FormLabel htmlFor='password'>パスワード</FormLabel>
              <InputGroup size='md'>
                <Input
                  pr='4.5rem'
                  type={show ? 'text' : 'password'}
                  {...register('password', {
                    required: '必須項目です',
                    minLength: {
                      value: 8,
                      message: '8文字以上で入力してください',
                    },
                    maxLength: {
                      value: 50,
                      message: '50文字以内で入力してください',
                    },
                  })}
                />
                <InputRightElement width='4.5rem'>
                  <Button h='1.75rem' size='sm' onClick={() => setShow(!show)}>
                    {show ? 'Hide' : 'Show'}
                  </Button>
                </InputRightElement>
              </InputGroup>
	      // エラーが表示される
              <FormErrorMessage>
                {errors.password && errors.password.message}
              </FormErrorMessage>
            </FormControl>
            <Button
              marginTop='4'
              color='white'
              bg='teal.400'
              isLoading={isSubmitting}
              type='submit'
              paddingX='auto'
              _hover={{
                borderColor: 'transparent',
                boxShadow: '0 7px 10px rgba(0, 0, 0, 0.3)',
              }}
            >
              ログイン
            </Button>
            <Button
              as={NextLink}
              bg='white'
              color='black'
              href='/signup'
              width='100%'
              _hover={{
                borderColor: 'transparent',
                boxShadow: '0 7px 10px rgba(0, 0, 0, 0.3)',
              }}
            >
              新規登録はこちらから
            </Button>
          </VStack>
        </form>
      </VStack>
    </Flex>
  )
}
```
ログイン画面では、Email、パスワード共に必須入力で、最大文字数が50文字に設定しています。
そして、パスワードは8文字以上の入力に設定しています。
必須の入力のエラーを確認してみましょう！下記のように表示されていればOKです！
他のエラーも表示させてみてください！（画像での確認は割愛します・・）
![](https://storage.googleapis.com/zenn-user-upload/a624bf2f6948-20230904.png)

## 新規登録画面
```typescript: signin.tsx
'use client'
import NextLink from 'next/link'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { useForm } from 'react-hook-form'

import {
  Button,
  Flex,
  FormControl,
  FormErrorMessage,
  FormLabel,
  Heading,
  Input,
  InputGroup,
  InputRightElement,
  useToast,
  VStack,
} from '@/common/design'
import { signUpWithEmail } from '@/lib/firebase/apis/auth'

// フォームで使用する変数の型を定義
type formInputs = {
  email: string
  password: string
  confirm: string
}

/** サインアップ画面
 * @screenname SignUpScreen
 * @description ユーザの新規登録を行う画面
 */
export default function SignUpScreen() {
  const router = useRouter()
  const toast = useToast()
  const {
    handleSubmit,
    register,
    getValues,
    formState: { errors, isSubmitting },
  } = useForm<formInputs>()

  const [password, setPassword] = useState(false)
  const [confirm, setConfirm] = useState(false)

  const onSubmit = handleSubmit(async (data) => {
    await signUpWithEmail({
      email: data.email,
      password: data.password,
    }).then((res: boolean) => {
      if (res) {
        console.log('ログイン成功')
      } else {
        console.log('ログイン失敗')
      }
    })
  })

  const passwordClick = () => setPassword(!password)
  const confirmClick = () => setConfirm(!confirm)

  return (
    <Flex height='100vh' justifyContent='center' alignItems='center'>
      <VStack spacing='5'>
        <Heading>新規登録</Heading>
        <form onSubmit={onSubmit}>
          <VStack alignItems='left'>
            <FormControl isInvalid={Boolean(errors.email)}>
              <FormLabel htmlFor='email' textAlign='start'>
                メールアドレス
              </FormLabel>
              <Input
                id='email'
                {...register('email', {
                  required: '必須項目です',
                  maxLength: {
                    value: 50,
                    message: '50文字以内で入力してください',
                  },
                  pattern: {
                    value:
                      /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@+[a-zA-Z0-9-]+\.+[a-zA-Z0-9-]+$/,
                    message: 'メールアドレスの形式が違います',
                  },
                })}
              />
	      // エラーが表示される
              <FormErrorMessage>
                {errors.email && errors.email.message}
              </FormErrorMessage>
            </FormControl>

            <FormControl isInvalid={Boolean(errors.password)}>
              <FormLabel htmlFor='password'>パスワード</FormLabel>
              <InputGroup size='md'>
                <Input
                  pr='4.5rem'
                  type={password ? 'text' : 'password'}
                  {...register('password', {
                    required: '必須項目です',
                    minLength: {
                      value: 8,
                      message: '8文字以上で入力してください',
                    },
                    maxLength: {
                      value: 50,
                      message: '50文字以内で入力してください',
                    },
                    pattern: {
                      value: /^(?=.*[A-Z])[0-9a-zA-Z]*$/,
                      message:
                        '半角英数字かつ少なくとも1つの大文字を含めてください',
                    },
                  })}
                />
                <InputRightElement width='4.5rem'>
                  <Button h='1.75rem' size='sm' onClick={passwordClick}>
                    {password ? 'Hide' : 'Show'}
                  </Button>
                </InputRightElement>
              </InputGroup>
	      // エラーが表示される
              <FormErrorMessage>
                {errors.password && errors.password.message}
              </FormErrorMessage>
            </FormControl>

            <FormControl isInvalid={Boolean(errors.confirm)}>
              <FormLabel htmlFor='confirm'>パスワード確認</FormLabel>
              <InputGroup size='md'>
                <Input
                  pr='4.5rem'
                  type={confirm ? 'text' : 'password'}
                  {...register('confirm', {
                    required: '必須項目です',
                    minLength: {
                      value: 8,
                      message: '8文字以上で入力してください',
                    },
                    maxLength: {
                      value: 50,
                      message: '50文字以内で入力してください',
                    },
                    pattern: {
                      value: /^(?=.*[A-Z])[0-9a-zA-Z]*$/,
                      message:
                        '半角英数字かつ少なくとも1つの大文字を含めてください',
                    },
                    validate: (value) =>
                      value === getValues('password') ||
                      'パスワードが一致しません',
                  })}
                />
                <InputRightElement width='4.5rem'>
                  <Button h='1.75rem' size='sm' onClick={confirmClick}>
                    {confirm ? 'Hide' : 'Show'}
                  </Button>
                </InputRightElement>
              </InputGroup>
	      // エラーが表示される
              <FormErrorMessage>
                {errors.confirm && errors.confirm.message}
              </FormErrorMessage>
            </FormControl>

            <Button
              marginTop='4'
              color='white'
              bg='teal.400'
              isLoading={isSubmitting}
              type='submit'
              paddingX='auto'
              _hover={{
                borderColor: 'transparent',
                boxShadow: '0 7px 10px rgba(0, 0, 0, 0.3)',
              }}
            >
              新規登録
            </Button>
          </VStack>
        </form>
        <Button
          as={NextLink}
          href='/signin'
          bg='white'
          width='100%'
          _hover={{
            borderColor: 'transparent',
            boxShadow: '0 7px 10px rgba(0, 0, 0, 0.3)',
          }}
        >
          ログインはこちらから
        </Button>
      </VStack>
    </Flex>
  )
}
```
新規登録画面では、メールアドレスの形式とパスワードの形式、確認用パスワードと一致しているか確認する入力規則を設定してます。※それ以外は、ログイン画面と同じなので、割愛します。

### メールアドレス
メールアドレスは、半角英数字、記号で形成されます。また、アドレス形式`xxx@xx.xx`の形式でなければエラーが表示されます。
```
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@+[a-zA-Z0-9-]+\.+[a-zA-Z0-9-]+$/
```

### パスワード
パスワードの形式は、大文字の英字が1文字入っているかつ半角英数字で入力しなければエラーが表示されます。
```
/^(?=.*[A-Z])[0-9a-zA-Z]*$/
```

### 確認用パスワード
確認用パスワードは、パスワード欄と、確認パスワードが一致しているかどうかを調べます。
```
// getValuesを使用できるようにする
const {
  handleSubmit,
  register,
  getValues,
  formState: { errors, isSubmitting },
} = useForm<formInputs>()
..省略..
// 確認用パスワードのパターンの1つ
validate: (value) => value === getValues('password') ||
                      'パスワードが一致しません',
```

### 全てのエラーを表示
エラーを確認してみましょう！下記のように3つのエラーが表示されました！
これで、入力フォームのバリデーションが設定できました。
![](https://storage.googleapis.com/zenn-user-upload/72efea70083c-20230904.png)

※ 細かい設定など追加をしていますが、入力規則周りを中心に解説をしました。`isLoading={isSubmitting}`、`isInvalid={Boolean(errors.email)}`などは、公式ドキュメントを確認してみてください。

# auth周りのエラーハンドリング
現状のauthの設定では、いくつか結果がエラーで返ってくるケースがあります。
例えば、ログインだと、パスワードが一致していないやユーザ情報がないなどのエラーがあります。
新規登録では、すでにユーザが登録されているケースが挙げられるます。これを全て同じエラーとして返すとユーザは何のエラーなのかわかりません。
そのため、firebase authから返ってくるエラーコードでユーザに対して、どういったエラーだよって表示できるようにエラーハンドリングをしていきます。

`auth.ts`のそれぞれの処理を以下のように更新します。
```typescript
import {
  createUserWithEmailAndPassword, GoogleAuthProvider, signInWithEmailAndPassword, signInWithPopup,
  signOut
} from 'firebase/auth';

import { auth } from '@/lib/config';

/** firebaseの処理結果 */
export type FirebaseResult = {
  isSuccess: boolean
  message: string
}

/** firebaseのエラー */
type FirebaseError = {
  code: string
  message: string
  name: string
}

const isFirebaseError = (e: Error): e is FirebaseError => {
  return 'code' in e && 'message' in e
}

/**
 * EmailとPasswordでサインイン
 * @param email
 * @param password
 * @returns Promise<FirebaseResult>
 */
export const signInWithEmail = async (args: {
  email: string
  password: string
}): Promise<FirebaseResult> => {
  let result: FirebaseResult = { isSuccess: false, message: '' }
  try {
    const user = await signInWithEmailAndPassword(
      auth,
      args.email,
      args.password
    )

    if (user) {
      result = { isSuccess: true, message: 'ログインに成功しました' }
    }
  } catch (error) {
    if (
      error instanceof Error &&
      isFirebaseError(error) &&
      error.code === 'auth/user-not-found'
    ) {
      result = { isSuccess: false, message: 'ユーザが見つかりませんでした' }
    } else if (
      error instanceof Error &&
      isFirebaseError(error) &&
      error.code === 'auth/wrong-password'
    ) {
      result = { isSuccess: false, message: 'パスワードが間違っています' }
    } else {
      result = { isSuccess: false, message: 'ログインに失敗しました' }
    }
  }
  return result
}

/**
 * EmailとPasswordでサインアップ
 * @param username
 * @param email
 * @param password
 * @returns Promise<FirebaseResult>
 */
export const signUpWithEmail = async (args: {
  email: string
  password: string
}): Promise<FirebaseResult> => {
  let result: FirebaseResult = { isSuccess: false, message: '' }
  try {
    const user = await createUserWithEmailAndPassword(
      auth,
      args.email,
      args.password
    )
    if (user) {
      result = { isSuccess: true, message: '新規登録に成功しました' }
    }
  } catch (error) {
    if (
      error instanceof Error &&
      isFirebaseError(error) &&
      error.code === 'auth/email-already-in-use'
    ) {
      result = {
        isSuccess: false,
        message: 'メールアドレスが既に使用されています',
      }
    } else {
      result = { isSuccess: false, message: '新規登録に失敗しました' }
    }
  }
  return result
}

/**
 * ログアウト処理
 * @returns Promise<FirebaseResult>
 */
export const logout = async (): Promise<FirebaseResult> => {
  let result: FirebaseResult = { isSuccess: false, message: '' }

  await signOut(auth)
    .then(() => {
      result = { isSuccess: true, message: 'ログアウトしました' }
    })
    .catch((error) => {
      result = { isSuccess: false, message: error.message }
    })

  return result
}
```

### 1. 返り値を変更
メソッドの返り値を`boolean`から処理結果`boolean`メッセージ`string`に変更します。
このようにすることで、画面側では処理結果でメッセージタイプを分岐させ、メッセージをトースト通知で表示させることができます。
画面側
```typescript
await signInWithEmail({
email: data.email,
password: data.password,
}).then((res: FirebaseResult) => {
  if (res.isSuccess) {
    toast({
      title: res.message,
      status: 'success',
      duration: 2000,
      isClosable: true,
    })
  } else {
      toast({
        title: res.message, 
        status: 'error',
        duration: 2000,
        isClosable: true,
      })
  }
})
```
※トースト通知は下記のURLを参考にしてください！

https://chakra-ui.com/docs/components/toast

### 2. 新規登録エラーハンドリング
新規登録のエラーハンドリングについて説明します。
新規登録の場合に考えられるエラーとしては、すでにメールアドレスが登録されているパターンです。
そのため、`auth/email-already-in-use`というエラーコードを受けとると該当します。
※それ以外で何らかの不具合で新規登録できなかった場合に「新規登録に失敗しました」というエラーを表示させます。
```typescript
if (
  error instanceof Error &&
  isFirebaseError(error) &&
  error.code === 'auth/email-already-in-use'
) {
  result = {
    isSuccess: false,
    message: 'メールアドレスが既に使用されています',
  }
} else {
  result = { isSuccess: false, message: '新規登録に失敗しました' }
}
```

### 3. ログインエラーハンドリング
次にログインのエラーハンドリングについて説明します。
ログインの場合に考えられるエラーとしては、一致するメールアドレスがない、パスワードが間違っているの2パターンです。
エラーコードは、`auth/user-not-found`と`auth/wrong-password`です。
※それ以外で何らかの不具合でログインできなかった場合に「ログインに失敗しました」というエラーを表示させます。
```typescript
if (
  error instanceof Error &&
  isFirebaseError(error) &&
  error.code === 'auth/user-not-found'
) {
    result = { isSuccess: false, message: 'ユーザが見つかりませんでした' }
  } else if (
    error instanceof Error &&
    isFirebaseError(error) &&
    error.code === 'auth/wrong-password'
  ) {
    result = { isSuccess: false, message: 'パスワードが間違っています' }
  } else {
    result = { isSuccess: false, message: 'ログインに失敗しました' }
  }
}
```

### 4. エラーの表示
最後にエラーを確認してましょう！

`auth/email-already-in-use`パターン
現在はこのユーザが登録されています。
![](https://storage.googleapis.com/zenn-user-upload/7d92a0cd33ff-20230904.png)
下記の情報で、新規登録ボタンを押下します。
![](https://storage.googleapis.com/zenn-user-upload/ba404008b029-20230904.png)
エラーが表示されました！新規登録はOKです！
![](https://storage.googleapis.com/zenn-user-upload/03f45f3ed309-20230904.png)

ログインについても確認してみましょう！
`auth/user-not-found`パターン
`auth_testuser_02@test.com`のユーザはいないので、下記のエラーが表示されます。
![](https://storage.googleapis.com/zenn-user-upload/6bd7e64c36aa-20230904.png)

`auth/wrong-password`パターン
登録時にこのアカウントでは`Password`でパスワードを設定したので、パスワードが間違っているというエラーが表示されました！
![](https://storage.googleapis.com/zenn-user-upload/836eca192ae1-20230904.png)

ログイン画面もこれでOKです！これでエラーハンドリングの実装は完了です！お疲れ様でした！

# 終わりに
今回は、全3編に分けて、Next.jsとFirebase、Chakra-ui、react-hook-formの4つのパッケージを使用したログイン、新規登録フォームを実装しました！
アプリ開発当初は正常パターンの処理にしか目がいきませんでしたが、最近はバリデーションやエラーハンドリングも大切だと感じています。
本当は後編でauthにユーザを新規登録したタイミングでトリガーを実装し、cloudfirestoreにも追加されるようにしようとしましたが、思いのほか長くなってしまったので、また次の機会に記事を投稿しようと思います！
ここまで読んでいただきありがとうございます！間違い等がありましたら、ご指摘の方をよろしくお願いします！！