LoginSignup
3
0

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

Posted at

概要

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

中編 firebase authentication編

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

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

フォームのバリデーション

react-hook-formにはフォームの入力チェックをする機能があらかじめ用意されています。
その機能を使って、フォームのチェックをして、ルールに乗っていない場合、エラーが表示されて処理を実行できないようしましょう。
今回は、よくあるフォームチェックを実装していきます!

  • 必須項目
  • Emailは半角英数字
  • Emailの形式(XXX@XXX.XXX
  • パスワードは8文字以上の半角英数字
  • パスワードに大文字を1つ含む
  • パスワードと確認用のパスワードが一致している

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

ログイン画面

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です!
他のエラーも表示させてみてください!(画像での確認は割愛します・・)

新規登録画面

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つのエラーが表示されました!
これで、入力フォームのバリデーションが設定できました。

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

auth周りのエラーハンドリング

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

auth.tsのそれぞれの処理を以下のように更新します。

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に変更します。
このようにすることで、画面側では処理結果でメッセージタイプを分岐させ、メッセージをトースト通知で表示させることができます。
画面側

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を参考にしてください!

2. 新規登録エラーハンドリング

新規登録のエラーハンドリングについて説明します。
新規登録の場合に考えられるエラーとしては、すでにメールアドレスが登録されているパターンです。
そのため、auth/email-already-in-useというエラーコードを受けとると該当します。
※それ以外で何らかの不具合で新規登録できなかった場合に「新規登録に失敗しました」というエラーを表示させます。

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-foundauth/wrong-passwordです。
※それ以外で何らかの不具合でログインできなかった場合に「ログインに失敗しました」というエラーを表示させます。

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パターン
現在はこのユーザが登録されています。

下記の情報で、新規登録ボタンを押下します。

エラーが表示されました!新規登録はOKです!

ログインについても確認してみましょう!
auth/user-not-foundパターン
auth_testuser_02@test.comのユーザはいないので、下記のエラーが表示されます。

auth/wrong-passwordパターン
登録時にこのアカウントではPasswordでパスワードを設定したので、パスワードが間違っているというエラーが表示されました!

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

終わりに

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

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0