【後編】Next.jsでログインフォームを実装する 〜バリデーション、トリガー編〜
概要
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つのルールをログインフォームに追加していきます!
ログイン画面
'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です!
他のエラーも表示させてみてください!(画像での確認は割愛します・・)
新規登録画面
'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-found
とauth/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にも追加されるようにしようとしましたが、思いのほか長くなってしまったので、また次の機会に記事を投稿しようと思います!
ここまで読んでいただきありがとうございます!間違い等がありましたら、ご指摘の方をよろしくお願いします!!
Discussion