[Next.js] 学習記録アプリ Firebase認証・DB実装

Next.jsによる学習記録アプリ、Firebase認証・DB実装 の後編です。
前編に続き、後編では、DBデータの更新・新規登録・削除機能の実装、ユーザーログイン、サインアップ、パスワード更新・リセット処理の実装、そしてGitHubとの連携とVercelへのデプロイ、AWS Amplifyへのデプロイについて記載しています。
GitHub上にコードを公開してますので、よろしければ参考にしていただければと思います。
https://github.com/amaya690221/pub-next-learnings-firebase

Next.jsは、Reactをベースに開発された、フロントエンドフレームワークです。ReactはJavaScript言語を用いた、Webサイト上のUIを構築するためのライブラリで、フレームワークとは、開発を効率化するための枠組みです。
Next.jsは「URLルーティング」と呼ばれるリクエストされたURLに対して呼び出すアクションを決定する仕組みや、Webアプリ開発を効率よくするための機能が多く含まれているのが特長です。

はじめに

本記事は、Next.jsを利用し、FirebaseのAuthenticationによる認証と、Firestore DatabaseによるDBを連携させた学習記録アプリのチュートリアル記事です。
今回は後編となります。
前編ではサーバーコンポーネント、及びクライアントコンポーネントの作成で、FirestoreDBのデータ取得まで実装しました。後編は、その続き、DBデータの更新処理から始めます。

なお、アプリの構造は下図となります。

Next.jsアプリ構造

5. DBデータ更新

後編と言うことで、前編の続き、5章から開始です。
ここでは、DBデータの更新処理をクライアントコンポーネントに実装していきます。
更新処理はUIとしては、Editコンポーネントを使います。処理ロジックはRecordsコンポーネントに実装していきます。

5.1 Recordsの変更

では、まずは、RecordsコンポーネントにDBデータ更新機能を追加します。
以下、変更後のRecords.tsxのコード全文です。

// /app/components/Records.tsx

"use client";
import { useEffect, useRef, useState } from "react";
import {
  AlertDialog,
  AlertDialogBody,
  AlertDialogCloseButton,
  AlertDialogContent,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogOverlay,
  Box,
  Button,
  Card,
  CardBody,
  Flex,
  Heading,
  Stack,
  Table,
  TableContainer,
  Tbody,
  Td,
  Th,
  Thead,
  Tr,
  useDisclosure,
  useToast,
  Spinner,
} from "@chakra-ui/react";
import { User } from "firebase/auth";
import { StudyData } from "../utils/studyData";
import Edit from "./Edit";
import Delete from "./Delete";
import NewEntry from "./NewEntry";

const Records = () => {
  const [email, setEmail] = useState("test@test.com");
  const [learnings, setLearnings] = useState<StudyData[]>([]);
  const { isOpen, onOpen, onClose } = useDisclosure();
  const cancelRef = useRef(null);
  const [loading, setLoading] = useState(false);
  const [user, setUser] = useState<User | null>(null);
  const toast = useToast();

  /***Firestoreデータ取得***/
  const fetchDb = async (email: string) => {
    setLoading(true);
    try {
      //サーバーコンポーネントの/api/records/read に対してリクエスト
      const res = await fetch(`/api/records/read?email=${email}`);
      const data = await res.json();

      if (res.ok && data.success) {
        console.log("fetchStudies:", email, data);
        setLearnings(data.data); // 成功時にデータをセット
      } else {
        console.error("fetchStudiesError", email, data);
        throw new Error(data.error || "Failed to fetch studies.");
      }
    } catch (err: unknown) {
      console.error("Error in fetchStudies:", err);
      toast({
        //エラー時はChakraUIのToast機能でエラー表示
        title: "データ取得に失敗しました",
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  //追加
  /**Firestoreデータ更新**/
  const updateDb = async (learnings: StudyData) => {
    setLoading(true);
    try {
      const res = await fetch("/api/records/update", {
        method: "PUT",
        body: JSON.stringify(learnings),
        headers: { "Content-Type": "application/json" },
      });
      const data = await res.json();
      if (data.success) {
        fetchDb(email); // リロード
        toast({
          title: "データ更新が完了しました",
          position: "top",
          status: "success",
          duration: 2000,
          isClosable: true,
        });
      } else {
        throw new Error(data.error);
      }
    } catch (err: unknown) {
      toast({
        title: "データ更新に失敗しました",
        description: `${err}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  //Firestore確認
  useEffect(() => {
    if (email) {
      fetchDb(email);
      console.log("useEffectFirestore:", email, user);
    }
  }, [user]); // userが更新された時のみ実行

  /**学習時間合計**/
  const calculateTotalTime = () => {
    return learnings.reduce((total, learning) => total + learning.time, 0);
  };

  return (
    <>
      <Flex alignItems="center" justify="center" p={5}>
        <Card size={{ base: "sm", md: "lg" }}>
          <Box textAlign="center" mb={2} mt={10}>
            ようこそ!{email} さん
          </Box>
          <Heading size="md" textAlign="center">
            Learning Records
          </Heading>
          <CardBody>
            {/*学習記録表示 */}
            <Box textAlign="center">
              学習記録
              {loading && (
                <Box p={10}>
                  <Spinner />
                </Box>
              )}
              <TableContainer>
                <Table variant="simple" size={{ base: "sm", md: "lg" }}>
                  <Thead>
                    <Tr>
                      <Th>学習内容</Th>
                      <Th>時間()</Th>
                      <Th></Th>
                      <Th></Th>
                    </Tr>
                  </Thead>
                  <Tbody>
                    {learnings.map((learning, index) => (
                      <Tr key={index}>
                        <Td>{learning.title}</Td>
                        <Td>{learning.time}</Td>
                        <Td>
                          <Edit
                            learning={learning}
                            // 追加ここから
                            updateDb={updateDb}
                            loading={loading}
                            // 追加ここまで
                          />
                        </Td>
                        <Td>
                          <Delete
                            learning={learning}
                          />
                        </Td>
                      </Tr>
                    ))}
                  </Tbody>
                </Table>
              </TableContainer>
            </Box>
            {!loading && (
              <Box p={5}>
                <div>合計学習時間:{calculateTotalTime()}</div>
              </Box>
            )}

            {/*データ新規登録*/}
            <Box p={25}>
              <NewEntry
                learnings={learnings}
              />
            </Box>

            {/* ログアウト*/}
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button width="100%" variant="outline" onClick={onOpen}>
                  ログアウト
                </Button>
                <AlertDialog
                  motionPreset="slideInBottom"
                  leastDestructiveRef={cancelRef}
                  onClose={onClose}
                  isOpen={isOpen}
                  isCentered
                >
                  <AlertDialogOverlay />
                  <AlertDialogContent>
                    <AlertDialogHeader>ログアウト</AlertDialogHeader>
                    <AlertDialogCloseButton />
                    <AlertDialogBody>ログアウトしますか?</AlertDialogBody>
                    <AlertDialogFooter>
                      <Button ref={cancelRef} onClick={onClose}>
                        Cancel
                      </Button>
                      <Button
                        loadingText="Loading"
                        spinnerPlacement="start"
                        colorScheme="red"
                        ml={3}
                        onClick={() => {}}
                      >
                        ログアウト
                      </Button>
                    </AlertDialogFooter>
                  </AlertDialogContent>
                </AlertDialog>
              </Stack>
            </Box>

            {/*パスワード更新 */}
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button
                  width="100%"
                  variant="outline"
                  onClick={() => {}}
                >
                  パスワード更新
                </Button>
              </Stack>
            </Box>
          </CardBody>
        </Card>
      </Flex>
    </>
  );
};

export default Records;

では、解説していきます。
冒頭のインポートの箇所、コンポーネントやフック定義の箇所は、変更ありません。

続いて、updateDbとして、FirestoreDBの更新処理を追加しています。

// /app/components/Records.tsx

//追加
/**Firestoreデータ更新**/
const updateDb = async (learnings: StudyData) => {
  //async/awaitによる非同期通信
  setLoading(true); //ローディング中にセット
  try {
    //サーバーコンポーネントの/api/records/update に対してリクエスト
    const res = await fetch("/api/records/update", {
      method: "PUT", //メソッドはPUT
      body: JSON.stringify(learnings), //learningsをJSON形式でbodyとして渡す
      headers: { "Content-Type": "application/json" }, //ヘッダの設定、 "application/json"
    });
    const data = await res.json();
    if (data.success) {
      //処理成功の場合は
      fetchDb(email); // 更新後のDBデータ全体を改めて取得
      toast({//ChakraUIのトースト機能で、更新完了のメッセージを表示
        title: "データ更新が完了しました",
        position: "top",
        status: "success",
        duration: 2000,
        isClosable: true,
      });
    } else {
      throw new Error(data.error);
    }
  } catch (err: unknown) {//エラー発生の場合は
    toast({
      //ChakraUIのトースト機能で、更新失敗のメッセージを表示
      title: "データ更新に失敗しました",
      description: `${err}`,
      position: "top",
      status: "error",
      duration: 4000,
      isClosable: true,
    });
  } finally {
    setLoading(false); //最後にローディング状態を解除
  }
};

コード中にコメントを記載しています。サーバーコンポーネントの更新用コンポーネントに対し、データ更新(PUT)をリクエストしています。リクエストのBodyとしては、学習記録のステート、learningsをJSONの形式で渡しています。
データ更新処理が成功すれば、fetchDbで更新後のFirestoreのDBデータ全体を取得の上、Chakra UIのトースト機能で、更新完了のメッセージを表示しています。エラー時はChakra UI のトースト機能でエラーメッセージを表示させています。
最後にローディング状態を解除しています。

続いて、JSXの箇所です。

// /app/components/Records.tsx

<Td>
  <Edit
    learning={learning}
    // 追加ここから
    updateDb={updateDb}
    loading={loading}
    // 追加ここまで
  />
</Td>;

JSXの変更は、上記の通り、Editコンポーネントに渡すPropsの追加です。
Recordsコンポーネントで追加してきた、updateDb, loadingを追加しています。

5.2 Editの変更

次に、更新処理のUIを実装している、Editコンポーネントを変更していきます。
追加Propsの定義、inputのデータ変更時、及び更新ボタンクリック時の処理等を追加します。
以下、Edit.tsxの変更内容、コード全文です。

// /app/components/Edit.tsx

"use client";

import React, { useRef, useState } from "react";
import {
  Button,
  FormControl,
  FormLabel,
  Input,
  Modal,
  ModalBody,
  ModalCloseButton,
  ModalContent,
  ModalFooter,
  ModalHeader,
  ModalOverlay,
  useDisclosure,
  useToast, //追加
} from "@chakra-ui/react";
import { FiEdit } from "react-icons/fi";
import { StudyData } from "../utils/studyData";

type Props = {
  learning: StudyData;
  loading: boolean; //追加
  updateDb: (data: StudyData) => void; //追加
};

const Edit: React.FC<Props> = ({
  learning,
  updateDb, //追加
  loading, //追加
}) => {
  const [updateLearning, setUpdateLearning] = useState(learning);
  const { isOpen, onOpen, onClose } = useDisclosure();
  const initialRef = useRef(null);
  const toast = useToast(); //追加

  //追加
  //input変更時の処理
  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    //eは、操作イベントの事。型は、React.ChangeEvent<HTMLInputElement>と定義される。
    const { name, value } = e.target; //イベントオブジェクトのtarget内に存在する、name と value を定数としてセット
    setUpdateLearning({
      //セット関数でupdateLearningに値を更新
      ...updateLearning, //updateLearningに対して
      [name]: name === "time" ? Number(value) : value,
      //3項演算子で処理。inputフィールドのnameがtimeの場合は、valueは、number型の値として扱い、そうではない場合は、valueは、string型の値として扱う
    });
  };

  //追加
  //更新ボタンクリック時の処理
  const handleUpdate = async () => {
    await updateDb(updateLearning); //updateLearningを引数としupdateDb実行、DBデータ更新
    if (!loading) {
      //ローディングが解除されていれば
      onClose(); //モーダルクローズ
    }
  };

  return (
    <>
      {/*モーダル開閉ボタン*/}
      <Button variant="ghost" onClick={onOpen}>
        <FiEdit color="black" />
      </Button>

      {/*モーダル本体 */}
      <Modal initialFocusRef={initialRef} isOpen={isOpen} onClose={onClose}>
        <ModalOverlay />
        <ModalContent>
          <ModalHeader>記録編集</ModalHeader>
          <ModalCloseButton />
          <ModalBody pb={6}>
            <FormControl>
              <FormLabel>学習内容</FormLabel>
              <Input
                ref={initialRef}
                placeholder="学習内容"
                name="title"
                value={updateLearning.title}
                onChange={handleInputChange} //変更
              />
            </FormControl>

            <FormControl mt={4}>
              <FormLabel>学習時間</FormLabel>
              <Input
                type="number"
                placeholder="学習時間"
                name="time"
                value={updateLearning.time}
                onChange={handleInputChange} //変更
              />
            </FormControl>
            <div>入力されている学習内容:{updateLearning.title}</div>
            <div>入力されている学習時間:{updateLearning.time}</div>
          </ModalBody>

          <ModalFooter>
            <Button
              isLoading={loading} //追加
              loadingText="Loading" //追加
              spinnerPlacement="start" //追加
              colorScheme="green" //追加
              mr={3}
              onClick={() => {
                //変更、inputの入力値チェックの上、handleUpdateを実行
                if (updateLearning.title !== "" && updateLearning.time > 0) {
                  handleUpdate();
                } else {
                  toast({
                    //入力内容に不足がある場合は、ChakraUIのトーストでメッセージ表示
                    title: "学習内容と時間を入力してください",
                    position: "top",
                    status: "error",
                    duration: 2000,
                    isClosable: true,
                  });
                }
              }}
            >
              データを更新
            </Button>
            <Button onClick={onClose}>Cancel</Button>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </>
  );
};

export default Edit;

冒頭のインポートの箇所は、ChakraUIのToast機能を追加しています。

続いて型定義、コンポーネント定義、フック定義の箇所です。

// /app/components/Edit.tsx

type Props = {
  learning: StudyData;
  loading: boolean; //追加
  updateDb: (data: StudyData) => void; //追加
};

const Edit: React.FC<Props> = ({
  learning,
  updateDb, //追加
  loading, //追加
}) => {
  const [updateLearning, setUpdateLearning] = useState(learning);
  const { isOpen, onOpen, onClose } = useDisclosure();
  const initialRef = useRef(null);
  const toast = useToast(); //追加

Recordsコンポーネントから受け取る、追加されたpropsの型を定義しています。loading, updateDbです。コンポーネント定義の箇所は、先の型定義の通り、追加されたprops、loading, updateDbを追記しています。フック定義については、Chakra UIのuseToastを追加しています。

次に、Inputの内容変更時のonChangeと更新ボタンクリック時のonClickの処理についてです。

// /app/components/Edit.tsx
  
//追加
//input変更時の処理
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  //eは、操作イベントの事。型は、React.ChangeEvent<HTMLInputElement>と定義される。
  const { name, value } = e.target; //イベントオブジェクトのtarget内に存在する、name と value を定数としてセット
  setUpdateLearning({
    //セット関数でupdateLearningに値を更新
    ...updateLearning, //updateLearningに対して
    [name]: name === "time" ? Number(value) : value,
    //3項演算子で処理。inputフィールドのnameがtimeの場合は、valueは、number型の値として扱い、そうではない場合は、valueは、string型の値として扱う
  });
};

//追加
//更新ボタンクリック時の処理
const handleUpdate = async () => {
  await updateDb(updateLearning); //updateLearningを引数としupdateDb実行、DBデータ更新
  if (!loading) {
    //ローディングが解除されていれば
    onClose(); //モーダルクローズ
  }
};

handleInputChangeの名前で、inputフィールド変更時の処理、onChangeの処理を記述しています。Editは更新処理となりますので、inputフィールドには更新前のデータが予めセットされています。その内容を変更した際の処理です。
その処理内容はコメント記載通りです。この後のJSXの箇所で定義される、input(Chakra UIではInput)の “name”に対する値が更新された場合、nameがtimeの場合は、StudyDataの型定義の通り、number型となりますので、値はnumber型として扱い、それ以外はstringとして扱います(=valueそのものとなります)。この処理は3項演算子で処理しています。
これは、条件 ? true時の処理 : false時の処理 と言う記載の仕方となります。

そして、その値をsetLocalLearningにてlocalLearningに格納しています。

その下の、handleUpdateは、更新ボタンをクリックした際、onClick時に処理されます。
updateLearningを引数としupdateDb実行、DBデータ更新をします。その後、ローディングが解除されればモーダルをクローズする動きです。

最後に、JSXの箇所です。

// /app/components/Edit.tsx

return (
  <>
    {/*モーダル開閉ボタン*/}
    <Button variant="ghost" onClick={onOpen}>
      <FiEdit color="black" />
    </Button>

    {/*モーダル本体 */}
    <Modal initialFocusRef={initialRef} isOpen={isOpen} onClose={onClose}>
      <ModalOverlay />
      <ModalContent>
        <ModalHeader>記録編集</ModalHeader>
        <ModalCloseButton />
        <ModalBody pb={6}>
          <FormControl>
            <FormLabel>学習内容</FormLabel>
            <Input
              ref={initialRef}
              placeholder="学習内容"
              name="title"
              value={updateLearning.title}
              onChange={handleInputChange} //変更
            />
          </FormControl>

          <FormControl mt={4}>
            <FormLabel>学習時間</FormLabel>
            <Input
              type="number"
              placeholder="学習時間"
              name="time"
              value={updateLearning.time}
              onChange={handleInputChange} //変更
            />
          </FormControl>
          <div>入力されている学習内容:{updateLearning.title}</div>
          <div>入力されている学習時間:{updateLearning.time}</div>
        </ModalBody>

        <ModalFooter>
          <Button
            isLoading={loading} //追加
            loadingText="Loading" //追加
            spinnerPlacement="start" //追加
            colorScheme="green" //追加
            mr={3}
            onClick={() => {
              //変更、inputの入力値チェックの上、handleUpdateを実行
              if (updateLearning.title !== "" && updateLearning.time > 0) {
                handleUpdate();
              } else {
                toast({
                  //入力内容に不足がある場合は、ChakraUIのトーストでメッセージ表示
                  title: "学習内容と時間を入力してください",
                  position: "top",
                  status: "error",
                  duration: 2000,
                  isClosable: true,
                });
              }
            }}
          >
            データを更新
          </Button>
          <Button onClick={onClose}>Cancel</Button>
        </ModalFooter>
      </ModalContent>
    </Modal>
  </>
);

return以降のJSX部分は、これまで仮設置だった、<Input>のonChangeの箇所と、<Button>のonClickの箇所を実際の処理に置換えています。

onChangeは、handleInputChangeを実行しています。
onClickはInputに入力されたデータ有無をチェックの上、OKであればhandleUpdateを実行、NGであればChakraUIのトーストで入力を促すメッセージ表示する形にしています。

これでDBデータの更新処理の実装は完了です。この時点で以下のような処理が実現出来ます。

学習記録アプリの動きアニメ

Firestore上のデータも更新されていることが確認出来ます。

6. DBデータ新規登録

次にDBデータの新規登録処理をクライアントコンポーネントに実装していきます。新規登録処理は、UIはNewEntryコンポーネント、処理ロジックはRecordsコンポーネントに実装していきます。

6.1 Recordsの変更

まずは、Records.tsxを変更していきます。新たにFirestoreにデータを登録する処理を追加します。変更後のRecords.tsx、コード全文は以下となります。

// /app/components/Records.tsx

"use client";
import { useEffect, useRef, useState } from "react";
import {
  AlertDialog,
  AlertDialogBody,
  AlertDialogCloseButton,
  AlertDialogContent,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogOverlay,
  Box,
  Button,
  Card,
  CardBody,
  Flex,
  Heading,
  Stack,
  Table,
  TableContainer,
  Tbody,
  Td,
  Th,
  Thead,
  Tr,
  useDisclosure,
  useToast,
  Spinner,
} from "@chakra-ui/react";
import { User } from "firebase/auth";
import { StudyData } from "../utils/studyData";
import Edit from "./Edit";
import Delete from "./Delete";
import NewEntry from "./NewEntry";

const Records = () => {
  const [email, setEmail] = useState("test@test.com");
  const [learnings, setLearnings] = useState<StudyData[]>([]);
  const { isOpen, onOpen, onClose } = useDisclosure();
  const cancelRef = useRef(null);
  const [loading, setLoading] = useState(false);
  const [user, setUser] = useState<User | null>(null);
  const toast = useToast();

  /** Firestoreデータ取得 **/
  const fetchDb = async (email: string) => {
    setLoading(true);
    try {
      const res = await fetch(`/api/records/read?email=${email}`);
      const data = await res.json();

      if (res.ok && data.success) {
        console.log("fetchStudies:", email, data);
        setLearnings(data.data); 
      } else {
        console.error("fetchStudiesError", email, data);
        throw new Error(data.error || "Failed to fetch studies.");
      }
    } catch (err: unknown) {
      console.error("Error in fetchStudies:", err);
      toast({
        title: "データ取得に失敗しました",
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  //追加
  /** Firestoreデータ新規登録 **/
  const entryDb = async (study: StudyData) => {
    setLoading(true);
    try {
      const res = await fetch("/api/records/create", {
        method: "POST",
        body: JSON.stringify({
          title: study.title,
          time: study.time,
          email: email,
        }),
        headers: { "Content-Type": "application/json" },
      });
      const data = await res.json();
      console.log(data);
      if (data.success) {
        fetchDb(email); // リロード
        toast({
          title: "データ登録が完了しました",
          position: "top",
          status: "success",
          duration: 2000,
          isClosable: true,
        });
      } else {
        throw new Error(data.error);
      }
    } catch (err: unknown) {
      toast({
        title: "データ登録に失敗しました",
        description: `${err}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** Firestoreデータ更新 **/
  const updateDb = async (learnings: StudyData) => {
    setLoading(true);
    try {
      const res = await fetch("/api/records/update", {
        method: "PUT",
        body: JSON.stringify(learnings),
        headers: { "Content-Type": "application/json" },
      });
      const data = await res.json();
      if (data.success) {
        fetchDb(email); // リロード
        toast({
          title: "データ更新が完了しました",
          position: "top",
          status: "success",
          duration: 2000,
          isClosable: true,
        });
      } else {
        throw new Error(data.error);
      }
    } catch (err: unknown) {
      toast({
        title: "データ更新に失敗しました",
        description: `${err}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** Firestore確認 **/
  useEffect(() => {
    if (email) {
      fetchDb(email);
      console.log("useEffectFirestore:", email, user);
    }
  }, [user]);

  /** 学習時間合計 **/
  const calculateTotalTime = () => {
    return learnings.reduce((total, learning) => total + learning.time, 0);
  };

  return (
    <>
      <Flex alignItems="center" justify="center" p={5}>
        <Card size={{ base: "sm", md: "lg" }}>
          <Box textAlign="center" mb={2} mt={10}>
            ようこそ!{email} さん
          </Box>
          <Heading size="md" textAlign="center">
            Learning Records
          </Heading>
          <CardBody>
            {/*学習記録表示 */}
            <Box textAlign="center">
              学習記録
              {loading && (
                <Box p={10}>
                  <Spinner />
                </Box>
              )}
              <TableContainer>
                <Table variant="simple" size={{ base: "sm", md: "lg" }}>
                  <Thead>
                    <Tr>
                      <Th>学習内容</Th>
                      <Th>時間()</Th>
                      <Th></Th>
                      <Th></Th>
                    </Tr>
                  </Thead>
                  <Tbody>
                    {learnings.map((learning, index) => (
                      <Tr key={index}>
                        <Td>{learning.title}</Td>
                        <Td>{learning.time}</Td>
                        <Td>
                          <Edit
                            learning={learning}
                            updateDb={updateDb}
                            loading={loading}
                          />
                        </Td>
                        <Td>
                          <Delete learning={learning} />
                        </Td>
                      </Tr>
                    ))}
                  </Tbody>
                </Table>
              </TableContainer>
            </Box>
            {!loading && (
              <Box p={5}>
                <div>合計学習時間:{calculateTotalTime()}</div>
              </Box>
            )}

            {/*データ新規登録*/}
            <Box p={25}>
              <NewEntry
                learnings={learnings}
                //追加ここから
                loading={loading}
                updateDb={updateDb}
                entryDb={entryDb}
                //追加ここまで
              />
            </Box>

            {/* ログアウト*/}
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button width="100%" variant="outline" onClick={onOpen}>
                  ログアウト
                </Button>
                <AlertDialog
                  motionPreset="slideInBottom"
                  leastDestructiveRef={cancelRef}
                  onClose={onClose}
                  isOpen={isOpen}
                  isCentered
                >
                  <AlertDialogOverlay />
                  <AlertDialogContent>
                    <AlertDialogHeader>ログアウト</AlertDialogHeader>
                    <AlertDialogCloseButton />
                    <AlertDialogBody>ログアウトしますか?</AlertDialogBody>
                    <AlertDialogFooter>
                      <Button ref={cancelRef} onClick={onClose}>
                        Cancel
                      </Button>
                      <Button
                        loadingText="Loading"
                        spinnerPlacement="start"
                        colorScheme="red"
                        ml={3}
                        onClick={() => {}}
                      >
                        ログアウト
                      </Button>
                    </AlertDialogFooter>
                  </AlertDialogContent>
                </AlertDialog>
              </Stack>
            </Box>

            {/*パスワード更新 */}
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button width="100%" variant="outline" onClick={() => {}}>
                  パスワード更新
                </Button>
              </Stack>
            </Box>
          </CardBody>
        </Card>
      </Flex>
    </>
  );
};

export default Records;

解説していきます。
冒頭のインポートの箇所、コンポーネントやフック定義の箇所は、変更ありません。

続いて、entryDbとして、FirestoreDBの新規登録処理を追加しています。

// /app/components/Records.tsx

//追加
/** Firestoreデータ新規登録 **/
const entryDb = async (study: StudyData) => {
  //async/awaitによる非同期通信
  setLoading(true); //ローディング中にセット
  try {
    //サーバーコンポーネントの/api/records/create に対してリクエスト
    const res = await fetch("/api/records/create", {
      method: "POST", //メソッドはPOST
      body: JSON.stringify({
        //title, time, emailをJSON形式でbodyとして渡す
        title: study.title,
        time: study.time,
        email: email,
      }),
      headers: { "Content-Type": "application/json" }, //ヘッダの設定、 "application/json"
    });
    const data = await res.json();
    if (data.success) {
      //処理成功の場合は
      fetchDb(email); // 登録後のDBデータ全体を改めて取得
      toast({
        //ChakraUIのトースト機能で、登録完了のメッセージを表示
        title: "データ登録が完了しました",
        position: "top",
        status: "success",
        duration: 2000,
        isClosable: true,
      });
    } else {
      throw new Error(data.error);
    }
  } catch (err: unknown) {
    //エラー発生の場合は
    toast({
      //ChakraUIのトースト機能で、登録失敗のメッセージを表示
      title: "データ登録に失敗しました",
      description: `${err}`,
      position: "top",
      status: "error",
      duration: 4000,
      isClosable: true,
    });
  } finally {
    setLoading(false); //最後にローディング状態を解除
  }
};

コード中にコメントを記載しています。サーバーコンポーネントの登録用コンポーネントに対し、データ登録(POST)をリクエストしています。リクエストのBodyとしては、title, time, emailをJSONの形式で渡しています。
データ登録処理が成功すれば、fetchDbで登録後のFirestoreのDBデータ全体を取得の上、Chakra UIのトースト機能で、登録完了のメッセージを表示しています。エラー時はChakra UI のトースト機能でエラーメッセージを表示させています。
最後にローディング状態を解除しています。

続いて、JSXの箇所についてです。

// /app/components/Records.tsx

{/*データ新規登録*/ }
<Box p={25}>
  <NewEntry
    learnings={learnings}
    //追加ここから
    loading={loading}
    updateDb={updateDb}
    entryDb={entryDb}
    //追加ここまで
  />
</Box>;

変更しているのは、NewEntryコンポーネントの箇所です。新たにpropsとして、loading, updateDb, entryDbを追加しています。

6.2 NewEntryの変更

続いて、新規登録のUI関係を実装している、NewEntryコンポーネントを変更していきます。
追加Propsの定義、inputのデータ変更時、及び登録ボタンクリック時の処理等を追加します。Editコンポーネントの時と同じような変更内容です。
以下、NewEntry.tsxの変更内容、コード全文です。

// /app/components/NewEntry.tsx

"use client";

import { useRef, useState } from "react";
import {
  Button,
  FormControl,
  FormLabel,
  Input,
  Modal,
  ModalBody,
  ModalCloseButton,
  ModalContent,
  ModalFooter,
  ModalHeader,
  ModalOverlay,
  Stack,
  useDisclosure,
  useToast, //追加
} from "@chakra-ui/react";
import { StudyData } from "../utils/studyData";

type Props = {
  learnings: StudyData[];
  loading: boolean; //追加
  updateDb: (data: StudyData) => void; //追加
  entryDb: (data: StudyData) => void; //追加
};

const NewEntry: React.FC<Props> = ({
  learnings,
  updateDb, //追加
  loading, //追加
  entryDb, //追加
}) => {
  const [entryLearning, SetEntryLearning] = useState<StudyData>({
    id: "",
    title: "",
    time: 0,
    email: "",
  });
  const { isOpen, onOpen, onClose } = useDisclosure();
  const initialRef = useRef(null);
  const toast = useToast(); //追加

  //追加
  //input入力時の処理
  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    //eは、操作イベントの事。型は、React.ChangeEvent<HTMLInputElement>と定義される。
    const { name, value } = e.target; //イベントオブジェクトのtarget内に存在する、name と value を定数としてセット
    SetEntryLearning({
      //セット関数でentryLearningの値を更新
      ...entryLearning, //entryLearningに対して
      [name]: name === "time" ? Number(value) : value,
      //3項演算子で処理。inputフィールドのnameがtimeの場合は、valueは、number型の値として扱い、そうではない場合は、valueは、string型の値として扱う
    });
  };

  //追加
  //登録ボタンクリック時の処理
  const handleEntry = async () => {
    if (learnings.some((l) => l.title === entryLearning.title)) {
      const existingLearning = learnings.find(
        (l) => l.title === entryLearning.title
      );
      if (existingLearning) {
        existingLearning.time += entryLearning.time;
        await updateDb(existingLearning);
      }
    } else {
      await entryDb(entryLearning);
    }
    SetEntryLearning({ id: "", title: "", time: 0, email: "" });
    if (!loading) {//ローディングが解除されていれば
      onClose(); //モーダルクローズ
    }
  };

  return (
    <>
      <Stack spacing={3}>
        <Button colorScheme="green" variant="outline" onClick={onOpen}>
          新規データ登録
        </Button>
      </Stack>
      <Modal initialFocusRef={initialRef} isOpen={isOpen} onClose={onClose}>
        <ModalOverlay />
        <ModalContent>
          <ModalHeader>新規データ登録</ModalHeader>
          <ModalCloseButton />
          <ModalBody pb={6}>
            <FormControl>
              <FormLabel>学習内容</FormLabel>
              <Input
                ref={initialRef}
                name="title"
                placeholder="学習内容"
                value={entryLearning.title}
                onChange={handleInputChange} //変更
              />
            </FormControl>

            <FormControl mt={4}>
              <FormLabel>学習時間</FormLabel>
              <Input
                type="number"
                name="time"
                placeholder="学習時間"
                value={entryLearning.time}
                onChange={handleInputChange} //変更
              />
            </FormControl>
            <div>入力されている学習内容:{entryLearning.title}</div>
            <div>入力されている学習時間:{entryLearning.time}</div>
          </ModalBody>
          <ModalFooter>
            <Button
              isLoading={loading} //追加
              loadingText="Loading" //追加
              spinnerPlacement="start" //追加
              colorScheme="green"
              mr={3}
              onClick={() => {
                //変更
                if (entryLearning.title !== "" && entryLearning.time > 0) {
                  handleEntry();
                } else {
                  toast({
                    title: "学習内容と時間を入力してください",
                    position: "top",
                    status: "error",
                    duration: 2000,
                    isClosable: true,
                  });
                }
              }}
            >
              登録
            </Button>
            <Button
              onClick={onClose}
            >
              Cancel
            </Button>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </>
  );
};
export default NewEntry;

冒頭のインポートの箇所は、ChakraUIのToast機能を追加しています。

続いて型定義、コンポーネント定義、フック定義の箇所です。

// /app/components/NewEntry.tsx

type Props = {
  learnings: StudyData[];
  loading: boolean; //追加
  updateDb: (data: StudyData) => void; //追加
  entryDb: (data: StudyData) => void; //追加
};

const NewEntry: React.FC<Props> = ({
  learnings,
  updateDb, //追加
  loading, //追加
  entryDb, //追加
}) => {
  const [entryLearning, SetEntryLearning] = useState<StudyData>({
    id: "",
    title: "",
    time: 0,
    email: "",
  });
  const { isOpen, onOpen, onClose } = useDisclosure();
  const initialRef = useRef(null);
  const toast = useToast(); //追加

Recordsコンポーネントから受け取る、追加されたpropsの型を定義しています。loading, updateDb, entryDbです。コンポーネント定義の箇所は、先の型定義の通り、追加されたprops、loading, updateDb, entryDbを追記しています。フック定義については、Chakra UIのuseToastを追加しています。

次に、Inputの内容変更時のonChangeと登録ボタンクリック時のonClickの処理についてです。

// /app/components/NewEntry.tsx

//追加
//input入力時の処理
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  //eは、操作イベントの事。型は、React.ChangeEvent<HTMLInputElement>と定義される。
  const { name, value } = e.target; //イベントオブジェクトのtarget内に存在する、name と value を定数としてセット
  SetEntryLearning({
    //セット関数でentryLearningの値を更新
    ...entryLearning, //entryLearningに対して
    [name]: name === "time" ? Number(value) : value,
    //3項演算子で処理。inputフィールドのnameがtimeの場合は、valueは、number型の値として扱い、そうではない場合は、valueは、string型の値として扱う
  });
};

//追加
//登録ボタンクリック時の処理
const handleEntry = async () => {
  //入力された学習内容(title)が既にあるかどうかチェック
  if (learnings.some((l) => l.title === entryLearning.title)) {
    //someメソッドでentryLearning.titleに一致するものが既にある場合は、existingLearningにその内容を格納
    const existingLearning = learnings.find(
      (l) => l.title === entryLearning.title
    );
    if (existingLearning) {
      //もし、学習内容が既にあるものであれば、
      existingLearning.time += entryLearning.time; //入力された学習時間(time)を既存の学習時間に加算し、
      await updateDb(existingLearning); //データ「更新」を行う
    }
  } else {
    //学習内容(title)が新規であれば、
    await entryDb(entryLearning); //データ「新規登録」を行う
  }
  SetEntryLearning({ id: "", title: "", time: 0, email: "" }); //entryLearningを初期化
  if (!loading) {
    //ローディングが解除されていれば
    onClose(); //モーダルクローズ
  }
};

handleInputChangeの名前で、inputフィールド変更時の処理、onChangeの処理を記述しています。これはEditコンポーネントと同じ内容です。Ninputフィールドにデータを入力した際の処理です。処理内容はコメント記載通りです。3項演算子でnameがtimeの場合は、StudyDataの型定義の通り、number型となりますので、値はnumber型として扱い、それ以外はstringとして扱っています。それをSetEntryLearningで、entryLearningに格納しています。

下の、handleEntryは、登録ボタンをクリックした際、onClick時に処理されます。
ここの処理は、入力された学習内容により、挙動が異なります。学習内容が既に存在するものであれば、学習時間を既存の学習時間に加算する処理としています。この場合は、DBデータの新規登録ではなく、「更新」処理となります。よって、updateDbによる処理を行っています。
学習内容が新しいものであれば、学習内容と学習時間をentryDbにより、DBに新規登録しています。
処理後、ステートentryLearningを初期化し、ローディングが解除されればモーダルをクローズします。

最後に、JSXの箇所です。

// /app/components/NewEntry.tsx

return (
  <>
    <Stack spacing={3}>
      <Button colorScheme="green" variant="outline" onClick={onOpen}>
        新規データ登録
      </Button>
    </Stack>
    <Modal initialFocusRef={initialRef} isOpen={isOpen} onClose={onClose}>
      <ModalOverlay />
      <ModalContent>
        <ModalHeader>新規データ登録</ModalHeader>
        <ModalCloseButton />
        <ModalBody pb={6}>
          <FormControl>
            <FormLabel>学習内容</FormLabel>
            <Input
              ref={initialRef}
              name="title"
              placeholder="学習内容"
              value={entryLearning.title}
              onChange={handleInputChange} //変更
            />
          </FormControl>

          <FormControl mt={4}>
            <FormLabel>学習時間</FormLabel>
            <Input
              type="number"
              name="time"
              placeholder="学習時間"
              value={entryLearning.time}
              onChange={handleInputChange} //変更
            />
          </FormControl>
          <div>入力されている学習内容:{entryLearning.title}</div>
          <div>入力されている学習時間:{entryLearning.time}</div>
        </ModalBody>
        <ModalFooter>
          <Button
            isLoading={loading} //追加
            loadingText="Loading" //追加
            spinnerPlacement="start" //追加
            colorScheme="green"
            mr={3}
            onClick={() => {
              //変更
              if (entryLearning.title !== "" && entryLearning.time > 0) {
                handleEntry();
              } else {
                toast({
                  title: "学習内容と時間を入力してください",
                  position: "top",
                  status: "error",
                  duration: 2000,
                  isClosable: true,
                });
              }
            }}
          >
            登録
          </Button>
          <Button onClick={onClose}>Cancel</Button>
        </ModalFooter>
      </ModalContent>
    </Modal>
  </>
);

return以降のJSX部分は、これまで仮設置だった、<Input>のonChangeの箇所と、<Button>のonClickの箇所を実際の処理に置換えています。

onChangeは、handleInputChangeを実行しています。
onClickはInputに入力されたデータ有無をチェックの上、OKであればhandleEntryを実行、NGであればChakraUIのトーストで入力を促すメッセージ表示する形です。

ここまでで、DBデータの新規登録処理の実装は完了です。この時点で以下のような処理が実現出来ます。

学習記録アプリの動きアニメ

Firestore上でも新しいデータが登録されていることが分かります。

7. DBデータ削除

DBデータ操作、最後はデータ削除の処理をクライアントコンポーネントに実装します。削除処理は、UIはDeleteコンポーネント、処理ロジックはRecordsコンポーネントに実装していきます。

7.1 Recordsの変更

まずは、Records.tsxを変更していきます。新たにFirestoreのデータを削除する処理を追加します。変更後のRecords.tsx、コード全文は以下となります。

// /app/components/Records.tsx

"use client";
import { useEffect, useRef, useState } from "react";
import {
  AlertDialog,
  AlertDialogBody,
  AlertDialogCloseButton,
  AlertDialogContent,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogOverlay,
  Box,
  Button,
  Card,
  CardBody,
  Flex,
  Heading,
  Stack,
  Table,
  TableContainer,
  Tbody,
  Td,
  Th,
  Thead,
  Tr,
  useDisclosure,
  useToast,
  Spinner,
} from "@chakra-ui/react";
import { User } from "firebase/auth";
import { StudyData } from "../utils/studyData";
import Edit from "./Edit";
import Delete from "./Delete";
import NewEntry from "./NewEntry";

const Records = () => {
  const [email, setEmail] = useState("test@test.com");
  const [learnings, setLearnings] = useState<StudyData[]>([]);
  const { isOpen, onOpen, onClose } = useDisclosure();
  const cancelRef = useRef(null);
  const [loading, setLoading] = useState(false);
  const [user, setUser] = useState<User | null>(null);
  const toast = useToast();

  /** Firestoreデータ取得 **/
  const fetchDb = async (email: string) => {
    setLoading(true);
    try {
      const res = await fetch(`/api/records/read?email=${email}`);
      const data = await res.json();

      if (res.ok && data.success) {
        console.log("fetchStudies:", email, data);
        setLearnings(data.data);
      } else {
        console.error("fetchStudiesError", email, data);
        throw new Error(data.error || "Failed to fetch studies.");
      }
    } catch (err: unknown) {
      console.error("Error in fetchStudies:", err);
      toast({
        title: "データ取得に失敗しました",
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** Firestoreデータ新規登録 **/
  const entryDb = async (study: StudyData) => {
    setLoading(true);
    try {
      const res = await fetch("/api/records/create", {
        method: "POST",
        body: JSON.stringify({
          title: study.title,
          time: study.time,
          email: email,
        }),
        headers: { "Content-Type": "application/json" },
      });
      const data = await res.json();
      console.log(data);
      if (data.success) {
        fetchDb(email); // リロード
        toast({
          title: "データ登録が完了しました",
          position: "top",
          status: "success",
          duration: 2000,
          isClosable: true,
        });
      } else {
        throw new Error(data.error);
      }
    } catch (err: unknown) {
      toast({
        title: "データ登録に失敗しました",
        description: `${err}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** Firestoreデータ更新 **/
  const updateDb = async (learnings: StudyData) => {
    setLoading(true);
    try {
      const res = await fetch("/api/records/update", {
        method: "PUT",
        body: JSON.stringify(learnings),
        headers: { "Content-Type": "application/json" },
      });
      const data = await res.json();
      if (data.success) {
        fetchDb(email); // リロード
        toast({
          title: "データ更新が完了しました",
          position: "top",
          status: "success",
          duration: 2000,
          isClosable: true,
        });
      } else {
        throw new Error(data.error);
      }
    } catch (err: unknown) {
      toast({
        title: "データ更新に失敗しました",
        description: `${err}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  //追加
  /** Firestoreデータ削除 **/
  const deleteDb = async (id: string) => {
    setLoading(true);
    try {
      const res = await fetch("/api/records/delete", {
        method: "DELETE",
        body: JSON.stringify({ id }),
        headers: { "Content-Type": "application/json" },
      });
      const data = await res.json();
      if (data.success) {
        fetchDb(email); // リロード
        toast({
          title: "データを削除しました",
          position: "top",
          status: "success",
          duration: 2000,
          isClosable: true,
        });
      } else {
        throw new Error(data.error);
      }
    } catch (err: unknown) {
      toast({
        title: "デー削除に失敗しました",
        description: `${err}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** Firestore確認 **/
  useEffect(() => {
    if (email) {
      fetchDb(email);
      console.log("useEffectFirestore:", email, user);
    }
  }, [user]);

  /** 学習時間合計 **/
  const calculateTotalTime = () => {
    return learnings.reduce((total, learning) => total + learning.time, 0);
  };

  return (
    <>
      <Flex alignItems="center" justify="center" p={5}>
        <Card size={{ base: "sm", md: "lg" }}>
          <Box textAlign="center" mb={2} mt={10}>
            ようこそ!{email} さん
          </Box>
          <Heading size="md" textAlign="center">
            Learning Records
          </Heading>
          <CardBody>
            {/*学習記録表示 */}
            <Box textAlign="center">
              学習記録
              {loading && (
                <Box p={10}>
                  <Spinner />
                </Box>
              )}
              <TableContainer>
                <Table variant="simple" size={{ base: "sm", md: "lg" }}>
                  <Thead>
                    <Tr>
                      <Th>学習内容</Th>
                      <Th>時間()</Th>
                      <Th></Th>
                      <Th></Th>
                    </Tr>
                  </Thead>
                  <Tbody>
                    {learnings.map((learning, index) => (
                      <Tr key={index}>
                        <Td>{learning.title}</Td>
                        <Td>{learning.time}</Td>
                        <Td>
                          <Edit
                            learning={learning}
                            updateDb={updateDb}
                            loading={loading}
                          />
                        </Td>
                        <Td>
                          <Delete
                            learning={learning}
                            deleteDb={deleteDb} //追加
                            loading={loading} //追加
                          />
                        </Td>
                      </Tr>
                    ))}
                  </Tbody>
                </Table>
              </TableContainer>
            </Box>
            {!loading && (
              <Box p={5}>
                <div>合計学習時間:{calculateTotalTime()}</div>
              </Box>
            )}

            {/*データ新規登録*/}
            <Box p={25}>
              <NewEntry
                learnings={learnings}
                loading={loading}
                updateDb={updateDb}
                entryDb={entryDb}
              />
            </Box>

            {/* ログアウト*/}
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button width="100%" variant="outline" onClick={onOpen}>
                  ログアウト
                </Button>
                <AlertDialog
                  motionPreset="slideInBottom"
                  leastDestructiveRef={cancelRef}
                  onClose={onClose}
                  isOpen={isOpen}
                  isCentered
                >
                  <AlertDialogOverlay />
                  <AlertDialogContent>
                    <AlertDialogHeader>ログアウト</AlertDialogHeader>
                    <AlertDialogCloseButton />
                    <AlertDialogBody>ログアウトしますか?</AlertDialogBody>
                    <AlertDialogFooter>
                      <Button ref={cancelRef} onClick={onClose}>
                        Cancel
                      </Button>
                      <Button
                        loadingText="Loading"
                        spinnerPlacement="start"
                        colorScheme="red"
                        ml={3}
                        onClick={() => {}}
                      >
                        ログアウト
                      </Button>
                    </AlertDialogFooter>
                  </AlertDialogContent>
                </AlertDialog>
              </Stack>
            </Box>

            {/*パスワード更新 */}
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button
                  width="100%"
                  variant="outline"
                  onClick={() => {}}
                >
                  パスワード更新
                </Button>
              </Stack>
            </Box>
          </CardBody>
        </Card>
      </Flex>
    </>
  );
};

export default Records;

解説します。
冒頭のインポートの箇所、コンポーネントやフック定義の箇所は、変更ありません。

続いて、deleteDbとして、FirestoreDBの削除処理を追加しています。

// /app/components/Records.tsx

//追加
/** Firestoreデータ削除 **/
const deleteDb = async (id: string) => {
  //async/awaitによる非同期通信
  setLoading(true); //ローディング中にセット
  try {
    //サーバーコンポーネントの/api/records/delete に対してリクエスト
    const res = await fetch("/api/records/delete", {
      method: "DELETE", //メソッドはDELETE
      body: JSON.stringify({ id }), //idをJSON形式でbodyとして渡す
      headers: { "Content-Type": "application/json" }, //ヘッダの設定、 "application/json"
    });
    const data = await res.json();
    if (data.success) {
      //処理成功の場合は
      fetchDb(email); // 削除後のDBデータ全体を改めて取得
      toast({
        //ChakraUIのトースト機能で、削除完了のメッセージを表示
        title: "データを削除しました",
        position: "top",
        status: "success",
        duration: 2000,
        isClosable: true,
      });
    } else {
      throw new Error(data.error);
    }
  } catch (err: unknown) {
    //エラー発生の場合は
    toast({
      //ChakraUIのトースト機能で、削除失敗のメッセージを表示
      title: "デー削除に失敗しました",
      description: `${err}`,
      position: "top",
      status: "error",
      duration: 4000,
      isClosable: true,
    });
  } finally {
    //最後にローディング状態を解除
    setLoading(false);
  }
};

コメント記載通りですが、サーバーコンポーネントの削除用コンポーネントに対し、データ削除(DELETE)をリクエストしています。リクエストのBodyとしては、idをJSONの形式で渡しています。
データ削除処理が成功すれば、fetchDbで削除後のFirestoreのDBデータ全体を取得の上、Chakra UIのトースト機能で、登録完了のメッセージを表示しています。エラー時はChakra UI のトースト機能でエラーメッセージを表示させています。
最後にローディング状態を解除しています。

続いて、JSXの箇所についてです。

// /app/components/Records.tsx

<Delete
  learning={learning}
  deleteDb={deleteDb} //追加
  loading={loading} //追加
/>;

変更しているのは、Deleteコンポーネントの箇所です。新たにpropsとして、loading, deleteDbを追加しています。

7.2 Deleteの変更

続いて、削除処理のUI関係を実装している、Deleteコンポーネントを変更していきます。
追加Propsの定義、削除ボタンクリック時の処理等を追加します。以下、Delete.tsxの変更内容、コード全文です。

// /app/components/Delete.tsx

"use client";

import React, { useRef } from "react";
import {
  Box,
  Button,
  Modal,
  ModalBody,
  ModalCloseButton,
  ModalContent,
  ModalFooter,
  ModalHeader,
  ModalOverlay,
  useDisclosure,
} from "@chakra-ui/react";
import { MdDelete } from "react-icons/md";
import { StudyData } from "../utils/studyData";

type Props = {
  learning: StudyData;
  loading: boolean; //追加
  deleteDb: (id: string) => Promise<void>; //追加
};

const Delete: React.FC<Props> = ({
  learning,
  deleteDb, //追加
  loading, //追加
}) => {
  const { isOpen, onOpen, onClose } = useDisclosure();
  const initialRef = useRef(null);

  //追加
  const handleDelete = async () => {
    await deleteDb(learning.id as string); //undefinedの可能性があるので、stringとして型アサーション
    if (!loading) {
      onClose();
    }
  };

  return (
    <>
      {/*モーダル開閉ボタン*/}
      <Button variant="ghost" onClick={onOpen}>
        <MdDelete color="black" />
      </Button>

      {/*モーダル本体 */}
      <Modal isOpen={isOpen} onClose={onClose}>
        <ModalOverlay />
        <ModalContent>
          <ModalHeader>データ削除</ModalHeader>
          <ModalCloseButton />
          <ModalBody pb={6}>
            <Box>
              以下のデータを削除します
              <br />
              学習内容:{learning.title}、学習時間:{learning.time}
            </Box>
          </ModalBody>
          <ModalFooter>
            <Button onClick={onClose} mr={3}>
              Cancel
            </Button>
            <Button
              isLoading={loading} //追加
              loadingText="Loading" //追加
              spinnerPlacement="start" //追加
              ref={initialRef}
              colorScheme="red"
              onClick={handleDelete} //変更
            >
              削除
            </Button>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </>
  );
};

export default Delete;

冒頭のインポートの箇所は、変更ありません。
続いて型定義、コンポーネント定義、フック定義の箇所です。

// /app/components/Delete.tsx

type Props = {
  learning: StudyData;
  loading: boolean; //追加
  deleteDb: (id: string) => Promise<void>; //追加
};

const Delete: React.FC<Props> = ({
  learning,
  deleteDb, //追加
  loading, //追加
}) => {
  const { isOpen, onOpen, onClose } = useDisclosure();
  const initialRef = useRef(null);

Recordsコンポーネントから受け取る、追加されたpropsの型を定義しています。loading, deleteDbです。コンポーネント定義の箇所は、型定義の通り、追加されたprops、loading, deleteDb追記しています。

次に、削除ボタンクリック時のonClickの処理についてです。

// /app/components/Delete.tsx

//追加
//削除ボタンクリック時の処理
const handleDelete = async () => {
  //learning.idにマッチするidのデータを削除
  await deleteDb(learning.id as string); //undefinedの可能性があるので、stringとして型アサーション
  if (!loading) {
    //ローディングが解除されていれば
    onClose(); //モーダルクローズ
  }
};

handleDeleteとして、削除ボタンクリック時の挙動を定義しています。削除処理はlearning.idにマッチするidのデータを削除する処理となります。idはlearningステートのオブジェクトとしてはオプションの位置付けなので、nullの可能性があり、as stringを付与し、型アサーションを行っています。これが無いとTypeScript側でnullの可能性エラーが出ます。処理が完了し、ローディングが解除されればモーダルをクローズします。

最後に、JSXの箇所です。

// /app/components/Delete.tsx

<Button
  isLoading={loading} //追加
  loadingText="Loading" //追加
  spinnerPlacement="start" //追加
  ref={initialRef}
  colorScheme="red"
  onClick={handleDelete} //変更
>
  削除
</Button>;

変更しているのは、削除ボタンの箇所です。
ローディング状態によってスピンアニメーションを表示するオプションを追加しています。そして、onClick時に、handleDeleteを実行しDBデータ削除を行う形です。

ここまでで、DBデータ削除処理の実装は完了です。この時点で以下のような処理が出来ます。

学習記録アプリの動きアニメ

8. ユーザーログイン・ログアウト

DBデータ操作関連の実装が完了したので、これからユーザー認証関係の機能を追加していきます。これはFirebaseのAuthenticationを利用した処理です。
ユーザー認証に関しては、前編の冒頭に記載した通り、FirebaseSDKがクライアントサイドで完結している為、サーバーコンポーネントには実装せず、クライアントコンポーネントで作成していきます。最初は、ユーザーログイン・ログアウト機能です。

8.1 ログイン機能

まずは、appフォルダ配下に新たにuserフォルダ、その下にloginフォルダを作成します。そしてloginフォルダにpages.tsxと言うファイルを作成します。

前編、2章のサーバーコンポーネントで紹介した通り、このpages.tsxは、サーバーコンポーネントのroute.tsと同様、自動でURLがセットされます。ここで作成した、/app/user/login/page.tsxであれば、URLのパスは/user/login/となります。このURL指定で、そのフォルダ内のpages.tsxが実行されると言う仕組みです。

では作成した、login/page.tsxに以下コードを記載します(コード全文です)。

// /app/user/login/page.tsx

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import {
  Box,
  Button,
  Card,
  CardBody,
  Flex,
  Heading,
  Input,
  InputGroup,
  InputLeftElement,
  Stack,
  useToast,
} from "@chakra-ui/react";
import { FaUserCheck } from "react-icons/fa";
import { RiLockPasswordFill } from "react-icons/ri";
import { signInWithEmailAndPassword } from "firebase/auth";
import { auth } from "@/app/utils/firebase";

const Login = () => {
  const [formState, setFormState] = useState({
    email: "",
    password: "",
  });
  const [loading, setLoading] = useState(false);
  const router = useRouter();
  const toast = useToast();

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormState((prevState) => ({
      ...prevState,
      [name]: value,
    }));
  };

  const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setLoading(true);
    try {
      const userLogin = await signInWithEmailAndPassword(
        auth,
        formState.email,
        formState.password
      );
      console.log("User Logined:", userLogin);
      toast({
        title: "ログインしました",
        position: "top",
        status: "success",
        duration: 2000,
        isClosable: true,
      });
      router.push("/");
    } catch (error) {
      console.error("Error during sign up:", error);
      toast({
        title: "ログインに失敗しました",
        description: `${error}`,
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  return (
    <Flex justifyContent="center" boxSize="fit-content" mx="auto" p={5}>
      <Card size={{ base: "sm", md: "lg" }} p={4}>
        <Heading size="md" textAlign="center">
          ログイン
        </Heading>
        <CardBody>
          <form onSubmit={handleLogin}>
            <InputGroup>
              <InputLeftElement pointerEvents="none">
                <FaUserCheck color="gray" />
              </InputLeftElement>
              <Input
                autoFocus
                type="email"
                placeholder="メールアドレスを入力"
                name="email"
                value={formState.email}
                required
                mb={2}
                onChange={handleInputChange}
              />
            </InputGroup>
            <InputGroup>
              <InputLeftElement pointerEvents="none">
                <RiLockPasswordFill color="gray" />
              </InputLeftElement>
              <Input
                type="password"
                placeholder="パスワードを入力"
                name="password"
                value={formState.password}
                required
                mb={2}
                onChange={handleInputChange}
              />
            </InputGroup>
            <Box mt={4} mb={2} textAlign="center">
              <Button
                isLoading={loading}
                loadingText="Loading"
                spinnerPlacement="start"
                type="submit"
                colorScheme="green"
                width="100%"
                mb={2}
              >
                ログイン
              </Button>
              <Button
                colorScheme="green"
                width="100%"
                variant="outline"
                onClick={() => router.push("/user/register")}
              >
                新規登録
              </Button>
            </Box>
            <Box mt={4} mb={2} textAlign="center">
              <Stack spacing={3}>
                <Button
                  colorScheme="green"
                  width="100%"
                  variant="ghost"
                  onClick={() => router.push("/user/sendReset")}
                >
                  パスワードをお忘れですか?
                </Button>
              </Stack>
            </Box>
          </form>
        </CardBody>
      </Card>
    </Flex>
  );
};

export default Login;

解説します。
まず、冒頭のインポート部分です。

// /app/user/login/page.tsx

import { useState } from "react";//state管理、useStateインポート
import { useRouter } from "next/navigation";//画面遷移を実現するuseRouterインポート
import {
  Box,
  Button,
  Card,
  CardBody,
  Flex,
  Heading,
  Input,
  InputGroup,
  InputLeftElement,
  Stack,
  useToast,
} from "@chakra-ui/react";//ChakraUIコンポーネントのインポート
import { FaUserCheck } from "react-icons/fa";//ユーザーアイコンのインポート
import { RiLockPasswordFill } from "react-icons/ri";//パスワードアイコンのインポート
import { signInWithEmailAndPassword } from "firebase/auth";//FirebaseSDKのemailログイン機能のインポート
import { auth } from "@/app/utils/firebase"; //Firebaseクライアントから認証機能のインポート

reactのuseState, next.jsのuseRouterをインポートしてます。
他、ChakraUIの利用するコンポーネント、react-iconsのアイコンのインポート。FirebaseSKD及びutilsフォルダに作成した、Firebaseクライアントからのインポートを行っています。

続いて、フック関連の定義です。

// /app/user/login/page.tsx

const Login = () => {
  const [formState, setFormState] = useState({//フォーム入力値用ステート
    email: "",
    password: "",
  });
  const [loading, setLoading] = useState(false);//ローディング状況を管理するステート
  const router = useRouter();//useRouterの定義
  const toast = useToast();//ChakraUIのトースト機能の定義

ステートとしては、フォーム入力値用のステートformStateと、ローディング状況を管理するステートloadingを定義しています。formStateは、emailとpasswordで構成されるオブジェクトです。
他、画面遷移を行うuseRouterの定義、ChakraUIのトースト機能useToastの定義を行っています。

次に、フォームのinputフィールドに入力した際に実行される処理です。

// /app/user/login/page.tsx

// 入力値変更時の処理
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  //eは、操作イベントの事。型は、React.ChangeEvent<HTMLInputElement>と定義される。
  const { name, value } = e.target; //イベントオブジェクトのtarget内に存在する、name と value を定数としてセット
  setFormState((prevState) => ({
    //セット関数でformStateの値を更新
    //直前のstate(prevState)に対し [name]: valueを更新
    ...prevState,
    [name]: value,
  }));
};

これは、5章、6章で記載した、DBデータの更新、新規登録時と同じ処理ですが、
(prevState) => ({…prevState,[name]: value, }) と言う書き方をしています。prevStateはステートの前の状態を意味するものです。明示的に、前の状態に対し新しい状態に更新する事を定義しています。
DBデータ処理時の書き方にすると、{…formState, [name]: value, }となりますが、このprevStateを利用した書き方が推奨されています。

続いて、ログインボタンクリック時に実行される処理についてです。

// /app/user/login/page.tsx

//ログインボタンクリック時の処理
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
  //async/awaitによる非同期通信
  //eは、操作イベントの事。型は、React.ChangeEvent<HTMLInputElement>と定義される。
  e.preventDefault(); // submitイベントの本来の動作を抑止
  setLoading(true); //ローディング中にセット
  try {
    const userLogin = await signInWithEmailAndPassword(
      //Firebase SDKによるログイン処理、authは、firebaseクライアントで定義した引数
      auth,
      formState.email,
      formState.password
    );
    console.log("User Logined:", userLogin);
    toast({
      //ChakraUIのトースト機能で、ログイン成功メッセージを表示
      title: "ログインしました",
      position: "top",
      status: "success",
      duration: 2000,
      isClosable: true,
    });
    router.push("/"); //ログイン成功時は、useRouterの機能で、"/"に移動
  } catch (error) {//エラーの場合は、
    console.error("Error during sign up:", error);
    toast({
      //ChakraUIのトースト機能で、ログイン失敗メッセージを表示
      title: "ログインに失敗しました",
      description: `${error}`,
      position: "top",
      status: "error",
      duration: 2000,
      isClosable: true,
    });
  } finally {
    setLoading(false); //最後にローディング状態を解除
  }
};

handleLoginとして定義しています。コード中に解説コメントを記載していますが、async/awaitによる非同期通信で処理しています。
その下の、e.preventDefault()はform処理の際によく使われるのですが、formの性質として、formの送信先が自身のURLの場合にリロードを繰り返す動きをします。これが行われると正常に処理されない為、その抑止の為のものです。

続いて、ローディングをローディング状態にセットします。
次にFirebase SDKにより、auth, email, passwordを引数にログイン認証処理を実行しています。
ログインが正常終了した場合は、Chakra UIのトースト機能で、成功メッセージを表示。その後、useRouterの機能でアプリのルートURL、’/’に遷移させています。

エラー発生した場合は、同様にChakra UIのトースト機能で、エラーメッセージ表示。最後はローディング状態を解除して終了です。

最後にJSXの箇所です。

// /app/user/login/page.tsx

return (
  <Flex //Flex適用
    justifyContent="center"
    boxSize="fit-content"
    mx="auto"
    p={5}
  >
    <Card
      size={{ base: "sm", md: "lg" }}
      p={4} //Chakra UIのCard適用
    >
      <Heading size="md" textAlign="center">
        ログイン
      </Heading>
      <CardBody>
        <form
          onSubmit={handleLogin} //ログインボタンクリック時(submit時)、handleLogin実行
        >
          <InputGroup>
            <InputLeftElement pointerEvents="none">
              <FaUserCheck color="gray" />
            </InputLeftElement>
            <Input
              autoFocus //自動でフォーカスをあてる
              type="email"
              placeholder="メールアドレスを入力"
              name="email"
              value={formState.email}
              required
              mb={2}
              onChange={handleInputChange} //データ入力時、handleInputChange実行
            />
          </InputGroup>
          <InputGroup>
            <InputLeftElement pointerEvents="none">
              <RiLockPasswordFill color="gray" />
            </InputLeftElement>
            <Input
              type="password"
              placeholder="パスワードを入力"
              name="password"
              value={formState.password}
              required
              mb={2}
              onChange={handleInputChange} //データ入力時、handleInputChange実行
            />
          </InputGroup>
          <Box mt={4} mb={2} textAlign="center">
            <Button
              isLoading={loading} //ローディング状態の定義、ローディング中の場合はスピナーアニメを表示する
              loadingText="Loading"
              spinnerPlacement="start"
              type="submit"
              colorScheme="green"
              width="100%"
              mb={2}
            >
              ログイン
            </Button>
            <Button
              colorScheme="green"
              width="100%"
              variant="outline"
              onClick={() => router.push("/user/register")} //クリックすると、/user/register に遷移
            >
              新規登録
            </Button>
          </Box>
          <Box mt={4} mb={2} textAlign="center">
            <Stack spacing={3}>
              <Button
                colorScheme="green"
                width="100%"
                variant="ghost"
                onClick={() => router.push("/user/sendReset")} //クリックすると、/user/sendReset に遷移
              >
                パスワードをお忘れですか?
              </Button>
            </Stack>
          </Box>
        </form>
      </CardBody>
    </Card>
  </Flex>
);

コード中に色々コメントで解説しています。
<Flex><Card>等、Chakra UIのコンポーネントを配置の上、<form>設置、<Input>にてメールアドレス、パスワード入力フィールドを設けています。
<Input>のフィールドにデータを入力した際に、先程、解説したhandleInputChangを実行し、ステートに入力データを格納しています。

また、こちらの実装では、以下としています。

  • ログインボタンクリック時、formのsubmitで処理
  • メールアドレスとパスワードは、requiredで定義

これにより、入力フィールドを空のままログインボタンをクリック(=formをsubimit)すると、ブラウザの機能でエラー表示します。

ログインボタンの下には「新規登録」ボタン、その下には、「パスワードをお忘れですか?」ボタンを配置しています。それぞれのボタンクリック時は、useRouterのrouter.pushにて、該当するページに遷移を行っています(”/user/register”、”/user/sendReset” )。
ただし、今時点は、遷移先のコンポーネントは作成していませんので、クリックしても404エラーとなります。こちらは、今後作成していきます。

8.2 Recordsの変更

前項で、ログイン成功後、”/”に遷移する処理としました。”/”で表示される内容は、Homeコンポーネント(/app/page.tsx)にラップされたRecords、つまり学習記録データです。これは、ログインユーザーのemail情報でDBからデータ取得しますので、ログインしないと取得出来るデータはありません。よって、ログインしていない場合は、まずログインをするよう、ログイン画面に遷移する実装を行います。

この実装は、Recordsコンポーネントで行います。ユーザーのセッション状況をチェックし、ログインしていない場合は、ログイン画面に遷移する処理を追加します。

Recods.tsxの変更箇所を解説していきます(コード全文は最後に掲載します)。まずインポート箇所です。

// /app/components/Records.tsx

import { useRouter } from "next/navigation"; //追加
import { auth } from "../utils/firebase"; //追加

インポートは、画面遷移を実現するuseRouterインポートと、Firebaseクライアントから認証機能(auth)のインポートを追加します。

続いてフック定義の箇所です。

// /app/components/Records.tsx

const Records = () => {
  const [email, setEmail] = useState(""); //初期値、空に変更
  const [learnings, setLearnings] = useState<StudyData[]>([]);
  const { isOpen, onOpen, onClose } = useDisclosure();
  const cancelRef = useRef(null);
  const [loading, setLoading] = useState(false);
  const [user, setUser] = useState<User | null>(null);
  const router = useRouter(); //追加
  const toast = useToast();

ステート、emailについて、これまで固定値をセットしてましたが、初期値を空に変更します。
また、useRouterについての定義を追加します。

次に、ユーザーのセッション状況をチェックする処理を追加します。これはuseEffectで実装します。

// /app/components/Records.tsx

  //追加
  /** ユーザがセッション中か否かの判定処理 **/
  useEffect(() => {
    const authUser = auth.onAuthStateChanged((user) => {
      //FrebseSDKのonAuthStateChangedメソッドで、セッション状況を監視
      setUser(user);
      if (user) {
        //userがセッション中であれば、
        setEmail(user.email as string);//user.emailをemailステートにセット
      } else {
        router.push("/user/login"); //userがセッション中でなければ/user/loginに移動
      }
    });
    return () => {
      authUser(); //authUserを実行
    };
  }, []);//依存配列は空、コンポーネントマウント時に実行

FirebaseSDKのonAuthStateChangedで、ユーザーセッションを監視します。userがセッション中であれば、emailステートをユーザーのemailでセットします。userがセッション中でなければ、ログイン画面の/user/loginに移動します。

useEffectの依存配列は[]で空配列です。これにより、コンポーネントマウント時にこの処理が実行されます。

なお、JSXの箇所は特に変更しません。この時点でRecords.tsxのコード全文は以下のとおりです。

// /app/components/Records.tsx

"use client";
import { useEffect, useRef, useState } from "react";
import {
  AlertDialog,
  AlertDialogBody,
  AlertDialogCloseButton,
  AlertDialogContent,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogOverlay,
  Box,
  Button,
  Card,
  CardBody,
  Flex,
  Heading,
  Stack,
  Table,
  TableContainer,
  Tbody,
  Td,
  Th,
  Thead,
  Tr,
  useDisclosure,
  useToast,
  Spinner,
} from "@chakra-ui/react";
import { User } from "firebase/auth";
import { StudyData } from "../utils/studyData";
import { useRouter } from "next/navigation"; //追加
import { auth } from "../utils/firebase"; //追加
import Edit from "./Edit";
import Delete from "./Delete";
import NewEntry from "./NewEntry";

const Records = () => {
  const [email, setEmail] = useState(""); //変更
  const [learnings, setLearnings] = useState<StudyData[]>([]);
  const { isOpen, onOpen, onClose } = useDisclosure();
  const cancelRef = useRef(null);
  const [loading, setLoading] = useState(false);
  const [user, setUser] = useState<User | null>(null);
  const router = useRouter(); //追加
  const toast = useToast();

  //追加
  /** ユーザがセッション中か否かの判定処理 **/
  useEffect(() => {
    const authUser = auth.onAuthStateChanged((user) => {
      setUser(user);
      if (user) {
        setEmail(user.email as string);
      } else {
        router.push("/user/login"); //userがセッション中でなければ/loginに移動
      }
    });
    return () => {
      authUser();
    };
  }, []);

  /** Firestoreデータ取得 **/
  const fetchDb = async (email: string) => {
    setLoading(true);
    try {
      const res = await fetch(`/api/records/read?email=${email}`);
      const data = await res.json();

      if (res.ok && data.success) {
        console.log("fetchStudies:", email, data);
        setLearnings(data.data);
      } else {
        console.error("fetchStudiesError", email, data);
        throw new Error(data.error || "Failed to fetch studies.");
      }
    } catch (err: unknown) {
      console.error("Error in fetchStudies:", err);
      toast({
        title: "データ取得に失敗しました",
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** Firestoreデータ新規登録 **/
  const entryDb = async (study: StudyData) => {
    setLoading(true);
    try {
      const res = await fetch("/api/records/create", {
        method: "POST",
        body: JSON.stringify({
          title: study.title,
          time: study.time,
          email: email,
        }),
        headers: { "Content-Type": "application/json" },
      });
      const data = await res.json();
      console.log(data);
      if (data.success) {
        fetchDb(email); // リロード
        toast({
          title: "データ登録が完了しました",
          position: "top",
          status: "success",
          duration: 2000,
          isClosable: true,
        });
      } else {
        throw new Error(data.error);
      }
    } catch (err: unknown) {
      toast({
        title: "データ登録に失敗しました",
        description: `${err}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** Firestoreデータ更新 **/
  const updateDb = async (learnings: StudyData) => {
    setLoading(true);
    try {
      const res = await fetch("/api/records/update", {
        method: "PUT",
        body: JSON.stringify(learnings),
        headers: { "Content-Type": "application/json" },
      });
      const data = await res.json();
      if (data.success) {
        fetchDb(email); // リロード
        toast({
          title: "データ更新が完了しました",
          position: "top",
          status: "success",
          duration: 2000,
          isClosable: true,
        });
      } else {
        throw new Error(data.error);
      }
    } catch (err: unknown) {
      toast({
        title: "データ更新に失敗しました",
        description: `${err}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** Firestoreデータ削除 **/
  const deleteDb = async (id: string) => {
    setLoading(true);
    try {
      const res = await fetch("/api/records/delete", {
        method: "DELETE",
        body: JSON.stringify({ id }),
        headers: { "Content-Type": "application/json" },
      });
      const data = await res.json();
      if (data.success) {
        fetchDb(email); // リロード
        toast({
          title: "データを削除しました",
          position: "top",
          status: "success",
          duration: 2000,
          isClosable: true,
        });
      } else {
        throw new Error(data.error);
      }
    } catch (err: unknown) {
      toast({
        title: "デー削除に失敗しました",
        description: `${err}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** Firestore確認 **/
  useEffect(() => {
    if (email) {
      fetchDb(email);
      console.log("useEffectFirestore:", email, user);
    }
  }, [user]);

  /** 学習時間合計 **/
  const calculateTotalTime = () => {
    return learnings.reduce((total, learning) => total + learning.time, 0);
  };

  return (
    <>
      <Flex alignItems="center" justify="center" p={5}>
        <Card size={{ base: "sm", md: "lg" }}>
          <Box textAlign="center" mb={2} mt={10}>
            ようこそ!{email} さん
          </Box>
          <Heading size="md" textAlign="center">
            Learning Records
          </Heading>
          <CardBody>
            {/*学習記録表示 */}
            <Box textAlign="center">
              学習記録
              {loading && (
                <Box p={10}>
                  <Spinner />
                </Box>
              )}
              <TableContainer>
                <Table variant="simple" size={{ base: "sm", md: "lg" }}>
                  <Thead>
                    <Tr>
                      <Th>学習内容</Th>
                      <Th>時間()</Th>
                      <Th></Th>
                      <Th></Th>
                    </Tr>
                  </Thead>
                  <Tbody>
                    {learnings.map((learning, index) => (
                      <Tr key={index}>
                        <Td>{learning.title}</Td>
                        <Td>{learning.time}</Td>
                        <Td>
                          <Edit
                            learning={learning}
                            updateDb={updateDb}
                            loading={loading}
                          />
                        </Td>
                        <Td>
                          <Delete
                            learning={learning}
                            deleteDb={deleteDb}
                            loading={loading}
                          />
                        </Td>
                      </Tr>
                    ))}
                  </Tbody>
                </Table>
              </TableContainer>
            </Box>
            {!loading && (
              <Box p={5}>
                <div>合計学習時間:{calculateTotalTime()}</div>
              </Box>
            )}

            {/*データ新規登録*/}
            <Box p={25}>
              <NewEntry
                learnings={learnings}
                loading={loading}
                updateDb={updateDb}
                entryDb={entryDb}
              />
            </Box>

            {/* ログアウト*/}
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button width="100%" variant="outline" onClick={onOpen}>
                  ログアウト
                </Button>
                <AlertDialog
                  motionPreset="slideInBottom"
                  leastDestructiveRef={cancelRef}
                  onClose={onClose}
                  isOpen={isOpen}
                  isCentered
                >
                  <AlertDialogOverlay />
                  <AlertDialogContent>
                    <AlertDialogHeader>ログアウト</AlertDialogHeader>
                    <AlertDialogCloseButton />
                    <AlertDialogBody>ログアウトしますか?</AlertDialogBody>
                    <AlertDialogFooter>
                      <Button ref={cancelRef} onClick={onClose}>
                        Cancel
                      </Button>
                      <Button
                        loadingText="Loading"
                        spinnerPlacement="start"
                        colorScheme="red"
                        ml={3}
                        onClick={() => {}}
                      >
                        ログアウト
                      </Button>
                    </AlertDialogFooter>
                  </AlertDialogContent>
                </AlertDialog>
              </Stack>
            </Box>

            {/*パスワード更新 */}
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button
                  width="100%"
                  variant="outline"
                  onClick={() => {}}
                >
                  パスワード更新
                </Button>
              </Stack>
            </Box>
          </CardBody>
        </Card>
      </Flex>
    </>
  );
};

export default Records;

8.3 ログアウト機能

続いて、ログアウト機能の実装を行います。これはRecordsコンポーネントに追加していきます。Recordsはアプリのルート”/”にアクセスした際に表示されるコンポーネントですので、ログアウトボタンはここに配置してます。

では、Records.tsxの変更箇所を説明していきます。コード全文は最後に掲載します。
インポートやフック定義の箇所は変更ありません。
handleLogoutとして、ログアウト処理を追加します。

// /app/components/Records.tsx

//追加
/**ログアウト処理 **/
const handleLogout = async () => {
  //async/awaitによる非同期通信
  setLoading(true); //ローディング中にセット
  try {
    const usertLogout = await auth.signOut(); //Firebase SDKのsignOutによるログアウト処理
    console.log("User Logout:", usertLogout);
    toast({
      //ChakraUIのトースト機能で、ログアウト成功メッセージを表示
      title: "ログアウトしました",
      position: "top",
      status: "success",
      duration: 2000,
      isClosable: true,
    });
    router.push("/user/login"); //ログアウト成功時は、useRouterの機能で、"/user/login"に移動
  } catch (error) {
    //エラーの場合は、
    console.error("Error during logout:", error);
    toast({
      //ChakraUIのトースト機能で、ログアウト失敗メッセージを表示
      title: "ログアウトに失敗しました",
      description: `${error}`,
      position: "top",
      status: "error",
      duration: 4000,
      isClosable: true,
    });
  } finally {
    setLoading(false); //最後にローディング状態を解除
  }
};

コード中に解説コメントを記載しています。
async/awaitによる非同期通信で処理しています。
Firebase SDKのsignOutによるログアウト処理を実施し、成功すればChakra UIのトースト機能で成功メッセージ表示、と共に、ログイン画面の”/user/login”に遷移させています。

エラー時は、Chakra UIのトースト機能で、ログアウト失敗メッセージを表示し、最後にローディング状態を解除してます。

続いて、JSXの箇所です。

// /app/components/Records.tsx
<Button
  isLoading={loading} //追加
  loadingText="Loading"
  spinnerPlacement="start"
  colorScheme="red"
  ml={3}
  onClick={handleLogout} //変更
>
  ログアウト
</Button>;

<Button
  width="100%"
  variant="outline"
  onClick={() => router.push("/user/updatePass")} //変更
>
  パスワード更新
</Button>;

ログアウトボタン、パスワード更新ボタンの箇所を変更します。
ログアウトボタンは、ローディング中にスピンアニメーションを表示する、isLoading={loading}を追加、また今まで仮設置していた、onClickの箇所を、handleLogoutを実行する形に変更します。
パスワードボタンの箇所は、ここも仮設置でしたが、クリックしたら、パスワード更新用の画面(”/user/updatePass”)に遷移するよう変更します。なお、現時点は、まだパスワード更新用の画面が作成してませんので、クリックしても404エラーとなります。この部分は後ほど開発します。

現時点の、Rcords.tsxのコード全文は以下となります。

// /app/components/Records.tsx

"use client";
import { useEffect, useRef, useState } from "react";
import {
  AlertDialog,
  AlertDialogBody,
  AlertDialogCloseButton,
  AlertDialogContent,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogOverlay,
  Box,
  Button,
  Card,
  CardBody,
  Flex,
  Heading,
  Stack,
  Table,
  TableContainer,
  Tbody,
  Td,
  Th,
  Thead,
  Tr,
  useDisclosure,
  useToast,
  Spinner,
} from "@chakra-ui/react";
import { User } from "firebase/auth";
import { StudyData } from "../utils/studyData";
import { useRouter } from "next/navigation";
import { auth } from "../utils/firebase";
import Edit from "./Edit";
import Delete from "./Delete";
import NewEntry from "./NewEntry";

const Records = () => {
  const [email, setEmail] = useState("");
  const [learnings, setLearnings] = useState<StudyData[]>([]);
  const { isOpen, onOpen, onClose } = useDisclosure();
  const cancelRef = useRef(null);
  const [loading, setLoading] = useState(false);
  const [user, setUser] = useState<User | null>(null);
  const router = useRouter();
  const toast = useToast();

  /** ユーザがセッション中か否かの判定処理 **/
  useEffect(() => {
    const authUser = auth.onAuthStateChanged((user) => {
      setUser(user);
      if (user) {
        setEmail(user.email as string);
      } else {
        router.push("/user/login"); //userがセッション中でなければ/loginに移動
      }
    });
    return () => {
      authUser();
    };
  }, []);

  /** Firestoreデータ取得 **/
  const fetchDb = async (email: string) => {
    setLoading(true);
    try {
      const res = await fetch(`/api/records/read?email=${email}`);
      const data = await res.json();

      if (res.ok && data.success) {
        console.log("fetchStudies:", email, data);
        setLearnings(data.data);
      } else {
        console.error("fetchStudiesError", email, data);
        throw new Error(data.error || "Failed to fetch studies.");
      }
    } catch (err: unknown) {
      console.error("Error in fetchStudies:", err);
      toast({
        title: "データ取得に失敗しました",
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** Firestoreデータ新規登録 **/
  const entryDb = async (study: StudyData) => {
    setLoading(true);
    try {
      const res = await fetch("/api/records/create", {
        method: "POST",
        body: JSON.stringify({
          title: study.title,
          time: study.time,
          email: email,
        }),
        headers: { "Content-Type": "application/json" },
      });
      const data = await res.json();
      console.log(data);
      if (data.success) {
        fetchDb(email); // リロード
        toast({
          title: "データ登録が完了しました",
          position: "top",
          status: "success",
          duration: 2000,
          isClosable: true,
        });
      } else {
        throw new Error(data.error);
      }
    } catch (err: unknown) {
      toast({
        title: "データ登録に失敗しました",
        description: `${err}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** Firestoreデータ更新 **/
  const updateDb = async (learnings: StudyData) => {
    setLoading(true);
    try {
      const res = await fetch("/api/records/update", {
        method: "PUT",
        body: JSON.stringify(learnings),
        headers: { "Content-Type": "application/json" },
      });
      const data = await res.json();
      if (data.success) {
        fetchDb(email); // リロード
        toast({
          title: "データ更新が完了しました",
          position: "top",
          status: "success",
          duration: 2000,
          isClosable: true,
        });
      } else {
        throw new Error(data.error);
      }
    } catch (err: unknown) {
      toast({
        title: "データ更新に失敗しました",
        description: `${err}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** Firestoreデータ削除 **/
  const deleteDb = async (id: string) => {
    setLoading(true);
    try {
      const res = await fetch("/api/records/delete", {
        method: "DELETE",
        body: JSON.stringify({ id }),
        headers: { "Content-Type": "application/json" },
      });
      const data = await res.json();
      if (data.success) {
        fetchDb(email); // リロード
        toast({
          title: "データを削除しました",
          position: "top",
          status: "success",
          duration: 2000,
          isClosable: true,
        });
      } else {
        throw new Error(data.error);
      }
    } catch (err: unknown) {
      toast({
        title: "デー削除に失敗しました",
        description: `${err}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** Firestore確認 **/
  useEffect(() => {
    if (email) {
      fetchDb(email);
      console.log("useEffectFirestore:", email, user);
    }
  }, [user]);

  //追加
  /**ログアウト処理 **/
  const handleLogout = async () => {
    setLoading(true);
    try {
      const usertLogout = await auth.signOut();
      console.log("User Logout:", usertLogout);
      toast({
        title: "ログアウトしました",
        position: "top",
        status: "success",
        duration: 2000,
        isClosable: true,
      });
      router.push("/user/login");
    } catch (error) {
      console.error("Error during logout:", error);
      toast({
        title: "ログアウトに失敗しました",
        description: `${error}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** 学習時間合計 **/
  const calculateTotalTime = () => {
    return learnings.reduce((total, learning) => total + learning.time, 0);
  };

  return (
    <>
      <Flex alignItems="center" justify="center" p={5}>
        <Card size={{ base: "sm", md: "lg" }}>
          <Box textAlign="center" mb={2} mt={10}>
            ようこそ!{email} さん
          </Box>
          <Heading size="md" textAlign="center">
            Learning Records
          </Heading>
          <CardBody>
            {/*学習記録表示 */}
            <Box textAlign="center">
              学習記録
              {loading && (
                <Box p={10}>
                  <Spinner />
                </Box>
              )}
              <TableContainer>
                <Table variant="simple" size={{ base: "sm", md: "lg" }}>
                  <Thead>
                    <Tr>
                      <Th>学習内容</Th>
                      <Th>時間()</Th>
                      <Th></Th>
                      <Th></Th>
                    </Tr>
                  </Thead>
                  <Tbody>
                    {learnings.map((learning, index) => (
                      <Tr key={index}>
                        <Td>{learning.title}</Td>
                        <Td>{learning.time}</Td>
                        <Td>
                          <Edit
                            learning={learning}
                            updateDb={updateDb}
                            loading={loading}
                          />
                        </Td>
                        <Td>
                          <Delete
                            learning={learning}
                            deleteDb={deleteDb}
                            loading={loading}
                          />
                        </Td>
                      </Tr>
                    ))}
                  </Tbody>
                </Table>
              </TableContainer>
            </Box>
            {!loading && (
              <Box p={5}>
                <div>合計学習時間:{calculateTotalTime()}</div>
              </Box>
            )}

            {/*データ新規登録*/}
            <Box p={25}>
              <NewEntry
                learnings={learnings}
                loading={loading}
                updateDb={updateDb}
                entryDb={entryDb}
              />
            </Box>

            {/* ログアウト*/}
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button width="100%" variant="outline" onClick={onOpen}>
                  ログアウト
                </Button>
                <AlertDialog
                  motionPreset="slideInBottom"
                  leastDestructiveRef={cancelRef}
                  onClose={onClose}
                  isOpen={isOpen}
                  isCentered
                >
                  <AlertDialogOverlay />
                  <AlertDialogContent>
                    <AlertDialogHeader>ログアウト</AlertDialogHeader>
                    <AlertDialogCloseButton />
                    <AlertDialogBody>ログアウトしますか?</AlertDialogBody>
                    <AlertDialogFooter>
                      <Button ref={cancelRef} onClick={onClose}>
                        Cancel
                      </Button>
                      <Button
                        isLoading={loading} //追加
                        loadingText="Loading"
                        spinnerPlacement="start"
                        colorScheme="red"
                        ml={3}
                        onClick={handleLogout} //変更
                      >
                        ログアウト
                      </Button>
                    </AlertDialogFooter>
                  </AlertDialogContent>
                </AlertDialog>
              </Stack>
            </Box>

            {/*パスワード更新 */}
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button
                  width="100%"
                  variant="outline"
                  onClick={() => router.push("/user/updatePass")} //変更
                >
                  パスワード更新
                </Button>
              </Stack>
            </Box>
          </CardBody>
        </Card>
      </Flex>
    </>
  );
};

export default Records;

ここまでの実装で、ログイン → 学習記録表示 → ログアウトの一連の処理が実現出来ました。下記画面のような動きです。

学習記録アプリの動きアニメ

8.4 見つかりませんページ

先程から、まだ遷移先が無いのに、router.pushの設定を実施してますが、この時点では404(not found)エラーとなります。404エラーの場合は、Next.js側の404エラー表示がされますが、404用のページを設け、それを表示させるようにする事も出来ます。
この「見つかりません」ページを作成します。

appフォルダ直下に、not-found.tsxと言うファイルを作成します。

not-found.tsxに以下内容を記載します。コード全文です。

// /app/not-found.tsx

"use client";

import { Box, Button, Card, CardBody, Flex, Heading } from "@chakra-ui/react";
import { useRouter } from "next/navigation";

const NotFound = () => {
  const router = useRouter();
  return (
    <Flex justifyContent="center" boxSize="fit-content" mx="auto" p={5}>
      <Card size={{ base: "sm", md: "lg" }} p={4}>
        <Heading size="md" textAlign="center" mt={8}>
          404 - ページが見つかりません
        </Heading>
        <CardBody>
          アクセスしようとしたページは存在しません
          <br />
          URLをご確認の上再度アクセスしてください
          <Box mt={4} textAlign="center">
            <Button
              colorScheme="green"
              width="100%"
              variant="link"
              onClick={() => router.back()}
            >
              前に戻る
            </Button>
          </Box>
        </CardBody>
      </Card>
    </Flex>
  );
};
export default NotFound;

これまでのコンポーネント同様、Chakra UIでレイアウト、デザインを組んでいます。
コンポーネントとしては、NotFoundでexportしています。このNotFoundがNext.jsでは404ページとして認識されます。
本画面で「前に戻る」のボタンを設けています。これは、useRouterのrouter.backと言う処理を使っています。JavaScriptで言うhistory.backと同様の機能です。
これで、現在存在しないURLにアクセスすると、以下のような画面が表示されます。

9. ユーザー登録

ユーザーログイン機能が実装出来ましたので、次は、ユーザー登録、サインアップ機能を作成します。
appフォルダ、userフォルダの下に新たにregisterフォルダを作成します。そしてregisterフォルダにpages.tsxと言うファイルを作成します。このファイルはURLとしては、/user/register/ で認識されます。

作成した、register/pages.tsx に以下のコードを記述します(以下は、コード全文です)。

// /app/user/register/page.tsx

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import {
  Box,
  Button,
  Card,
  CardBody,
  Flex,
  Heading,
  Input,
  InputGroup,
  InputLeftElement,
  Text,
  useToast,
} from "@chakra-ui/react";
import { FaUserCheck } from "react-icons/fa";
import { RiLockPasswordFill } from "react-icons/ri";
import { createUserWithEmailAndPassword } from "firebase/auth";
import { auth } from "@/app/utils/firebase";

const Register = () => {
  const [formState, setFormState] = useState({
    email: "",
    password: "",
    passwordConf: "",
  });
  const [loading, setLoading] = useState(false);
  const router = useRouter();
  const toast = useToast();

  // input入力値変更時の処理
  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormState((prevState) => ({
      ...prevState,
      [name]: value,
    }));
  };

  //登録するボタンクリック時処理
  const handleSignup = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setLoading(true);
    if (formState.password !== formState.passwordConf) {
      toast({
        title: "パスワードが一致しません",
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
      return;
    } else if (formState.password.length < 6) {
      toast({
        title: "パスワードは6文字以上にしてください",
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
      return;
    }
    try {
      const userCredential = await createUserWithEmailAndPassword(
        auth,
        formState.email,
        formState.password
      );
      console.log("User Signuped:", userCredential);
      toast({
        title: "ユーザー登録が完了しました。",
        position: "top",
        status: "success",
        duration: 2000,
        isClosable: true,
      });
      router.push("/");
    } catch (error: unknown) {
      toast({
        title: "サインアップに失敗しました",
        description: `${error}`,
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  return (
    <>
      <Flex justifyContent="center" boxSize="fit-content" mx="auto" p={5}>
        <Card size={{ base: "sm", md: "lg" }} p={4}>
          <Heading size="md" textAlign="center">
            ユーザ登録
          </Heading>
          <CardBody>
            <form onSubmit={handleSignup}>
              <InputGroup>
                <InputLeftElement pointerEvents="none">
                  <FaUserCheck color="gray" />
                </InputLeftElement>
                <Input
                  autoFocus
                  type="email"
                  placeholder="メールアドレスを入力"
                  name="email"
                  value={formState.email}
                  required
                  mb={2}
                  onChange={handleInputChange}
                />
              </InputGroup>
              <Text fontSize="12px" color="gray">
                パスワードは6文字以上
              </Text>
              <InputGroup>
                <InputLeftElement pointerEvents="none">
                  <RiLockPasswordFill color="gray" />
                </InputLeftElement>
                <Input
                  type="password"
                  placeholder="パスワードを入力"
                  name="password"
                  value={formState.password}
                  required
                  mb={2}
                  onChange={handleInputChange}
                />
              </InputGroup>
              <InputGroup>
                <InputLeftElement pointerEvents="none">
                  <RiLockPasswordFill color="gray" />
                </InputLeftElement>
                <Input
                  type="password"
                  placeholder="パスワードを入力(確認)"
                  name="passwordConf"
                  value={formState.passwordConf}
                  required
                  mb={2}
                  onChange={handleInputChange}
                />
              </InputGroup>
              <Box mt={4} mb={2} textAlign="center">
                <Button
                  isLoading={loading}
                  loadingText="Loading"
                  spinnerPlacement="start"
                  type="submit"
                  colorScheme="green"
                  width="100%"
                  mb={2}
                >
                  登録する
                </Button>
                <Button
                  colorScheme="gray"
                  onClick={() => router.back()}
                  width="100%"
                >
                  戻る
                </Button>
              </Box>
            </form>
          </CardBody>
        </Card>
      </Flex>
    </>
  );
};
export default Register;

構造、内容としては、Loginコンポーネントと同じです。フォーム入力値を同様にステートformStateで定義しています。Registerについては、パスワード確認用のpasswordConfを追加しています。他の流れは、Loginと同じです。

サインアップ処理は、handleSignupで定義しています。FirebaseSDKのcreateUserWithEmailAndPasswordメソッドを利用して処理しています。

戻るボタンについては、useRouterのrouter.backを利用しています。

これで、ユーザーのサインアップ機能が実装されました。下記のようにユーザー登録から新規登録が可能になります。登録処理後、FirestoreDBからデータ取得しますが、登録時点はDBデータは無いので、表示されるデータもありません。

学習記録アプリの動きアニメ

Firebase Authentication 上でも新しくユーザーが登録されたのが分かります。

10. パスワード更新、リセット

続いて、パスワードの更新とリセット機能を作成します。
パスワード更新は、ログイン中に実施。パスワードリセットは、ログアウト状態でも実施出来るようにします。よって、パスワード更新はRecordsにボタン配置、パスワードリセットはログイン画面にボタンを設置しています。

10.1 パスワード更新機能

まずは、パスワード更新機能です。
appフォルダ、userフォルダの下に新たにupdatePassフォルダを作成します。そしてupdatePass フォルダにpages.tsxを作成します。このファイルはURLとしては、/user/updatePass/ で認識されます。

作成した、updatePass/pages.tsx に以下のコードを記述します(以下は、コード全文です)。

// /app/user/updatepass/page.tsx

"use client";

import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
  Box,
  Button,
  Card,
  CardBody,
  Flex,
  Heading,
  Input,
  InputGroup,
  InputLeftElement,
  useToast,
} from "@chakra-ui/react";
import { RiLockPasswordFill } from "react-icons/ri";
import {
  reauthenticateWithCredential,
  updatePassword,
  User,
} from "firebase/auth";
import { EmailAuthProvider } from "firebase/auth/web-extension";
import { auth } from "@/app/utils/firebase";

const UpdatePass = () => {
  const [formState, setFormState] = useState({
    password: "",
    passwordConf: "",
    currentPassword: "",
  });
  const [loading, setLoading] = useState(false);
  const [user, setUser] = useState<User | null>(null);
  const router = useRouter();
  const toast = useToast();

  //ユーザがセッション中か否かの判定処理
  useEffect(() => {
    const authUser = auth.onAuthStateChanged((user) => {
      setUser(user);
    });
    return () => {
      authUser();
    };
  }, []);

  // input入力値変更時の処理
  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormState((prevState) => ({
      ...prevState,
      [name]: value,
    }));
  };

  //パスワードを更新するボタンをクリックした時の処理
  const handleUpdatePassword = async (e: React.FormEvent) => {
    e.preventDefault();
    if (formState.password !== formState.passwordConf) {
      toast({
        title: "パスワードが一致しません",
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
      return;
    } else if (formState.password.length < 6) {
      toast({
        title: "パスワードは6文字以上にしてください",
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
      return;
    }
    try {
      //パスワードの更新はユーザの再認証が必要
      setLoading(true);
      if (user) {
        // 再認証のために、ユーザーの認証情報を取得
        const credential = EmailAuthProvider.credential(
          user.email!,
          formState.currentPassword // 現在のパスワードを入力
        );
        console.log("パスワード更新", user);

        // 再認証処理
        await reauthenticateWithCredential(user, credential);

        // パスワードの更新処理
        await updatePassword(user, formState.password);
        toast({
          title: "パスワード更新が完了しました",
          position: "top",
          status: "success",
          duration: 2000,
          isClosable: true,
        });
        router.push("/"); // updatePasswordが成功した場合にのみページ遷移
      }
    } catch (error: unknown) {
      console.error("Error during password reset:", error);
      toast({
        title: "パスワード更新に失敗しました",
        description: `${error}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  return (
    <>
      <Flex justifyContent="center" boxSize="fit-content" mx="auto" p={5}>
        <Card size={{ base: "sm", md: "lg" }} p={4}>
          <Heading size="md" textAlign="center">
            パスワード更新
          </Heading>
          <CardBody>
            <form onSubmit={handleUpdatePassword}>
              <InputGroup>
                <InputLeftElement pointerEvents="none">
                  <RiLockPasswordFill color="gray" />
                </InputLeftElement>
                <Input
                  type="password"
                  placeholder="現在のパスワードを入力"
                  name="currentPassword"
                  value={formState.currentPassword}
                  required
                  mb={2}
                  onChange={handleInputChange}
                />
              </InputGroup>
              <InputGroup>
                <InputLeftElement pointerEvents="none">
                  <RiLockPasswordFill color="gray" />
                </InputLeftElement>
                <Input
                  type="password"
                  placeholder="新パスワードを入力"
                  name="password"
                  value={formState.password}
                  required
                  mb={2}
                  onChange={handleInputChange}
                />
              </InputGroup>
              <InputGroup>
                <InputLeftElement pointerEvents="none">
                  <RiLockPasswordFill color="gray" />
                </InputLeftElement>
                <Input
                  type="password"
                  placeholder="新パスワードを入力(確認)"
                  name="passwordConf"
                  value={formState.passwordConf}
                  required
                  mb={2}
                  onChange={handleInputChange}
                />
              </InputGroup>
              <Box mt={4} mb={2} textAlign="center">
                <Button
                  isLoading={loading}
                  loadingText="Loading"
                  spinnerPlacement="start"
                  type="submit"
                  colorScheme="green"
                >
                  パスワードを更新する
                </Button>
                <Button colorScheme="gray" onClick={() => router.back()} mx={2}>
                  戻る
                </Button>
              </Box>
            </form>
          </CardBody>
        </Card>
      </Flex>
    </>
  );
};
export default UpdatePass;

構造、内容としては、Login, Registerコンポーネントと同じです。フォーム入力値を同様にステートformStateで定義しています。UpdatePassについては、現在のパスワードcurrentPassword、新しいパスワードpassword、パスワード確認用のpasswordConfの構成です。他の流れは、Login, Registerと同じ感じです。

パスワード更新処理は、handleUpdatePasswordで定義しています。FirebaseSDKのupdatePasswordメソッドを利用して処理しています。
が、パスワード更新等、重要な処理の場合は、ユーザーを再認証する必要があります。この為、EmailAuthProvider.credential でユーザー認証情報を取得し、再認証処理、reauthenticateWithCredentialを行っています。
下記、解説します。

 // /app/user/updatepass/page.tsx
 
 //パスワードを更新するボタンをクリックした時の処理
  const handleUpdatePassword = async (e: React.FormEvent) => {
    //async/awaitによる非同期通信、React.FormEventによるイベントの型
    e.preventDefault(); //submitイベントの本来の動作を抑止
    if (formState.password !== formState.passwordConf) {
      //入力したパスワードと再入力したパスワードの一致確認
      toast({
        //一致しなければ、エラーメッセージ表示し、処理終了
        title: "パスワードが一致しません",
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
      return;
    } else if (formState.password.length < 6) {
      //パスワード要件、6文字以上に合致するかチェック
      toast({
        //合致しなければ、エラーメッセージ表示し、処理終了
        title: "パスワードは6文字以上にしてください",
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
      return;
    }
    try {
      //パスワードの更新はユーザの再認証が必要
      setLoading(true); //ローディング状態をセット
      if (user) {
        // 再認証のために、ユーザーの認証情報を取得
        const credential = EmailAuthProvider.credential(
          //EmailAuthProviderメソッドで認証情報取得
          user.email!, //emai情報を取得、型としてはnullの可能性がある為、末尾に!を付与
          formState.currentPassword // 現在のパスワードを入力
        );
        console.log("パスワード更新", user);

        // 再認証処理
        await reauthenticateWithCredential(user, credential);

        // パスワードの更新処理
        await updatePassword(user, formState.password);
        toast({
          //正常終了すれば成功メッセージ表示
          title: "パスワード更新が完了しました",
          position: "top",
          status: "success",
          duration: 2000,
          isClosable: true,
        });
        router.push("/"); // updatePasswordが成功した場合にのみページ遷移
      }
    } catch (error: unknown) {
      //エラーの場合は
      console.error("Error during password reset:", error); //エラー出力
      toast({
        //エラーメッセージ表示
        title: "パスワード更新に失敗しました",
        description: `${error}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false); //最後にローディング解除
    }
  };

handleUpdatePasswordについて、コード中に説明をコメントしています。先程、記載の通り、ユーザー再認証の為に、EmailAuthProvider.credential でユーザー認証情報を取得し、再認証処理、reauthenticateWithCredentialを行っています。

またこれに当たり、改めてuser情報取得の為、useEffectの処理を行っています。下記内容です。

// /app/user/updatepass/page.tsx

//ユーザがセッション中か否かの判定処理
useEffect(() => {
  const authUser = auth.onAuthStateChanged((user) => {
    setUser(user);
  });
  return () => {
    authUser();
  };
}, []);

これはRecordsコンポーネントでも実装した内容です。これにより、user情報をセットしています。

これでパスワード更新の実装は完了です。以下画面のようなパスワード更新処理が可能となります。

学習記録アプリの動きアニメ

10.2 パスワードリセット機能

続いて、パスワードリセット機能を作成します。これは、パスワードリセット申請を行うものです。リセット申請を行い、リセット用のURLをメールで案内する仕組みです。このリセット用のUIは、Firebaseが提供しているUIを利用します。

appフォルダ、userフォルダの下に新たにsendResetフォルダを作成します。そしてsendReset フォルダにpages.tsxを作成します。このファイルはURLとしては、/user/sendReset/ で認識されます。

作成した、sendReset/pages.tsx に以下のコードを記述します(以下は、コード全文です)。

// /app/user/sendReset/page.tsx

"use client";

import { auth } from "@/app/utils/firebase";
import {
  Box,
  Button,
  Card,
  CardBody,
  Flex,
  Heading,
  Input,
  InputGroup,
  InputLeftElement,
  Stack,
  Text,
  useToast,
} from "@chakra-ui/react";
import { sendPasswordResetEmail } from "firebase/auth";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FaUserCheck } from "react-icons/fa";
import { RiMailSendLine } from "react-icons/ri";

const SendReset = () => {
  const [loading, setLoading] = useState(false);
  const [email, setEmail] = useState("");
  const router = useRouter();
  const toast = useToast();

  // 入力値変更時の処理
  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value);
  };

  //パスワードリセット申請処理
  const handleResetPassword = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      // パスワードリセットメール送信
      await sendPasswordResetEmail(auth, email);
      toast({
        title: "パスワード設定メールを確認してください",
        position: "top",
        status: "success",
        duration: 2000,
        isClosable: true,
      });
      router.push("/user/login"); // sendPasswordResetEmailが成功した場合にのみページ遷移
    } catch (error: unknown) {
      console.error("Error during password reset:", error);
      toast({
        title: "パスワード更新に失敗しました",
        description: `${error}`,
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  return (
    <>
      <Flex alignItems="center" justify="center" p={5}>
        <Card px={5}>
          <Heading size="md" textAlign="center" mt={4}>
            パスワードリセット申請
          </Heading>
          <Text textAlign="center" fontSize="12px" color="gray">
            入力したメールアドレス宛にパスワードリセットURLの案内をお送りします
          </Text>
          <CardBody w={{ base: "xs", md: "lg" }}>
            <form onSubmit={handleResetPassword}>
              <InputGroup>
                <InputLeftElement pointerEvents="none">
                  <FaUserCheck color="gray" />
                </InputLeftElement>
                <Input
                  autoFocus
                  type="email"
                  placeholder="登録メールアドレスを入力"
                  name="email"
                  value={email}
                  required
                  mb={2}
                  onChange={handleInputChange}
                />
              </InputGroup>
              <Box mt={4} mb={2} textAlign="center">
                <Button
                  type="submit"
                  isLoading={loading}
                  loadingText="Loading"
                  spinnerPlacement="start"
                  colorScheme="green"
                  mx={2}
                >
                  <Stack mr={2}>
                    <RiMailSendLine />
                  </Stack>
                  リセット申請する
                </Button>
                <Button colorScheme="gray" onClick={() => router.back()} mx={2}>
                  戻る
                </Button>
              </Box>
            </form>
          </CardBody>
        </Card>
      </Flex>
    </>
  );
};
export default SendReset;

構造、内容としては、これまでのコンポーネントと同じです。フォーム入力値は、今回はemailしか無いので、emailで定義しています。

パスワードリセット申請は、handleResetPasswordで定義しています。FirebaseSDKのsendPasswordResetEmailメソッドを利用して処理しています。

ここまでの実装で、下図のようにパスワードリセット申請の処理を実現できます。

パスワードリセット申請の動き

なお、上図の例では存在しないメールアドレスを入力してますが、受信可能なメールアドレス及び、認証済のメールアドレスじゃないとメールは送信されませんので、ご注意ください。この箇所をテストする場合は、受信可能及び認証済のメールアドレスでテストしましょう。
以下のようなメールがFirebaseから届きます。

リンクをクリックすると、下記のようなFirebaseのパスワードリセット画面に遷移します。そこでパスワードリセット処理を行います。

ここまでで、パスワード更新・リセット機能の実装は完了です。また、本記事で開発する内容はこれで完了となります。

11. GitHub、Vercel連携

一通り、コード作成が完了しましたので、リポジトリ作成とGitHubへのコミット(push)を行います。また、アプリのデプロイ先は、Vercelを利用したいと思いますので、そしてvercelとGitHub連携を行い、GitHubプッシュ時にvercelに自動デプロイされるようにしてみます。
VercelはNext.jsの開発元なので、Next.jsと親和性が高く、スムーズな連携が可能です。

GitHubについては、過去投稿した下記記事に詳細を記載してますので、ご参照ください。プロジェクト名等を読み替えていただくだけです。

VrecelでのGitHub連携とデプロイについて解説します(画面は記事作成時点のものです。変更されている可能性もありますが、推測は出来ると思います)。
まず、Vercelのサイトにアクセスします。
https://vercel.com/

上部右の「Login」をクリックします。

ログインオプションが表示されますので、GitHubを選択し、ログインします。

Overview画面に遷移しますので、「Add New …」から「Project」を選択します。

Import Git Repositoryの箇所で「Add GitHub Account」を選択します。

GitHubのセッティング画面に遷移しますので、Repository accessの設定を行います。全てのリポジトリを読み込みするか、特定のリポジトリのみにするか、選択出来ます。どちらでも構わないと思います。
設定したら、「Save」ボタンをクリックします。
(ここは初めての方は「Install」になってるかも知れません。)

Import Git Repository画面に戻りますので、対象となるリポジトリで「Import」ボタンをクリックします。

New Projectの画面となりますので、「Environment Variables」の箇所に、.env.localで定義した環境変数(FirebaseのAPI Key等)を定義します。なお、.env.localの内容を丸ごとコピーしてペーストすると、そのまま定義が設定項目に自動で挿入されます。

下図のイメージです。以前、Reactで作成しFirebaseでデプロイした際には、.envファイルをGitHubにもアップロードし、その情報でFirebaseと連携、デプロイしました。
Vercelでは、このように環境変数として設定が可能なため、.env, .env.local等の環境変数ファイルをGitHubにアップロードする必要はありません。

なお、Firebaseでも、Functionsを利用すれば環境変数の保存は可能です。が、Functionsは有料プランに移行しないと利用できないので、断念しました。

「Deploy」ボタンをクリックします。
Deployが実行され、完了すると、下図のようにCongratulations!が表示され、アプリのプレビュー画面が表示されます。

プレビュー画面をクリックすると、デプロイしたアプリサイトに遷移します。無事に動作すれば完成です!なお、以降は更新をGitHubにプッシュする度に、自動でVercelでもデプロイされます。

12. AWS Amplify連携

続いて、こちらは参考の位置付けですが、AWS Amplifyでもデプロイしてみたので、ご紹介します。
AWS Amplifyは、モバイルアプリケーションやウェブアプリケーションをフルスタックで構築するためのプラットフォームサービスです。
なお、AWSのアカウントを所有している事が前提となります(かつ適切な権限のあるアカウント)。

AWSのコンソールから、検索バーでamplifyと入力し、AWS Amplifyのページに移動します。

右上部に表示されたメニューから「新しいアプリを作成」をクリックします。

Gitプロバイダーの選択に移りますので、GitHubを選択し、下部にある「次へ」をクリックします。

リポジトリとブランチの選択画面となりますので、デプロイしたいリポジトリ、ブランチを選択し、「次へ」をクリックします。なお、モノレポでは無いので、モノレポはチェック不要です。

参考までに「モノレポ」とはMonorepoの事です。

次の、アプリケーションの設定で、名前や、その他、必要に応じて設定を入力します。
ロールの設定については、デフォルトのままでいいと思いますが、利用AWSアカウントでロールに関するポリシーがある場合はそれに準じて設定してください。

その下、「詳細設定」の環境変数の箇所で、Vercelの時と同様、.env.localの内容を環境変数として定義します。こちらは、コピー&ペーストでは貼り付かないので・・・一つずつ入力が必要です。

下部にある「次へ」をクリックします。確認画面に遷移しますので、内容確認の上、下の「保存してデプロイ」をクリックします。

デプロイが開始されます。デプロイ完了後、アプリの概要ページに遷移します。

右上の「デプロイされたURLにアクセス」をクリックすると、デプロイされたアプリに移動します。無事に動作すれば完成です!なお、Vercelと同様、以降は更新をGitHubにプッシュする度に、自動でAWSでもデプロイされます。

Next.jsによる学習記録アプリ、Firebase認証・DB実装 の後編、これにて終了です。どなたかの参考になれば嬉しいです!

なお、今回は使用しませんでしたが、FirebaseにはサーバーサイドのFirebase Admin SDKと言うものがあります。管理者レベルの操作をサーバーサイドから行うためのライブラリです。サーバー側でトークンベースのセッション管理・検証を行ったり、カスタム認証を実装したりすることが出来るものです。機会があれば、こちらも利用してみたいと思います。

 


[Next.js] 学習記録アプリ Firebase認証・DB実装

ソースコード


Firebase認証・DB連携:React編

One response

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

AD




TWITTER


アーカイブ
OTHER ISSUE
PVアクセスランキング にほんブログ村