はじめに
Next.js(React) + Amplify + Cognitoという技術スタックでWebアプリのUIを開発したいと思い、いくつかの記事を参考にさせていただきました。
- 認証機能 :: Amplify SNS Workshop
- AWS cognitoとReactでログインを実装する
- Amplify Docs | Authentication | Sign up, Sign in & Sign out
提供されているUIライブラリ(@aws-amplify/ui-react)を利用する方法が一番簡単だと思いましたが、
@aws-amplify/ui-reactを利用せず、SignUpページを独自のUIで構築したいと思い、自分なりに調査し、考えて実装してみました。
これより良い実装方法やコードの書き方などいくらでもあると思うので、ぜひコメント等でご教授いただけましたら幸いです。
環境情報
$ 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一覧です)
{
"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の設定
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"]
}
UIのスタイリングにTailwindCSS、daisyUIを使用しているので、念のため設定ファイルも載せておきます。
postcss.config.js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
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',
},
},
],
},
};
画面設計
実現したいSignUp画面の完成予想図
仕様
下記のようにしました。
- username, email, passwordでSignUp
- usernameはユニーク
- usernameは3文字以上30文字以下
- passwordは12文字以上50文字以下で、大文字・小文字の英字と数字を含めなければならない
- passwordとconfirm passwordが一致している必要がある
- バックエンドとフロントエンド両方でバリデーションをかける
- Emailに認証コードを送信する
- 認証コードを使ってアカウントを有効化できる
実装
1. AmplifyにAuthを追加
$ 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
$ amplify push
2. _app.tsxにAmplifyの設定を追加
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実装
こんな感じにしてみました。
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を活用しています。
それぞれのiconコンポーネントはheroiconsを活用しています。
4. 関数の実装
仕様を満たすための関数を実装していきます。
- handleInputChange
- inputの入力値をそれぞれのstateにセットする関数
- isValidUsername
- 入力されたusernameが有効値かどうか判定する関数
- isValidPassword
- 入力されたpasswordが有効値かどうか判定する関数
handleInputChange
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
const isValidUsername = (): boolean => {
if (username.length < 3 || username.length > 30) {
setErrorMessage('usernameは3文字以上, 30文字以下にしてください。');
return false;
} else {
setErrorMessage('');
return true;
}
};
isValidPassword
正規表現に関しては下記を参考にしました。
JavaScriptで文字列が小文字・大文字・数字を全て含むかどうか判定する方法について
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
signUp関数が呼ばれたときにusernameとpasswordのバリデーションが走ります。
isValidUsername関数とisValidPassword関数はbooleanが返ってくるので、両方trueになったときのみSignUpされます。
どちらかでもバリデーションエラーになった場合(falseが返って来た場合)isErrorステートがtrueになりアラートが表示されます。
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
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]);
完成形のコード
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;