プログラミング

メモアプリ開発① アカウント機能

この記事は約57分で読めます。

メモアプリ「Simpl」の開発手順をまとめるシリーズ
第1弾はアカウント機能です。

(膨大な量になってしまったので目次利用を推奨します。)


これまでの学習内容については以下の記事にまとめています。

はじめに

今回追加する機能は以下のとおりです。

  1. サインアップ
  2. サインイン
  3. アカウント削除

作業の流れを以下のように分類してまとめます。

  • 【設計】要件定義
  • 【設計】データベース設計
  • 【設計】API設計
  • 【設計】デザインカンプ
  • 【設計】コンポーネント設計
  • 【MySQL】テーブルの追加
  • 【Spring Boot】バックエンド処理の追加
  • 【React】フロントエンド処理の追加

バージョン情報は以下のとおりです。

技術スタックバージョン確認方法
MySQL8.3.0mysql --version
Javaopenjdk 22.0.1java -version
SpringBoot3.2.5pom.xml内の<spring-boot.version>タグ
maven3.9.8mvn -version
Node.js18.17.0node -v
TypeScript5.5.2tsc -v
React18.3.3package.json
MUI5.15.18package.json

【設計】要件定義

3つの機能について、簡易的な要件を定めます。

サインアップ
ユーザーはトップページから以下の情報を入力して新規登録を行います。
既存のメールアドレスを入力するとエラーが表示されます。

  • メールアドレス
  • パスワード
  • ユーザー名

サインイン
ユーザーはトップページから以下の情報を入力してログインを行います。

  1. メールアドレス
  2. パスワード

アカウント削除
ユーザーは警告モーダル画面からアカウント削除を行います。

【設計】データベース設計

ER図

MySQLでVARCHARを使用する場合、格納できる半角文字と全角文字の数は次のようになります。

半角文字(英数字、記号など): 1バイトで格納
全角文字(日本語などのマルチバイト文字): UTF-8の場合、1文字が通常3バイトで格納(ただし、絵文字や一部の漢字は4バイト)。

VARCHARの定義
VARCHAR(n)は最大でnバイトの文字列を格納することを意味します。nは文字数ではなくバイト数です。

したがってVARCHAR(256)の場合、半角文字なら256文字、全角文字なら85文字程度格納できます。

【設計】API設計

今回は学習目的の実装のためCognitoやAuth0、トークンを使用せず自前で実装します。セキュリティの観点では非推奨です。

【設計】デザインカンプ

Figmaで作成しました。

  • 1枚目:サインアップ画面
  • 2枚目:サインイン画面
  • 3枚目:サインイン後の画面(今回メモ機能は実装しません)
  • 4枚目:ヘッダーメニュー画面
  • 5枚目:アカウント削除確認モーダル画面

【設計】コンポーネント設計

デザインカンプをもとにコンポーネントを設計します。

【MySQL】テーブル定義

データベース設計書に従ってテーブルを作成します。

SQL
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
);
Zsh
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クラス

src/main/java/com/example/simplememo/model/User.java
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クラス

src/main/java/com/example/simplememo/controller/SignUpRequest.java
package com.example.simplememo.controller;

import lombok.Data;

@Data
public class SignUpRequest {
    private String email;
    private String password;
    private String userName;
}

サインインのリクエストを格納するSignInRequestクラス

src/main/java/com/example/simplememo/controller/SignInRequest.java
package com.example.simplememo.controller;

import lombok.Data;

@Data
public class SignInRequest {
    private String email;
    private String password;
}

UserMapper

データベースとの直接的なやり取りを担当するUserMapperを作成します。

src/main/java/com/example/simplememo/mapper/UserMapper.java
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を作成します。

src/main/java/com/example/simplememo/service/UserService.java
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を作成します。

src/main/java/com/example/simplememo/controller/UserController.java
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の動作を確認します。

Zsh
# サインアップ
$ 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.javainsertUserメソッド実行時にorg.mybatis.spring.MyBatisSystemExceptionが発生

原因:INSERT文にid列が明示的に指定されていないにも関わらず@OptionsアノテーションkeyProperty = "id"を指定していた。

対処@Optionsアノテーションを削除

修正前のコード

src/main/java/com/example/simplememo/mapper/UserMapper.java
    @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);

修正後のコード(上記記載のコード)

src/main/java/com/example/simplememo/mapper/UserMapper.java
    @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:モーダルの表示状態を管理します。
src/context/AuthContext.tsx
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コンポーネントは、アカウント削除のモーダルです。(詳細後述)

src/components/Header/Header.tsx
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」で解説します)

MUI(Material UI)は、Google の Material Design をベースに開発された、UI コンポーネントライブラリです。

src/components/Header/HeaderMenu.tsx
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:モーダル外押下時にモーダルを非表示にします。
src/components/Header/HeaderModal.tsx
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:ユーザーのサインイン状態に応じてコンポーネントを切り替えます。
src/components/Main/Main.tsx
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:認証情報の入力内容の不備に対するエラーメッセージを表示します。
src/components/Main/AuthContainer/AuthContainer.tsx
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コンポーネントはStateで切り替えています。

内部的にStateを持つことで、URLを変更しないシンプルな実装になるだけでなく、速いレンダリングが実現します。

より大規模なアプリケーションである場合にはURLの管理やSEOの観点を考慮してReactRouterを使用するケースもあるそうです。

SignUp・SignIn

└── App
    └── AuthProvider
        ├── Header
           ├── HeaderMenu
           └── HeaderModal
        └── Main
            └── AuthContainer
                ├── SignIn # ココ
                └── SignUp # ココ

サインインとサインアウトの入力フォームをそれぞれ表示するコンポーネントです。

AuthContextから以下を取得しています。

  • handleAuthModeChange:サインアップとサインインを切り替えます。
  • signUpData / signUpData:サインアップ・サインインの入力データを保持します。
  • handleAuthFormChange:フォームの入力内容を更新します。
  • handleAuthClick:認証(サインアップまたはサインイン)の処理を実行します。
src/components/Main/AuthContainer/SignUp.tsx
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;
src/components/Main/AuthContainer/SignIn.tsx
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の向上
    • サインアウト/アカウント削除時のフィードバックがない

参考文献

やさしい図解で学ぶ ER図 表記法一覧 - Qiita
ER図とは?ER図もしくはERDとは"Entity Relationship Diagram"のことDB設計において「テーブルとテーブルを線でつなぎ、中身の種類と関係性見やすくしたもの」と…
データベースオブジェクトの命名規約 - Qiita
オブジェクトの命名規約DB設計によく携わっていた頃に多くのプロジェクトで共通で規定されていた規約をまとめてみました。ここでは オブジェクト として以下のものを対象としています。(カラムはテーブ…
React ログイン機能作ってみた。(JSX) - Qiita
はじめに結論:セキュリティ向上のため、CognitoやAuth0を使用することをお勧めします。【注意】今回は上記を使用しませんのでご了承ください。ご利用される際は、自己責任でご利用ください。実行環…
代表的なHTTPステータスコードと問題解決へのヒント - freee Developers Community
APIを利用したリクエストは、HTTPメソッドで行います。HTTPリクエストを送ると、その応答としてレスポンスが返ってきます。 レスポンスには「HTTPステータスコード」が含まれています。レスポンスに含まれるHTTPステ … Continu...
破壊的なアクションをどうデザインすべきか
データ損失は、パソコンを扱うユーザーの誰もがもっとも経験したくないことのひとつです。ただ単にデータを失うだけでなく、そこに至るまでの時間とお金も無駄なものになっ…

これまでの学習内容については以下の記事にまとめています。

コメント