メモアプリ「Simpl」の開発手順をまとめるシリーズ
第1弾はアカウント機能です。
(膨大な量になってしまったので目次利用を推奨します。)
これまでの学習内容については以下の記事にまとめています。
はじめに
今回追加する機能は以下のとおりです。
- サインアップ
- サインイン
- アカウント削除
作業の流れを以下のように分類してまとめます。
- 【設計】要件定義
- 【設計】データベース設計
- 【設計】API設計
- 【設計】デザインカンプ
- 【設計】コンポーネント設計
- 【MySQL】テーブルの追加
- 【Spring Boot】バックエンド処理の追加
- 【React】フロントエンド処理の追加
バージョン情報は以下のとおりです。
技術スタック | バージョン | 確認方法 |
MySQL | 8.3.0 | mysql --version |
Java | openjdk 22.0.1 | java -version |
SpringBoot | 3.2.5 | pom.xml内の<spring-boot.version> タグ |
maven | 3.9.8 | mvn -version |
Node.js | 18.17.0 | node -v |
TypeScript | 5.5.2 | tsc -v |
React | 18.3.3 | package.json |
MUI | 5.15.18 | package.json |
【設計】要件定義
3つの機能について、簡易的な要件を定めます。
サインアップ
ユーザーはトップページから以下の情報を入力して新規登録を行います。
既存のメールアドレスを入力するとエラーが表示されます。
- メールアドレス
- パスワード
- ユーザー名
サインイン
ユーザーはトップページから以下の情報を入力してログインを行います。
- メールアドレス
- パスワード
アカウント削除
ユーザーは警告モーダル画面からアカウント削除を行います。
【設計】データベース設計
ER図
【設計】API設計
【設計】デザインカンプ
Figmaで作成しました。
- 1枚目:サインアップ画面
- 2枚目:サインイン画面
- 3枚目:サインイン後の画面(今回メモ機能は実装しません)
- 4枚目:ヘッダーメニュー画面
- 5枚目:アカウント削除確認モーダル画面
【設計】コンポーネント設計
デザインカンプをもとにコンポーネントを設計します。
【MySQL】テーブル定義
データベース設計書に従ってテーブルを作成します。
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(256) NOT NULL UNIQUE,
password VARCHAR(256) NOT NULL,
user_name VARCHAR(256) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
mysql> CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(256) NOT NULL UNIQUE, password VARCHAR(256) NOT NULL, user_name VARCHAR(256) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL);
Query OK, 0 rows affected (0.05 sec)
mysql> show tables;
+-----------------------+
| Tables_in_simple_memo |
+-----------------------+
| users |
+-----------------------+
1 rows in set (0.03 sec)
mysql> DESCRIBE users;
+------------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+------------+--------------+------+-----+---------+----------------+
| id | int | NO | PRI | NULL | auto_increment |
| email | varchar(256) | NO | UNI | NULL | |
| password | varchar(256) | NO | | NULL | |
| user_name | varchar(256) | NO | | NULL | |
| created_at | datetime | NO | | NULL | |
| updated_at | datetime | NO | | NULL | |
+------------+--------------+------+-----+---------+----------------+
6 rows in set (0.07 sec)
【Spring Boot】バックエンド処理
API設計に従ってバックエンド処理を作成します。
プロジェクト構成
└── example
└── simplememo
├── SimpleMemoApplication.java
├── config
│ └── WebConfig.java
├── controller
│ ├── UserController.java
│ ├── SignUpRequest.java
│ └── SignInRequest.java
├── mapper
│ └── UserMapper.java
├── model
│ └── User.java
└── service
└── UserService.java
モデルクラス
データベースのレコードやメソッドの引数データを格納するデータモデルを作成します。
usersテーブルのレコードを格納するUserクラス
package com.example.simplememo.model;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class User {
private Long id;
private String email;
private String password;
private String userName;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
サインアップのリクエストを格納するSignUpRequestクラス
package com.example.simplememo.controller;
import lombok.Data;
@Data
public class SignUpRequest {
private String email;
private String password;
private String userName;
}
サインインのリクエストを格納するSignInRequestクラス
package com.example.simplememo.controller;
import lombok.Data;
@Data
public class SignInRequest {
private String email;
private String password;
}
UserMapper
データベースとの直接的なやり取りを担当するUserMapperを作成します。
package com.example.simplememo.mapper;
import com.example.simplememo.controller.SignUpRequest;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import com.example.simplememo.model.User;
@Mapper
public interface UserMapper {
@Insert("INSERT INTO users (email, password, user_name, created_at, updated_at) VALUES (#{email}, #{password}, #{userName}, NOW(), NOW())")
void insertUser(SignUpRequest request);
@Select("SELECT * FROM users WHERE email = #{email}")
User findByEmail(String email);
@Delete("DELETE FROM users WHERE email = #{email}")
int deleteUser(String email); // 影響を受けた行数を返す
}
UserService
MapperとControllerの間でビジネスロジックの処理を行うUserServiceを作成します。
package com.example.simplememo.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.example.simplememo.controller.SignInRequest;
import com.example.simplememo.controller.SignUpRequest;
import com.example.simplememo.mapper.UserMapper;
import com.example.simplememo.model.User;
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
public void signup(SignUpRequest request) {
// 該当のメールアドレスが既に登録されているか確認
User user = userMapper.findByEmail(request.getEmail());
if (user != null) {
throw new RuntimeException("入力されたメールアドレスは既に登録されています。");
}
try {
userMapper.insertUser(request);
} catch (Exception e) {
throw new RuntimeException("登録に失敗しました。入力内容を確認してください。");
}
}
public User signin(SignInRequest request) {
User user = userMapper.findByEmail(request.getEmail());
if (user.getPassword().equals(request.getPassword())) {
return user;
} else {
throw new RuntimeException("入力されたパスワードが違います。");
}
}
public void deleteUser(String email) {
try {
userMapper.deleteUser(email);
} catch (Exception e) {
throw new RuntimeException("削除に失敗しました。");
}
}
}
UserController
クライアントからリクエストを受け取り、適切なサービスやデータモデルを呼び出して処理を行い、レスポンスを返すUserControllerを作成します。
package com.example.simplememo.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.example.simplememo.model.User;
import com.example.simplememo.service.UserService;
@RestController
@RequestMapping("/api")
public class UserController {
@Autowired
private UserService userService;
@PostMapping("/signup")
public ResponseEntity<String> signup(@RequestBody SignUpRequest request) {
try {
userService.signup(request);
return new ResponseEntity<>(HttpStatus.CREATED); // 201
} catch (RuntimeException e) {
return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); // 500
}
}
@PostMapping("/signin")
public ResponseEntity<User> signin(@RequestBody SignInRequest request) {
try {
User user = userService.signin(request);
return new ResponseEntity<>(user, HttpStatus.OK); // 200
} catch (RuntimeException e) {
return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); // 401
}
}
@DeleteMapping("/account")
public ResponseEntity<Void> deleteUser(@RequestBody String email) {
try {
userService.deleteUser(email);
return new ResponseEntity<>(HttpStatus.NO_CONTENT); // 204
} catch (RuntimeException e) {
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); // 500
}
}
}
動作確認
curlコマンドでAPIの動作を確認します。
# サインアップ
$ curl -i -X POST -H "Content-Type: application/json" -d '{
"email": "test@example.com",
"password": "password123",
"userName": "Test User"
}' http://localhost:8080/api/signup
HTTP/1.1 201
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Length: 0
Date: Sat, 22 Jun 2024 04:20:10 GMT
Connection: close
# サインイン
$ curl -i -X POST -H "Content-Type: application/json" -d '{
"email": "test@example.com",
"password": "password123"
}' http://localhost:8080/api/signin
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 22 Jun 2024 04:21:37 GMT
{"id":1,"email":"test@example.com","password":"password123","userName":"Test User","createdAt":"2024-06-29T13:41:24","updatedAt":"2024-06-29T13:41:24"}%
# アカウント削除
$ curl -i -X DELETE -H "Content-Type: text/plain" --data "test@example.com" http://localhost:8080/api/account
HTTP/1.1 204
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Date: Sat, 22 Jun 2024 04:22:45 GMT
4時間苦しみました…
症状:UserMapper.javaのinsertUserメソッド実行時にorg.mybatis.spring.MyBatisSystemException
が発生
原因:INSERT文にid列が明示的に指定されていないにも関わらず@OptionsアノテーションでkeyProperty = "id"
を指定していた。
対処:@Optionsアノテーションを削除
修正前のコード
@Insert("INSERT INTO users (email, password, user_name, created_at, updated_at) VALUES (#{request.email}, #{request.password}, #{request.userName}, NOW(), NOW())")
@Options(useGeneratedKeys = true, keyProperty = "id")
void insertUser(SignUpRequest request);
修正後のコード(上記記載のコード)
@Insert("INSERT INTO users (email, password, user_name, created_at, updated_at) VALUES (#{request.email}, #{request.password}, #{request.userName}, NOW(), NOW())")
void insertUser(SignUpRequest request);
解決に至った経緯はこうです。
Exceptionのスタックトレースを出力して1行ずつ解読していったら以下の箇所が気になったので
Caused by: org.apache.ibatis.executor.ExecutorException: Could not determine which parameter to assign generated keys to. Note that when there are multiple parameters, 'keyProperty' must include the parameter name (e.g. 'param.id'). Specified key properties are [id] and available parameters are [password, userName, param3, email, param1, param2]
chatGPTにチャラい感じで(これが1番分かりやすかったりします)説明してもらったところ、
やべー、自動生成キーをどのパラメータに割り当てるかわからん!パラメータが複数あるときは、'keyProperty'にパラメータ名を含めないとダメだぜ。指定されたキーのパラメータは[id]で、利用可能なパラメータは[password, userName, param3, email, param1, param2]だったりするんだ。
と言われたので「idをパラメータに含んでなかった!」と気付けた訳です。
エラー大事。
【React】フロントエンド処理
コンポーネント構造
コンポーネント設計に認証結果を格納するAuthProviderを追加します。(詳細は後述)
└── App
└── AuthProvider
├── Header
│ ├── HeaderMenu
│ └── HeaderModal
└── Main
└── AuthContainer
├── SignIn
└── SignUp
このコンポーネント構造に従って実装していきます。
AuthProvider
└── App
└── AuthProvider # ココ
├── Header
│ ├── HeaderMenu
│ └── HeaderModal
└── Main
└── AuthContainer
├── SignIn
└── SignUp
Appコンポーネントの直下にAuthProviderコンポーネントを作成します。
以下の状態や関数をAuthContextで提供します。
詳細は使用するコンポーネントとともに解説します。
isSignedIn
/setIsSignedIn
:ユーザーのサインイン状態を管理します。authMode
/handleAuthModeChange
:認証モードを管理します。handleAuthFormChange
:認証フォームの入力内容を更新します。signUpData
/signUpData
:サインアップ・サインインの入力データを保持します。handleAuthClick
:認証(サインアップまたはサインイン)の処理を実行します。errorMessage
/setErrorMessage
:エラーメッセージを管理および表示します。handleSignOut
:サインアウトの処理を実行します。deleteConfirm
/setDeleteConfirm
:アカウント削除の確認用入力を管理します。handleDeleteClick
:アカウントの削除処理を実行します。modalOpen
/setModalOpen
:モーダルの表示状態を管理します。
import React, { useEffect, useState } from "react";
import { createContext, ReactNode } from "react";
import {
AuthContextProps,
initialSignUpData,
SignUpData,
initialSignInData,
SignInData,
AuthMode,
} from "../Types";
// コンテキストを作成
const AuthContext = createContext<AuthContextProps | undefined>(undefined);
const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
// 認証状態
const [isSignedIn, setIsSignedIn] = useState(false);
// 画面状態
const [authMode, setAuthMode] = useState<AuthMode>(AuthMode.SIGNIN);
const [signUpData, setSignUpData] = useState<SignUpData>(initialSignUpData); // サインアップ時の入力内容
const [signInData, setSignInData] = useState<SignInData>(initialSignInData); // サインイン時の入力内容
const [errorMessage, setErrorMessage] = useState<string>("");
const [deleteConfirm, setDeleteConfirm] = useState<string>(""); // アカウント削除確認時の入力内容
const [modalOpen, setModalOpen] = useState(false);
// サインイン状態の復元
useEffect(() => {
const userInfo = localStorage.getItem("userInfo");
if (userInfo) {
setIsSignedIn(true);
}
}, []);
// 認証モードの変更
const handleAuthModeChange = (newMode: AuthMode) => {
setAuthMode(newMode);
setErrorMessage("");
};
// フォームの入力変更ハンドリング
const handleAuthFormChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setErrorMessage("");
if (authMode === AuthMode.SIGNUP) {
setSignUpData({ ...signUpData, [e.target.name]: e.target.value });
} else if (authMode === AuthMode.SIGNIN) {
setSignInData({ ...signInData, [e.target.name]: e.target.value });
}
};
// 認証処理
const handleAuthClick = async () => {
const varidationResult = varidateForm();
if (!varidationResult) return;
const baseUrl = process.env.REACT_APP_API_BASE_URL;
const endpoint = authMode === AuthMode.SIGNUP ? "signup" : "signin";
const url = `${baseUrl}/api/${endpoint}`;
const requestBody = authMode === AuthMode.SIGNUP ? signUpData : signInData;
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
if (authMode === AuthMode.SIGNUP) {
setErrorMessage("このメールアドレスはすでに登録されています");
} else if (authMode === AuthMode.SIGNIN) {
setErrorMessage("メールアドレスもしくはパスワードが違います");
}
setErrorMessage("認証に失敗しました。");
throw new Error("Failed to auth request");
}
setIsSignedIn(true);
resetForm(); // 入力フォームをリセット
const userInfo = await response.json();
localStorage.setItem("userInfo", JSON.stringify(userInfo)); // ローカルストレージに保存
} catch (error) {
console.error("Error:", error);
}
};
const varidateForm = () => {
setErrorMessage("");
if (authMode === AuthMode.SIGNUP) {
if (!signUpData.userName || !signUpData.email || !signUpData.password) {
setErrorMessage("入力欄が空欄の場合は入力してください");
return false;
}
if (
getByteLength(signUpData.userName) > 255 ||
getByteLength(signUpData.email) > 255 ||
getByteLength(signUpData.password) > 255
) {
setErrorMessage("文字数制限を超えています");
return false;
}
} else if (authMode === AuthMode.SIGNIN) {
console.log(getByteLength(signInData.email), getByteLength(signInData.password));
if (!signInData.email || !signInData.password) {
setErrorMessage("入力欄が空欄の場合は入力してください");
return false;
}
}
return true;
};
const getByteLength = (content: string) => {
const encoder = new TextEncoder();
const byteArray = encoder.encode(content);
return byteArray.length;
};
// サインアウト処理
const handleSignOut = () => {
setErrorMessage("");
localStorage.removeItem("userInfo");
setIsSignedIn(false);
};
// アカウント削除処理
const handleDeleteClick = async () => {
if (deleteConfirm !== "アカウント削除") {
setErrorMessage("入力内容を確認してください");
return;
}
const baseUrl = process.env.REACT_APP_API_BASE_URL;
const url = `${baseUrl}/api/account`;
const userInfo = localStorage.getItem("userInfo");
const email = userInfo ? JSON.parse(userInfo).email : "";
try {
const response = await fetch(url, {
method: "DELETE",
headers: {
"Content-Type": "text/plain",
},
body: email,
});
if (!response.ok) {
console.log(response);
setErrorMessage("アカウント削除に失敗しました。");
throw new Error("Failed to auth request");
}
setIsSignedIn(false);
setErrorMessage("");
localStorage.removeItem("userInfo"); // ローカルストレージから削除
} catch (error) {
console.error("Error:", error);
}
};
// 画面状態のリセット
const resetForm = () => {
setAuthMode(AuthMode.SIGNIN);
setSignUpData(initialSignUpData);
setSignInData(initialSignInData);
setDeleteConfirm("");
setErrorMessage("");
setModalOpen(false);
};
return (
<AuthContext.Provider
value={{
isSignedIn,
setIsSignedIn,
authMode,
handleAuthModeChange,
handleAuthFormChange,
signUpData,
signInData,
handleAuthClick,
errorMessage,
handleSignOut,
deleteConfirm,
setDeleteConfirm,
handleDeleteClick,
setErrorMessage,
modalOpen,
setModalOpen,
}}
>
{children}
</AuthContext.Provider>
);
};
// contextオブジェクトを子が直接触らずに済むようにする
const useAuthContext = () => {
const context = React.useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuthContext must be used within a AuthProvider");
}
return context;
};
export { AuthProvider, useAuthContext };
Header
└── App
└── AuthProvider
├── Header # ココ
│ ├── HeaderMenu
│ └── HeaderModal
└── Main
└── AuthContainer
├── SignIn
└── SignUp
AuthContextから以下を取得しています。
isSignedIn
:ユーザーのサインイン状態を保持します。ユーザーアイコンの表示切替に使用します。
ユーザーアイコン押下時にメニューを表示させるため、HeaderMenuコンポーネントを記述します。
HeaderModalコンポーネントは、アカウント削除のモーダルです。(詳細後述)
import React, { useState } from "react";
import { useAuthContext } from "../../context/AuthContext";
import HeaderMenu from "./HeaderMenu";
import HeaderModal from "./HeaderModal";
const Header: React.VFC = () => {
const { isSignedIn } = useAuthContext();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); // メニューを開く場所
const handleIconClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
return (
<header className="header">
<h1 className="header__title">Simpl</h1>
{isSignedIn && (
<>
<button className="header__button" onClick={handleIconClick}>
<img src="icon.png" alt="" />
</button>
<HeaderMenu
anchorEl={anchorEl}
setAnchorEl={setAnchorEl}
/>
<HeaderModal />
</>
)}
</header>
);
};
export default Header;
HeaderMenu
└── App
└── AuthProvider
├── Header
│ ├── HeaderMenu # ココ
│ └── HeaderModal
└── Main
└── AuthContainer
├── SignIn
└── SignUp
ユーザーアイコンを押下すると表示されるアクションメニューです。
MUIを使用してカスタムコンポーネントを作成しました。
AuthContextから以下を取得しています。
handleSignOut
:サインアウトの処理を実行します。setModalOpen
:モーダルの表示状態を管理します。「アカウント削除」メニュー押下時にHeaderModalを表示させる処理を行います。
モーダルはアカウント削除の確認を行う画面です。(次節「HeaderModal」で解説します)
import { useAuthContext } from "../../context/AuthContext";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import ListItemText from "@mui/material/ListItemText";
import ListItemIcon from "@mui/material/ListItemIcon";
import LogoutIcon from "@mui/icons-material/Logout";
import DeleteForeverIcon from "@mui/icons-material/DeleteForever";
import { HeaderMenuProps } from "../../Types";
const HeaderMenu: React.VFC<HeaderMenuProps> = ({
anchorEl,
setAnchorEl,
}) => {
const { handleSignOut, setModalOpen } = useAuthContext();
const menuOpen = Boolean(anchorEl); // メニューを開いているか否か
const handleMenuClose = () => {
setAnchorEl(null);
};
return (
<Menu
anchorEl={anchorEl}
open={menuOpen}
onClose={handleMenuClose}
anchorOrigin={{
vertical: "bottom", // メニューの基準となる要素(anchorEl)の下部からの表示
horizontal: "right", // メニューの基準となる要素(anchorEl)の右端からの表示
}}
transformOrigin={{
vertical: "top", // メニュー自体の上部を基準として配置
horizontal: "right", // メニュー自体の右端を基準として配置
}}
>
<MenuItem className="header__menu-item" onClick={handleMenuClose}>
<ListItemIcon>
<LogoutIcon fontSize="large" />
</ListItemIcon>
<ListItemText className="header__menu-text" onClick={handleSignOut}>
サインアウト
</ListItemText>
</MenuItem>
<MenuItem className="header__menu-item" onClick={handleMenuClose}>
<ListItemIcon>
<DeleteForeverIcon fontSize="large" className="header__menu-img-red" />
</ListItemIcon>
<ListItemText
className="header__menu-text header__menu-text-red"
onClick={() => setModalOpen(true)}
>
アカウントを削除
</ListItemText>
</MenuItem>
</Menu>
);
};
export default HeaderMenu;
HeaderModal
└── App
└── AuthProvider
├── Header
│ ├── HeaderMenu
│ └── HeaderModal # ココ
└── Main
└── AuthContainer
├── SignIn
└── SignUp
アカウント削除を行う際の確認画面です。
ユーザーに操作の意味を認識させるために「アカウント削除」と入力するようにしました。
AuthContextから以下を取得しています。
errorMessage
/setErrorMessage
:「アカウント削除」以外の入力に対してエラーメッセージを表示します。deleteConfirm
/setDeleteConfirm
:「アカウント削除」の入力内容を管理します。handleDeleteClick
:アカウントの削除処理を実行します。modalOpen
/setModalOpen
:モーダル外押下時にモーダルを非表示にします。
import Modal from "@mui/material/Modal";
import { useAuthContext } from "../../context/AuthContext";
import { useEffect } from "react";
const HeaderModal: React.VFC = () => {
const {
errorMessage,
setErrorMessage,
deleteConfirm,
setDeleteConfirm,
modalOpen,
setModalOpen,
handleDeleteClick,
} = useAuthContext();
useEffect(() => {
setErrorMessage("");
setDeleteConfirm("");
}, [modalOpen]);
const handleDeleteConfirmChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setErrorMessage("");
setDeleteConfirm(e.target.value);
};
return (
<Modal open={modalOpen} onClose={() => setModalOpen(false)}>
<div className="modal">
<h2 className="modal__title">アカウント削除</h2>
<div className="modal__content">
<p className="modal__text">削除すると以下の情報がすべて失われます</p>
<ul className="modal__list">
<li className="modal__list-item">・プロフィール情報</li>
<li className="modal__list-item">・メモ情報</li>
</ul>
</div>
<div className="modal__content">
<p className="modal__text">確認のため「アカウント削除」と入力してください</p>
<input
className="common__form common__form--input"
type="text"
placeholder="アカウント削除"
value={deleteConfirm}
onChange={handleDeleteConfirmChange}
/>
</div>
<div className="modal__button-box">
<button
className="common__form common__form--button modal__button modal__button--cancel"
onClick={() => setModalOpen(false)}
>
キャンセル
</button>
<button
className="common__form common__form--button modal__button modal__button--delete"
onClick={handleDeleteClick}
>
削除
</button>
</div>
<div className="common__error">{errorMessage}</div>
</div>
</Modal>
);
};
export default HeaderModal;
Main
└── App
└── AuthProvider
├── Header
│ ├── HeaderMenu
│ └── HeaderModal
└── Main # ココ
└── AuthContainer
├── SignIn
└── SignUp
認証フォームやメモ一覧を表示するコンポーネントです。
AuthContextから以下を取得しています。
isSignedIn
:ユーザーのサインイン状態に応じてコンポーネントを切り替えます。
import AuthContainer from "./AuthContainer/AuthContainer";
import { useAuthContext } from "../../context/AuthContext";
import MemoContainer from "./MemoContainer/MemoContainer";
const Main: React.VFC = () => {
const { isSignedIn } = useAuthContext();
return <div className="main">{isSignedIn ? <MemoContainer /> : <AuthContainer />}</div>;
};
export default Main;
AuthContainer
└── App
└── AuthProvider
├── Header
│ ├── HeaderMenu
│ └── HeaderModal
└── Main
└── AuthContainer # ココ
├── SignIn
└── SignUp
サインインとサインアップ用の入力フォームを表示するコンポーネントです。
AuthContextから以下を取得しています。
authMode
:認証モードを保持し、サインアップとサインインを切り替えます。errorMessage
:認証情報の入力内容の不備に対するエラーメッセージを表示します。
import SignIn from "./SignIn";
import SignUp from "./SignUp";
import { useAuthContext } from "../../../context/AuthContext";
const AuthContainer: React.VFC = () => {
const { authMode, errorMessage } = useAuthContext();
return (
<div className="auth">
<div className="auth__copy-box">
<div className="auth__copy">メモをもっとシンプルに</div>
<div className="auth__copy auth__copy--grayed">直感的にアイデアを書き出そう</div>
</div>
{authMode === "signup" ? <SignUp /> : <SignIn />}
<div className="common__error auth__error">{errorMessage}</div>
</div>
);
};
export default AuthContainer;
SignUp・SignIn
└── App
└── AuthProvider
├── Header
│ ├── HeaderMenu
│ └── HeaderModal
└── Main
└── AuthContainer
├── SignIn # ココ
└── SignUp # ココ
サインインとサインアウトの入力フォームをそれぞれ表示するコンポーネントです。
AuthContextから以下を取得しています。
handleAuthModeChange
:サインアップとサインインを切り替えます。signUpData
/signUpData
:サインアップ・サインインの入力データを保持します。handleAuthFormChange
:フォームの入力内容を更新します。handleAuthClick
:認証(サインアップまたはサインイン)の処理を実行します。
import { AuthMode } from "../../../Types";
import { useAuthContext } from "../../../context/AuthContext";
const SignUp: React.VFC = () => {
const { handleAuthModeChange, signUpData, handleAuthFormChange, handleAuthClick } =
useAuthContext();
return (
<>
<div className="auth__label-box">
<p className="auth__label">アカウントを作成</p>
<p
className="auth__label auth__label--link"
onClick={() => handleAuthModeChange(AuthMode.SIGNIN)}
>
サインイン
</p>
</div>
<div className="auth__form-box">
<input
className="common__form common__form--input"
name="userName"
type="text"
placeholder="ユーザー名を入力"
value={signUpData.userName}
onChange={handleAuthFormChange}
/>
<input
className="common__form common__form--input"
name="email"
type="text"
placeholder="メールアドレスを入力"
value={signUpData.email}
onChange={handleAuthFormChange}
/>
<input
className="common__form common__form--input"
name="password"
type="password"
placeholder="パスワードを入力"
value={signUpData.password}
onChange={handleAuthFormChange}
/>
<button
className="common__form common__form--button auth__form--button"
onClick={handleAuthClick}
>
新規登録する
</button>
</div>
</>
);
};
export default SignUp;
import { AuthMode } from "../../../Types";
import { useAuthContext } from "../../../context/AuthContext";
const SignIn: React.VFC = () => {
const { handleAuthModeChange, signInData, handleAuthFormChange, handleAuthClick } =
useAuthContext();
return (
<>
<div className="auth__label-box">
<p className="auth__label">サインイン</p>
<p
className="auth__label auth__label--link"
onClick={() => handleAuthModeChange(AuthMode.SIGNUP)}
>
アカウントを作成
</p>
</div>
<div className="auth__form-box">
<input
className="common__form common__form--input"
name="email"
type="text"
placeholder="メールアドレスを入力"
value={signInData.email}
onChange={handleAuthFormChange}
/>
<input
className="common__form common__form--input"
name="password"
type="password"
placeholder="パスワードを入力"
value={signInData.password}
onChange={handleAuthFormChange}
/>
<button
className="common__form common__form--button auth__form--button"
onClick={handleAuthClick}
>
サインインする
</button>
</div>
</>
);
};
export default SignIn;
動作確認
まとめ
今回はアカウント機能について設計から実装までまとめました。
以下の課題が残っていますがひとまず完成に向けて次回はメモ機能に取り組みます。
今後の課題
- 認証のセキュリティが低い
- フロントエンドにパスワード情報を持ってきている(localStorageに保存)
- UI/UXの向上
- サインアウト/アカウント削除時のフィードバックがない
参考文献
これまでの学習内容については以下の記事にまとめています。
コメント