Next.js + Amplify + CognitoでSignUpページのUIを自前実装したい

## はじめに
Next.js(React) + Amplify + Cognitoという技術スタックでWebアプリのUIを開発したいと思い、いくつかの記事を参考にさせていただきました。

- [認証機能 :: Amplify SNS Workshop](https://amplify-sns.workshop.aws/ja/30_mock/10_auth.html)
- [AWS cognitoとReactでログインを実装する](https://zenn.dev/dove/articles/63494de652511c)
- [Amplify Docs | Authentication | Sign up, Sign in & Sign out](https://docs.amplify.aws/lib/auth/emailpassword/q/platform/js/)

提供されているUIライブラリ（@aws-amplify/ui-react）を利用する方法が一番簡単だと思いましたが、
@aws-amplify/ui-reactを利用せず、SignUpページを独自のUIで構築したいと思い、自分なりに調査し、考えて実装してみました。
これより良い実装方法やコードの書き方などいくらでもあると思うので、ぜひコメント等でご教授いただけましたら幸いです。

## 環境情報

```sh
$ node -v
v15.14.0

$ amplify -v
6.1.1
```

自分が作っているNext.jsアプリのディレクトリ構成を簡略的に示すと下記のようになっています。
（今回載せているコードの必要最小限のディレクトリ構成です）

```:ディレクトリ構成
app-name/
       ├ amplify/
       ├ public/
       ├ src/
       │   ├ pages/
       │   │     ├ _app.tsx
       │   │     ├ sign-up.tsx ← この記事で実装するのはSignUpだけです。
       │   │     ├ sign-in.tsx ← リンク先に指定しているため念のため記載しています。
       │   │     └ user-profile.tsx ← リダイレクト先に指定しているため念のため記載しています。
       │   │
       │   └ components/
       │              ├ Alerts/
       │              │      ├ ErrorAlert/
       │              │      │          └ index.tsx
       │              │      ├ Alert.d.ts
       │              │      └ index.ts
       │              ├ Button/
       │              │      ├ Button.d.ts
       │              │      └ index.tsx
       │              └ icons/
       ├ package.json       ├ ArrowLeftIcon.tsx
       ├ postcss.config.js  ├ LockIcon.tsx
       ├ tailwind.config.js ├ MailIcon.tsx
       ├ tsconfig.json      ├ MessageIcon.tsx
       └ yarn.lock          ├ UserIcon.tsx
                            ├ icon.d.ts
                            └ index.ts
```

使用しているnpmは以下の通りです。
（今回載せているコードで使用している必要最小限のnpm一覧です）

```json:package.json
{
  "dependencies": {
    "aws-amplify": "^4.3.3",
    "daisyui": "^1.10.0",
    "next": "^11.1.1",
    "react": "17.0.2",
    "react-dom": "17.0.2"
  },
  "devDependencies": {
    "@babel/core": "^7.14.8",
    "@types/node": "^15.6.1",
    "@types/react": "^17.0.6",
    "@types/react-dom": "^17.0.5",
    "autoprefixer": "^10.3.1",
    "babel-loader": "^8.2.2",
    "postcss": "^8.3.6",
    "tailwindcss": "^2.2.6",
    "typescript": "^4.3.2"
  }
}
```

TypeScriptの設定
<details>
<summary>tsconfig.json</summary>
<div>

```json:tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "src",
    "sourceMap": true,
    "noImplicitAny": true,
    "module": "esnext",
    "target": "es6",
    "jsx": "preserve",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": false,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true
  },
  "include": [
    "next-env.d.ts",
    "globals.d.ts",
    "src/**/*.ts",
    "src/**/*.tsx"
  ],
  "exclude": ["node_modules", ".next"]
}
```
</div>
</details>


UIのスタイリングに[TailwindCSS](https://tailwindcss.com)、[daisyUI](https://daisyui.com/)を使用しているので、念のため設定ファイルも載せておきます。

<details>
<summary>postcss.config.js</summary>
<div>

```js:postcss.config.js
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};
```
</div>
</details>

<details>
<summary>tailwind.config.js</summary>
<div>

```js:tailwind.config.js
module.exports = {
  purge: ['./src/pages/**/*.tsx', './src/components/**/*.tsx'],
  darkMode: false, // or 'media' or 'class'
  theme: {
    backgroundColor: (theme) => ({
      ...theme('colors'),
      primary: '#81B29A',
      secondary: '#F4F1DE',
      danger: '#E07A5F',
    }),
    extend: {},
  },
  variants: {
    extend: {},
  },
  plugins: [require('daisyui')],
  daisyui: {
    themes: [
      {
        default: {
          primary: '#81B29A',
          'primary-focus': '#719885',
          'primary-content': '#FFFFFF',

          secondary: '#F2CC8F',
          'secondary-focus': '#D5BA90',
          'secondary-content': '#FFFFFF',

          accent: '#E07A5F',
          'accent-focus': '#C68270',
          'accent-content': '#FFFFFF',

          neutral: '#3D4451',
          'neutral-focus': '#2A2E37',
          'neutral-content': '#FFFFFF',

          'base-100': '#F4F1DE',
          'base-200': '#F9FAFB',
          'base-300': '#D1D5DB',
          'base-content': '#3D405B',

          info: '#2094F3',
          success: '#009485',
          warning: '#FF9900',
          error: '#FF5724',
        },
      },
    ],
  },
};
```
</div>
</details>

## 画面設計

### 実現したいSignUp画面の完成予想図

![スクリーンショット 2021-10-31 14.43.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/174479/a920147a-dfea-bf51-42c4-3e249bf44a83.png)

## 仕様

下記のようにしました。

- username, email, passwordでSignUp
- usernameはユニーク
- usernameは3文字以上30文字以下
- passwordは12文字以上50文字以下で、大文字・小文字の英字と数字を含めなければならない
- passwordとconfirm passwordが一致している必要がある
- バックエンドとフロントエンド両方でバリデーションをかける
- Emailに認証コードを送信する
- 認証コードを使ってアカウントを有効化できる

## 実装

### 1. AmplifyにAuthを追加

```shell
$ amplify add auth
Using service: Cognito, provided by: awscloudformation

 The current configured provider is Amazon Cognito.

 Do you want to use the default authentication and security configuration? Manual configuration
 Select the authentication/authorization services that you want to use: User Sign-Up & Sign-In only (Best used with a cloud API only)
 Please provide a friendly name for your resource that will be used to label this category in the project: appname
 Please provide a name for your user pool: appname_user_pool
 Warning: you will not be able to edit these selections.
 How do you want users to be able to sign in? Username
 Do you want to add User Pool Groups? No
 Do you want to add an admin queries API? No
 Multifactor authentication (MFA) user login options: OPTIONAL (Individual users can use MFA)
 For user login, select the MFA types: SMS Text Message, Time-Based One-Time Password (TOTP)
 Please specify an SMS authentication message: AppName 認証コード：{####}
 Email based user registration/forgot password: Enabled (Requires per-user email entry at registration)
 Please specify an email verification subject: 確認コード | AppName
 Please specify an email verification message: 確認コード：{####}
 Do you want to override the default password policy for this User Pool? Yes
 Enter the minimum password length for this User Pool: 12
 Select the password character requirements for your userpool: Requires Lowercase, Requires Uppercase, Requires Numbers
 Warning: you will not be able to edit these selections.
 What attributes are required for signing up? Email
 Specify the apps refresh token expiration period (in days): 30
 Do you want to specify the user attributes this app can read and write? Yes
 Specify read attributes: Address, Birthdate, Email, Gender, Nickname, Phone Number, Preferred Username, Picture, Profile, Website
 Specify write attributes: Address, Birthdate, Gender, Nickname, Phone Number, Preferred Username, Picture, Profile, Website
 Do you want to enable any of the following capabilities?
 Do you want to use an OAuth flow? No
 Do you want to configure Lambda Triggers for Cognito? No
Successfully added auth resource appname locally
```

```shell
$ amplify push
```

### 2. _app.tsxにAmplifyの設定を追加

```tsx:src/pages/_app.tsx
import { Amplify } from 'aws-amplify'; // 追加
import { NextPage } from 'next';
import { AppProps } from 'next/app';
import awsExports from '../aws-exports'; // 追加
import 'tailwindcss/tailwind.css';

Amplify.configure({ ...awsExports, ssr: true }); // 追加

const MyApp: NextPage<AppProps> = ({ Component, pageProps }: AppProps) => {
  return <Component {...pageProps} />;
};

export default MyApp;
```

### 3. SignUpページのUI実装

こんな感じにしてみました。

```tsx:src/pages/sign-up.tsx
import { NextPage } from 'next';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState, useEffect } from 'react';

import { Button } from '../../components';
import { ErrorAlert } from '../../components/Alerts';
import { ArrowLeftIcon, LockIcon, MailIcon, MessageIcon, UserIcon } from '../../components/icons';

const SignUp: NextPage = () => {
  const [username, setUsername] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');
  const [code, setCode] = useState('');
  const [isSubmitted, setIsSubmitted] = useState(false);
  const [isError, setIsError] = useState(false);
  const [errorMessage, setErrorMessage] = useState('');
  const [user, setUser] = useState();
  const router = useRouter();

  return (
    <>
      {isError ? <ErrorAlert message={errorMessage} /> : <></>}
      {isSubmitted ? (
        <div className='card bg-white shadow-2xl w-96 p-10 m-auto sm:mt-10'>
          <div className='form-control'>
            <div className='flex m-1 p-2'>
              <label className='label'>
                <UserIcon classes='h-6 w-6 text-primary' />
              </label>
              <input
                name='username'
                type='text'
                placeholder='username'
                className='bg-white ml-1 p-2'
                value={username}
                onChange={handleInputChange}
              />
            </div>
            <div className='flex m-1 mb-10 p-2'>
              <label className='label'>
                <MessageIcon classes='h-6 w-6 text-primary' />
              </label>
              <input
                name='code'
                type='text'
                placeholder='verification code'
                className='bg-white ml-1 p-2'
                value={code}
                onChange={handleInputChange}
              />
            </div>
          </div>
          <Button size='large' label='activate account' onClick={confirmSignUp} />
        </div>
      ) : (
        <>
          <div className='card bg-white shadow-2xl w-96 p-10 m-auto sm:mt-10'>
            <div className='form-control'>
              <div className='mb-20'>
                <Link href='/'>
                  <a>
                    <ArrowLeftIcon classes='h-5 w-5' />
                  </a>
                </Link>
              </div>
              <div className='flex m-1 p-2'>
                <label className='label'>
                  <UserIcon classes='h-6 w-6 text-primary' />
                </label>
                <input
                  name='username'
                  type='text'
                  placeholder='username'
                  className='bg-white ml-1 p-2'
                  value={username}
                  onChange={handleInputChange}
                />
              </div>
              <div className='flex m-1 p-2'>
                <label className='label'>
                  <MailIcon classes='h-6 w-6 text-primary' />
                </label>
                <input
                  name='email'
                  type='email'
                  placeholder='user@email.com'
                  className='bg-white ml-1 p-2'
                  value={email}
                  onChange={handleInputChange}
                />
              </div>
              <div className='flex m-1 p-2'>
                <label className='label'>
                  <LockIcon classes='h-6 w-6 text-primary' />
                </label>
                <input
                  name='password'
                  type='password'
                  placeholder='password'
                  className='bg-white ml-1 p-2'
                  value={password}
                  onChange={handleInputChange}
                />
              </div>
              <div className='flex m-1 mb-10 p-2'>
                <label className='label'>
                  <LockIcon classes='h-6 w-6 text-primary' />
                </label>
                <input
                  name='confirmPassword'
                  type='password'
                  placeholder='confirm password'
                  className='bg-white ml-1 p-2'
                  value={confirmPassword}
                  onChange={handleInputChange}
                />
              </div>
            </div>
            <Button size='large' label='create user' onClick={signUp} />
          </div>
          <div className='mt-10 text-center'>
            <p>Already have an account?</p>
            <div className='mt-2'>
              <Link href='/sign-in'>
                <a>
                  <Button btnColor='btn-secondary' size='medium' label='sign in' />
                </a>
              </Link>
            </div>
          </div>
        </>
      )}
    </>
  );
};

export default SignUp;
```

※ ButtonコンポーネントやErrorAlertコンポーネント、それぞれのiconコンポーネントに関してはコードを割愛します。
ButtonコンポーネントはTailwindCSSでUIを構築しています。
ErrorAlertコンポーネントは[daisyUIのAlert](https://daisyui.com/components/alert/)を活用しています。
それぞれのiconコンポーネントは[heroicons](https://heroicons.com/)を活用しています。

### 4. 関数の実装

仕様を満たすための関数を実装していきます。

- handleInputChange
  - inputの入力値をそれぞれのstateにセットする関数
- isValidUsername
  - 入力されたusernameが有効値かどうか判定する関数
- isValidPassword
  - 入力されたpasswordが有効値かどうか判定する関数

#### handleInputChange

```tsx:src/pages/sign-up.tx
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
  const { target } = event;
  if (!(target instanceof HTMLInputElement)) return;

  const { name, value } = target;

  switch (name) {
    case 'username':
      setUsername(value);
      break;
    case 'email':
      setEmail(value);
      break;
    case 'password':
      setPassword(value);
      break;
    case 'confirmPassword':
      setConfirmPassword(value);
      break;
    case 'code':
      setCode(value);
      break;
  }
};
```

#### isValidUsername

```tsx:src/pages/sign-up.tsx
const isValidUsername = (): boolean => {
  if (username.length < 3 || username.length > 30) {
    setErrorMessage('usernameは3文字以上, 30文字以下にしてください。');
    return false;
  } else {
    setErrorMessage('');
    return true;
  }
};
```

#### isValidPassword

正規表現に関しては下記を参考にしました。
[JavaScriptで文字列が小文字・大文字・数字を全て含むかどうか判定する方法について](https://qiita.com/uhyo/items/68dbcdb5ec81af24b6b3)

```tsx:src/pages/sign-up.tsx
const isValidPassword = (): boolean => {
  const regexPassword = /^(?=.*?[a-z])(?=.*?[A-Z])(?=.*?[0-9])/;
  if (!regexPassword.test(password)) {
    setErrorMessage('passwordには大文字・小文字の英字と数字を含めてください。');
    return false;
  } else if (password.length < 12 || password.length > 50) {
    setErrorMessage('passwordは12文字以上, 50文字以下にしてください。');
    return false;
  } else if (password !== confirmPassword) {
    setErrorMessage('passwordとconfirm passwordが一致していません。');
    return false
  } else {
    setErrorMessage('');
    return true;
  }
};
```

### 5. SignUp処理の実装

aws-amplifyのAuthを使ってSignUp処理とConfirmSignUp処理を実装します。
参考：[Amplify Docs | Authentication | Sign up, Sign in & Sign out](https://docs.amplify.aws/lib/auth/emailpassword/q/platform/js/)

signUp関数が呼ばれたときにusernameとpasswordのバリデーションが走ります。
isValidUsername関数とisValidPassword関数はbooleanが返ってくるので、両方trueになったときのみSignUpされます。
どちらかでもバリデーションエラーになった場合（falseが返って来た場合）isErrorステートがtrueになりアラートが表示されます。

```tsx:src/pages/sign-up.tsx
import { Auth } from 'aws-amplify';

const signUp = async (): Promise<void> => {
  try {
    if (isValidUsername() && isValidPassword()) {
      await Auth.signUp({
        username,
        password,
        attributes: {
          email,
        },
      });
      setIsError(false);
      setIsSubmitted(true);
    } else {
      setIsError(true);
    }
  } catch (error) {
    switch (error.name) {
      case 'UsernameExistsException':
        setErrorMessage('すでに登録済みのユーザー名のため使えません。');
        break;
      default:
        setErrorMessage(`${error}`);
        break;
    }

    setIsError(true);
  }
};

const confirmSignUp = async (): Promise<void> => {
  try {
    await Auth.confirmSignUp(username, code);
    await Auth.signIn({ username, password });
    router.push('/user-profile');
  } catch (error) {
    setErrorMessage(`${error}`);
    setIsError(true);
  }
};
```

### 6. リダイレクト処理

最後に、ユーザーがSignIn済みだった場合、UserProfileページにリダイレクトするようにしました。
※ 実装中にメモリリークが発生したため下記の記事を参考にしました。
[React useEffect メモリーリーク防止 Tip](https://zenn.dev/maktub_bros/articles/3366d053275d60)

```tsx:src/pages/sign-up.tsx
useEffect(() => {
    const abortController = new AbortController();
    const init = async () => {
      try {
        const currentUser = await Auth.currentAuthenticatedUser();
        setUser(currentUser);
      } catch (error) {
        console.log(error);
      }
    };
    init();

    if (user) {
      router.push('/user-profile');
    }

    return () => {
      abortController.abort();
    };
  }, [user]);
```

## 完成形のコード

<details>
<summary>src/pages/sign-up.tsx</summary>
<div>

```tsx:src/pages/sign-up.tsx
import { Auth } from 'aws-amplify';
import { NextPage } from 'next';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState, useEffect } from 'react';

import { Button } from '../../components';
import { ErrorAlert } from '../../components/Alerts';
import { ArrowLeftIcon, LockIcon, MailIcon, MessageIcon, UserIcon } from '../../components/icons';

const SignUp: NextPage = () => {
  const [username, setUsername] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');
  const [code, setCode] = useState('');
  const [isSubmitted, setIsSubmitted] = useState(false);
  const [isError, setIsError] = useState(false);
  const [errorMessage, setErrorMessage] = useState('');
  const [user, setUser] = useState();
  const router = useRouter();

  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
    const { target } = event;
    if (!(target instanceof HTMLInputElement)) return;

    const { name, value } = target;

    switch (name) {
      case 'username':
        setUsername(value);
        break;
      case 'email':
        setEmail(value);
        break;
      case 'password':
        setPassword(value);
        break;
      case 'confirmPassword':
        setConfirmPassword(value);
        break;
      case 'code':
        setCode(value);
        break;
    }
  };

  const isValidUsername = (): boolean => {
    if (username.length < 3 || username.length > 30) {
      setErrorMessage('usernameは3文字以上, 30文字以下にしてください。');
      return false;
    } else {
      setErrorMessage('');
      return true;
    }
  };

  const isValidPassword = (): boolean => {
    const regexPassword = /^(?=.*?[a-z])(?=.*?[A-Z])(?=.*?[0-9])/;
    if (!regexPassword.test(password)) {
      setErrorMessage('passwordには大文字・小文字の英字と数字を含めてください。');
      return false;
    } else if (password.length < 12 || password.length > 50) {
      setErrorMessage('passwordは12文字以上, 50文字以下にしてください。');
      return false;
    } else if (password !== confirmPassword) {
      setErrorMessage('passwordとconfirm passwordが一致していません。');
      return false;
    } else {
      setErrorMessage('');
      return true;
    }
  };

  const signUp = async (): Promise<void> => {
    try {
      if (isValidUsername() && isValidPassword()) {
        await Auth.signUp({
          username,
          password,
          attributes: {
            email,
          },
        });
        setIsError(false);
        setIsSubmitted(true);
      } else {
        setIsError(true);
      }
    } catch (error) {
      switch (error.name) {
        case 'UsernameExistsException':
          setErrorMessage('すでに登録済みのユーザー名のため使えません。');
          break;
        default:
          setErrorMessage(`${error}`);
          break;
      }

      setIsError(true);
    }
  };

  const confirmSignUp = async (): Promise<void> => {
    try {
      await Auth.confirmSignUp(username, code);
      await Auth.signIn({ username, password });
      router.push('/user-profile');
    } catch (error) {
      setErrorMessage(`${error}`);
      setIsError(true);
    }
  };

  useEffect(() => {
    const abortController = new AbortController();
    const init = async () => {
      try {
        const currentUser = await Auth.currentAuthenticatedUser();
        setUser(currentUser);
      } catch (error) {
        console.log(error);
      }
    };
    init();

    if (user) {
      router.push('/user-profile');
    }

    return () => {
      abortController.abort();
    };
  }, [user]);

  return (
    <>
      {isError ? <ErrorAlert message={errorMessage} /> : <></>}
      {isSubmitted ? (
        <div className='card bg-white shadow-2xl w-96 p-10 m-auto sm:mt-10'>
          <div className='form-control'>
            <div className='flex m-1 p-2'>
              <label className='label'>
                <UserIcon classes='h-6 w-6 text-primary' />
              </label>
              <input
                name='username'
                type='text'
                placeholder='username'
                className='bg-white ml-1 p-2'
                value={username}
                onChange={handleInputChange}
              />
            </div>
            <div className='flex m-1 mb-10 p-2'>
              <label className='label'>
                <MessageIcon classes='h-6 w-6 text-primary' />
              </label>
              <input
                name='code'
                type='text'
                placeholder='verification code'
                className='bg-white ml-1 p-2'
                value={code}
                onChange={handleInputChange}
              />
            </div>
          </div>
          <Button size='large' label='activate account' onClick={confirmSignUp} />
        </div>
      ) : (
        <>
          <div className='card bg-white shadow-2xl w-96 p-10 m-auto sm:mt-10'>
            <div className='form-control'>
              <div className='mb-20'>
                <Link href='/'>
                  <a>
                    <ArrowLeftIcon classes='h-5 w-5' />
                  </a>
                </Link>
              </div>
              <div className='flex m-1 p-2'>
                <label className='label'>
                  <UserIcon classes='h-6 w-6 text-primary' />
                </label>
                <input
                  name='username'
                  type='text'
                  placeholder='username'
                  className='bg-white ml-1 p-2'
                  value={username}
                  onChange={handleInputChange}
                />
              </div>
              <div className='flex m-1 p-2'>
                <label className='label'>
                  <MailIcon classes='h-6 w-6 text-primary' />
                </label>
                <input
                  name='email'
                  type='email'
                  placeholder='user@email.com'
                  className='bg-white ml-1 p-2'
                  value={email}
                  onChange={handleInputChange}
                />
              </div>
              <div className='flex m-1 p-2'>
                <label className='label'>
                  <LockIcon classes='h-6 w-6 text-primary' />
                </label>
                <input
                  name='password'
                  type='password'
                  placeholder='password'
                  className='bg-white ml-1 p-2'
                  value={password}
                  onChange={handleInputChange}
                />
              </div>
              <div className='flex m-1 mb-10 p-2'>
                <label className='label'>
                  <LockIcon classes='h-6 w-6 text-primary' />
                </label>
                <input
                  name='confirmPassword'
                  type='password'
                  placeholder='confirm password'
                  className='bg-white ml-1 p-2'
                  value={confirmPassword}
                  onChange={handleInputChange}
                />
              </div>
            </div>
            <Button size='large' label='create user' onClick={signUp} />
          </div>
          <div className='mt-10 text-center'>
            <p>Already have an account?</p>
            <div className='mt-2'>
              <Link href='/sign-in'>
                <a>
                  <Button btnColor='btn-secondary' size='medium' label='sign in' />
                </a>
              </Link>
            </div>
          </div>
        </>
      )}
    </>
  );
};

export default SignUp;
```
</div>
</details>

## 参考記事一覧

- [認証機能 :: Amplify SNS Workshop](https://amplify-sns.workshop.aws/ja/30_mock/10_auth.html)
- [AWS cognitoとReactでログインを実装する](https://zenn.dev/dove/articles/63494de652511c)
- [Amplify Docs | Authentication | Sign up, Sign in & Sign out](https://docs.amplify.aws/lib/auth/emailpassword/q/platform/js/)
- [JavaScriptで文字列が小文字・大文字・数字を全て含むかどうか判定する方法について](https://qiita.com/uhyo/items/68dbcdb5ec81af24b6b3)
- [React useEffect メモリーリーク防止 Tip](https://zenn.dev/maktub_bros/articles/3366d053275d60)