react+Supabase

チュートリアル 学習記録アプリ、3回目です。前回まででSupabaseを活用したDBデータの読み込み・編集・削除を実装しました。
今回は、Supabaseの認証機能を利用してユーザー認証の仕組みを設けてみたいと思います。内容が長くなってしまったので、前編・後編と2回に分けてお届けします。
今回は前編となります。前編ではSupabaseの設定、環境構築、サインアップ機能の開発までを解説します。後編では、ログイン・ログアウト、パスワードリセット機能の実装を解説します。

なお、1回目、2回目の記事は以下となります。

後編公開しました!

はじめに

本記事は、Reactの初学者向けのチュートリアルコンテンツです。初学者向けとは言え、データの格納や認証にBaaS(Backend as a Service)であるSupabaseを利用したものとなっており、バックエンド処理も入ってきてますので、難易度はやや高いかも知れません。
Reactコードの記述、SupabaseのDBデータ処理、認証処理の実装、及び実際にアプリケーションをデプロイしてユーザーが使えるリリースまでを行えます。

React環境は、Vite+TypeScript
CSS+UIツールとしては、Chakra UI、アイコンとして、React Icons
BaaSとしてSupabase、ホスティングサービスはGoogleのFirebaseを利用しています。
本記事で、以下の対応が可能です。

  • React+Vite環境の構築
  • useStateによるState管理、Propsの扱い、useEffectの使用
  • Formイベント、イベントハンドラーの扱い
  • React Routerによるルート設定、制御
  • TypeScriptのコード記述
  • ChakraUIの導入・利用
  • Reactアイコンの利用
  • async/awaitによる非同期処理
  • Supabaseの環境設定とテーブル処理(表示、登録、更新、削除)
  • Supabaseの認証設定と、認証制御、セッション管理
  • GitHubリポジトリの扱い
  • Firebaseホスティングの利用、GitHubと連携した自動デプロイ 等

今回は、前編と言うことで、Supabaseの設定、環境構築、サインアップ機能の開発をしていきます。
また、1回目、2回目で割と細かく説明してた箇所は、適宜、省略することもありますので、状況に応じて、以前の内容を参照いただければと思います。

なお、今回、Supabaseのユーザー認証の実装にあたって、参考にしたサイトは以下となります。

Next.jsを前提にした記事ですが、内容はReactコードですので、十分に参考になります。
その他、補足として下記のサイトとSupabaseのドキュメントを参考にしています。

1. Supabaseの設定

Supabaseのアカウントやプロジェクトは既にある前提で進めます。お持ちでない方はこちらを参照して作成してください。

1.1 ユーザ認証設定

まずは、Supabaseのサイトでユーザー認証の設定を行います。今回はベーシックなEmailとpasswordによる認証を実装したいと思います。
Supabaseのサイトで、左側アイコンをhover時に表示される、Authenticationを選択し、Providersを選択します。冒頭にEmailがありますので、Enabledにします(おそらくデフォルトでEnabledになってるかと思います)。それぞれのオプションは下図のとおりに。なお、Email OTP ExpirationはSupabase推奨の3600秒で設定してください(オーバーしてると警告が出ます)。

1.2 カスタムSMTP設定

ユーザー新規登録時やパスワードリセット時のメール送信について、これまでは、Supabaseが準備しているSMTP機能でメール送信が可能でしたが、2024/9/26より、これに制限が加わり、プロジェクトに存在しているユーザーのみにメール送信が可能と言う仕様に変更されました・・・。これは不正利用を抑止・防止するための措置だそうです。

実質、新たにユーザーを作成する場合は、これまでのSupabaseのSMTPは使えないと言うことになります。

よって、メールによる認証を行うためには、個別にSMTPの設定が必要になります。これは、悩ましいですが、SMTPのサービスを提供しているResendや、AWSのSESの活用が考えられますが、結果的に私は自分のメールアドレスのSMTPサーバを使う形にしました。

カスタムSMTPの設定は、左側メニューの下側、歯車アイコンのProject Settings > Authenticationの箇所にあります。そこの2つ目に「SMTP Settings」がありますので、「Enable Custom SMTP」を有効にします。その上で、SMTPサーバの設定に必要な内容を入力します。これはSMTPプロバイダーによって異なってくると思います。入力後は保存します。

1.3 新規登録時のメールテンプレート

続いて、ユーザー新規登録時に、登録確認・完了処理のため、送信されるメールテンプレートの設定です。Supabaseのサイトで、左側アイコンをhover時に表示される、Authenticationを選択し、Email Templatesを選択します。Confirm signup の箇所が新規登録時に送信されるメールテンプレートです。
この内容はデフォルトでもいいかと思いますが、カスタマイズする場合は、下の Source の箇所を編集します。最後に一番下にある、緑色の「Save」ボタンをクリックします。

これでユーザー新規登録時に、以下のようなメールが届き、登録完了クリックにより、Supabase上でユーザー登録が完了します。

Supabase上のAuthentication > Usersを選択すると、登録されたユーザ一覧が表示されます。
まだリンクでの認証が済んでいない場合は、Last Sign Inの列でWaiting gor verificationと黄色で表示されます。

1.4 パスワードリセット時のメールテンプレート

次に、パスワードリセット処理時にユーザーにリセット用URLを案内するメールテンプレートの設定です。まず、申請メール中にあるリンクのリダイレクト先ドメインを設定します。
後ほど、開発を進めるメール送信部分のresetPasswordForEmail関数で設定したリダイレクト先のURLドメインを設定するものです。SupabaseのAuthenticationを選択し、URL ConfigurationのRedirect URLsにリダイレクト先のドメインを登録します。

なお、ここでは、ひとまず、Viteの開発要サーバ、http://localhost:5173/*で設定します。
これは、後にFirebaseでデプロイし、アプリ公開をする場合は、変更が必要となります。

そして、先程のようにメールテンプレートを設定します。予めテンプレートはセットされてますので、カスタマイズしたい場合に変更します。
Authentication > Email Templates、Reset Passwordを選択します。変更後、下にある「Save」ボタンをクリックして保存します。

下記のような案内メールが送信されます。クリックすると、後ほど解説する、パスワードリセット画面にアクセスできます。

1.5 テーブルの準備

ここまでで、Supabaseのユーザー認証周りの設定は実施しましたので、次に利用するテーブルの準備です。作成したプロジェクトを選択の上、左側にhover時表示されるメニューから「Table Editor」を選択します。

ここではテーブルは新規作成してますが、前回作成したテーブルをDuplicateして利用するのもありだと思います。ただし、ポリシーは新規に作成し直しが必要です。

「Create a new table]を選択します。

Nameは「learning_record_auth」としました(※お好きなもので結構です)。
Descriptionは任意で
Enable Row Level Security (RLS)Recommended は、デフォルトのONのままで
Enable Realtime は不要です。
Columnsは以下内容で設定してください。前回から、emailのフィールドを新たに追加しています。これは認証ユーザーのemailを登録し、そのemailがマッチするデータのみをユーザに表示・編集させるようにしたいので、このようにしてます。

NameTypeDafault ValuePrimaryExtra Options
iduuidgen_random_uuid()
titlevarchar選択なし
timeinit4選択なし
emailtext選択なし

Foreign keysは不要です。

次に作成したtableにデータを作成しておきます。
左メニューのTable Editorから「learning_record」(作成したテーブル)を選択し、insert → insert lowをクリックします。

右側にデータの内容を入力するエリアが表示されますので、データを入力します。ひとまず、初期データとして下記のReactとTypeScriptの2つセットします(idは自動でセットされますので、入力不要です)。またセットするメールアドレスは実際にメールを受け取れるアドレスを設定してください。後ほど、ユーザ登録の際に送信されたメールで認証を行う為です。

Supabase上でメール認証を実施しなくても、ユーザーを作成することは可能です(本記事ではそのようにしてtest@test.comを設定してます)。ですが、送信されたメールでの認証処理等、一連の処理を実行するには、実際にメール確認出来るアドレスを利用されるのがお薦めです。
※追記:
1.2で記述の通り2024/9/26以降の仕様変更により、ダッシュボード上でのユーザー作成は出来なくなりました。よってカスタムSMTP等でのメール認証を実施するか、emailではない認証方式でユーザ作成が必要です。
idtitletimeemail
自動セットReact10ユーザ登録に使用するアドレス
自動セットTypeScript5ユーザ登録に使用するアドレス

「learning_record_auth」の内容が以下のように表示されるかと思います。

1.6 ポリシーの作成

続いて作成したテーブルに対して、ポリシー作成を行います。これはテーブルのデータ操作(SELECT,INSERT,UPDATE,DELETE)に対する権限を設定するものです。
作成テーブル「learning_record」の右側にある、・・・の箇所をクリックすると表示されるメニューから、View Policies を選択します。

表示される画面の右側の、Create Policyをクリックします。

以下画面のように設定していきます。

  • Policy Name:任意のポリシー名を入力します。ここではlearning-records policyとしてます。
  • Polciy Command:ALLを選択、全ての操作に共通のポリシーとしてます。
  • Target Roles:anonと、authenticatedを選択
  • Use options above to edit:7行目に、tureと記載
  • Use check expression:チェックしない
前回記事のテーブル作成時は、Target Roles:anonとしてましたが、今回はユーザー認証が通ったユーザーにアクセス許可しますので、authenticatedを追加します。なお、anonを残していますが、これは開発途中段階での動作確認の為の暫定です。最終的には認証ユーザのみとしますので、authenticatedのみとするよう、後ほど修正します。

この内容で、画面下にある、Save policyをクリックしてポリシーを保存します。
これでSupabase側の設定は完了です。

2. 環境構築

それでは開発環境の構築をしていきます。今回は、ユーザー認証実装にあたり、サインアップ、ログイン、ログアウト、パスワードリセットと言った機能を開発します。また従来(前回まで)のテーブル表示・編集・追加・削除機能もあります。色々と画面遷移が発生しますので、React Routerを利用します。React Routerは、複数のページを持つReactアプリケーションを構築する際に利用されるライブラリです。React Routerを使うことで、ユーザーの操作に応じて表示内容を変更したり、URLにパラメータやクエリを含めて表示内容を変更したりすることができます。

2.1 アプリ構造

今回作成するアプリの構造は下図のイメージです。

  • App.tsx
    トップコンポーネントです。
  • pages/Login.tsx
    ログイン画面です。初期画面となります。ルーティングは”/”で設定します。
  • pages/Home.tsx
    ログイン後、ログインユーザ用の学習記録をsupabaseより読み込み表示します。ルーティングは”/home”でセットします。
  • components/Edit.tsx
    前回記事で作成した学習記録を編集・更新するコンポーネントです。モーダルを採用しています。
  • components/Delete.tsx
    前回記事で作成した学習記録を削除するコンポーネントです。モーダルを採用しています。
  • components/NewEntry.tsx
    前回記事で作成した学習記録を新規登録コンポーネントです。モーダルを採用しています。
  • pages/Logout.tsx
    ログアウト用のモーダル。ログアウトすると、初期画面(ログインページ)に遷移します。
  • pages/Register.tsx
    ユーザーサインアップの画面です。初期画面で新規登録をクリックすると遷移します。ルーティングは”/register”でセットします。
  • pages/ResetSend.tsx
    パスワード忘れ等、パスワードをリセットする場合の画面です。入力されたメールアドレス宛にリセット画面URLの案内メールを送信します。ルーティングは”/sendReset”でセットします。
  • pages/PasswordSend.tsx
    パスワードリセット申請を受けたあと、メールで案内されるパスワードリセット用の画面です。ルーティングは”/passwordReset”でセットします。

2.2 初期環境構築

前回までの記事で、Reactプロジェクトの作成、及び必要なライブラリインストールを実施している方は、そのままその環境を利用いただいても結構です。新規のプロジェクトを作成したい場合は、以下の手順を実施してください。なお、JavaScript実行環境のnode.jsとパッケージマネージャのnpmはインストールされている前提としてます。環境がない場合は、以下を参照ください。

まずは、Viteを利用してReactプロジェクトを作成します。ターミナル上で、プロジェクトを作成したいディレクトリに移動して、npm create vite@latest でプロジェクト作成を実行します。
プロジェクト名は「learning-records-auth」としましたが、お好きな名前で結構です。
他の選択肢は下記の通りです。

npm create vite@latest
 Project name:  learning-records-auth
 Select a framework:  React
 Select a variant:  TypeScript


これでReactの初期環境構築は完了です。
続けて出てくる内容に従って、環境起動を行います。

cd learning-records-auth
npm i
npm run dev


以下のように表示されると思います。http://localhost:5173/にアクセスすれば開発環境の画面が表示されます。

  VITE v5.4.2  ready in 237 ms<

    Local:   http://localhost:5173/
    Network: use --host to expose
    press h + enter to show help


続いて、必要なライブラリをインストールします。Chakura UI、React Icons、そしてReact Routerをインストールします。サーバを起動したターミナルで「ctrl-c」を入力し一度サーバ停止するか、別のターミナルを開いてプロジェクトディレクトリ(learning-records-auth)に移動してインストールします

npm i @chakra-ui/react @emotion/react @emotion/styled framer-motion react-icons react-router-dom


Supabaseのパッケージをインストールします。

 npm i @supabase/supabase-js

2.3 環境定義ファイル等の作成

それではコードを記載していきます。
まずは、前回記事で作成していた、Supabaseの環境設定ファイルをプロジェクトルート直下に、また、Supabaseのクライアント機能のファイル、学習データの型定義ファイルを/src配下に作成します。

  • supabaseの環境設定:/.env
  • supabaseのクライアント機能:/src/supabaseClient.ts
  • 学習データの型定義:/src/studyData.ts

まずは、.envです。ここには、Supabase接続の為の機密情報を記載しています。

// /.env

VITE_SUPABASE_URL=Supabaseのアクセス先URL
VITE_SUPABASE_ANON_KEY=SupabaseAPIキー


ここで、VITE_SUPABASE_URLとVITE_SUPABASE_ANON_KEYを記載しますが、それぞれの内容は、Supabaseのサイトにて確認します。Supabaseのプロジェクトの設定 > APIから、Project URL及びProject API Keys内のanon keyをコピー(下図参照)し、上記の.envにそれぞれ貼り付けます。

続いて、/src/supabaseClient.tsです。下記コードを記載します。他のコンポーネントからDBデータの参照や更新等の処理を行う際は、この機能を呼び出して利用します。

// /src/supabaseClient.ts
import { createClient } from '@supabase/supabase-js'

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY;

export const supabase = createClient(supabaseUrl, supabaseKey);


そして、型定義の/src/studyData.tsです。下記のコードを記載します。

// /src/studyData.ts

export type StudyData = {
    id: string,
    title: string,
    time: number,
}

2.3 その他、下準備

下準備と記載したのは、前回記事の繋がりからそのように表現してます。前回のコードをベースに解説をしていきます。なお、新規に作成される方も進め方は変わりません。掲載しているコードをそのまま記載頂ければ大丈夫です。
今回はReact Router機能を使っていきます。App.tsxはRouter設定の記述が中心となりますので、前回記載していた、Supabaseから読み込んだテーブルデータ表示箇所は別のコンポーネントに移行します。srcフォルダ配下に、pagesフォルダを作成し、その中に、Home.tsxと言うファイルを作成します。

まず、main.tsxとApp.tsxにReact Routerの設定を記述します。また、App.tsxは、Home.tsxに持って行くテーブル表示の箇所は削除します。
mai.tsxとApp.tsxのコード内容は以下です。

// /src/main.tsx

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import { ChakraProvider } from '@chakra-ui/react'
import { BrowserRouter } from 'react-router-dom'//React Routerのインポート

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <ChakraProvider>//Chakra UI
      <BrowserRouter>//BrowserRouterで囲む
        <App />
      </BrowserRouter>//BrowserRouterで囲む
    </ChakraProvider>//Chakra UI
  </StrictMode>,
)


続いて、App.tsxです(下記はコード全体です)。

// /src/App.tsx
import { useState } from "react";
import { Link, Route, Routes } from "react-router-dom";//React Routerのインポート
import { Box, Card, Flex } from "@chakra-ui/react"
import { StudyData } from "./studyData";
import { supabase } from "./supabaseClient";
import Home from "./pages/Home";//Home.tsxインポート

function App() {
  const [learnings, setLearnings] = useState<StudyData[]>([]);
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<string>('');
  const [email, setEmail] = useState('test@test.com');
  //emailステートを追加、test@test.comと記載してますが、Supabaseテーブル作成時に設定したメールアドレスを定義してください

  // Supabaseからデータを取得する関数
  const fetchLearnings = async () => {
    setLoading(true);
    const { data, error } = await supabase
      .from('learning_record_auth')
      .select('*')
      .eq('email', [email])//email追加、配列として渡す
    if (error) {
      console.error('Error fetching data:', error);
      console.log('データの読込に失敗しました', error);
      setError(`データの読込に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      console.log('データ読み込み完了', data);
      setLearnings(data);
      setLoading(false);
    }
  };

  /*App.tsxからは除外、Home.tsxで実装
  // useEffectを使ってコンポーネントのマウント時にデータを取得
  useEffect(() => {
    fetchLearnings();
  }, []);
*/

  //DB新規登録
  const insertDb = async (learning: StudyData) => {
    setLoading(true);
    const { data, error } = await supabase
      .from('learning_record_auth')
      .insert([
        { title: learning.title, time: learning.time, email: email },//email追加
      ])
      .select();
    if (error) {
      console.error('Error insert data:', error);
      setError(`データの更新に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      console.log('insert', data);
      fetchLearnings();//登録後、データ全体を再表示、ローディング解除
    }
  }

  //DB更新
  const updateDb = async (learning: StudyData) => {
    setLoading(true);

    const { data, error } = await supabase
      .from('learning_record_auth')
      .update({ title: learning.title, time: learning.time })
      .eq('id', learning.id)
      .select()

    if (error) {
      console.error('Error insert data:', error);
      setError(`データの更新に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      console.log('update', data);
      fetchLearnings();//登録後、データ全体を再表示、ローディング解除
    }
  }

  //DBデータ削除
  const deleteDb = async (learning: StudyData) => {
    setLoading(true);
    const { error } = await supabase
      .from('learning_record_auth')
      .delete()
      .eq('id', learning.id);
    if (error) {
      console.error('Error delete data:', error);
      setLoading(false);
    } else {
      console.log('delete', learning.id);
      fetchLearnings();//登録後、データ全体を再表示、ローディング解除
    }
  }
  const calculateTotalTime = () => {
    return learnings.reduce((total, learning) => total + learning.time, 0);
  };

  return (
    <Routes>

      <Route path='/home' element={
      <Home
        learnings={learnings} loading={loading} setLoading={setLoading} error={error} 
        setError={setError} deleteDb={deleteDb}
        insertDb={insertDb} updateDb={updateDb} 
        calculateTotalTime={calculateTotalTime} 
        fetchLearnings={fetchLearnings} email={email} />
        }
       />

      <Route
        path="*"
        element={
          <Flex justifyContent='center' alignItems='center' p='5'>
            <Card p='10'>
              みつかりません<br />
              <Box
                as='span'
                textDecoration='none'
                _hover={{ textDecoration: 'underline' }}
              >
                <Link to='/'>トップページへ</Link>
              </Box>
            </Card>
          </Flex>
        }
      />
    </Routes>
  )
}

export default App

App.tsxについて解説します。
まずは、React Routerのインポートと、新しいstate、email, setEmailの追加です。

// /src/App.tsx

import { useState } from "react";
import { Link, Route, Routes } from "react-router-dom";//React Routerのインポート
import { Box, Card, Flex } from "@chakra-ui/react"
import { StudyData } from "./studyData";
import { supabase } from "./supabaseClient";
import Home from "./pages/Home";//Home.tsxインポート

function App() {
  const [learnings, setLearnings] = useState<StudyData[]>([]);
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<string>('');
  const [email, setEmail] = useState('test@test.com');
  //emailステートを追加、test@test.comと記載してますが、Supabaseテーブル作成時に設定したメールアドレスを定義してください

インポートの箇所に、React Routerパッケージのインポートを記述します。また、先に作成した、Home.tsxのインポートも追加しておきます。

補足ながら、このimport文はVSCodeをお使いであれば、それに関連するコードを入力することで、自動で補完してくれます。以前の記事に掲載しています。

そして、新たなstateとしてemail, setEmailを追加しています。ログインユーザー情報としてemailを利用してテーブルのデータ操作を行うためです。この段階では、emailは、test@test.comと固定値をセットしています。上記コードではtest@test.comと記載してますが、このメールアドレスは先の1.4 テーブルの準備で設定したメールアドレスを記載してください。動作確認の際、ここで設定されたメールアドレスにマッチしたテーブルデータを抽出する為です。

続いて、DB処理関連の関数についてです。

 // /src/App.tsx
 
  // Supabaseからデータを取得する関数
  const fetchLearnings = async () => {
    setLoading(true);
    const { data, error } = await supabase
      .from('learning_record_auth')
      .select('*')
      .eq('email', [email])//email追加、配列として渡す
    if (error) {
      console.error('Error fetching data:', error);
      console.log('データの読込に失敗しました', error);
      setError(`データの読込に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      console.log('データ読み込み完了', data);
      setLearnings(data);
      setLoading(false);
    }
  };

  //DB新規登録
  const insertDb = async (learning: StudyData) => {
    setLoading(true);
    const { data, error } = await supabase
      .from('learning_record_auth')
      .insert([
        { title: learning.title, time: learning.time, email: email },//email追加
      ])
      .select();
    if (error) {
      console.error('Error insert data:', error);
      setError(`データの更新に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      console.log('insert', data);
      fetchLearnings();//登録後、データ全体を再表示、ローディング解除
    }
  }

まず、Supabaseのテーブル「learning_record_auth」のデータを読み込む、fetchLearningsです。
新たに.eq(‘email’, [email]) を追記し、テーブルのemailの欄が、定数emailにマッチするデータを持ってきています。なお、emailの型はstringですので、テーブルからselectする際は配列の形式([ ]で囲む)で渡す必要があります。

続いて、新規データ登録を行う、insertDbですが、新たにinsert(データ追加)の要素として、email: emailを追加しています。

他の関数(updateDb、deleteDb)は、emailの追加等は発生しません。が、前回のコードを流用している方は、操作対象のデーブルが変更となってますので、そこは修正必要ですので、ご注意ください(learning_record → learning_record_auth、テーブルを変更していない方は不要です)。

console.logをあちこちに記載してますが、処理自体には必要ありません。コードがどこまで処理されてるかのトレース目的と、console.logで定数指定しないと、「宣言されてるけど、どこにも使われてない警告」が出たりするので、残してます。

続いて、return以降のJSX分の箇所です。

※JSX(JavaScript XML)は、コンポーネント指向のJavaScriptライブラリやフレームワーク(特にReact)で一般的に採用されている、JavaScriptの拡張構文です。JSXを用いると、JavaScriptのコード内にHTMLタグのような構文が埋め込み可能となり、より直感的かつ読みやすい形でUIのコードを表現することができます。それによって、開発者のコーディング体験や開発、デバッグの効率が上がります。
https://typescriptbook.jp/reference/jsx
 // /src/App.tsx
 return (
    <Routes>

      <Route path='/home' element={
      <Home
        learnings={learnings} loading={loading} setLoading={setLoading} error={error} 
        setError={setError} deleteDb={deleteDb}
        insertDb={insertDb} updateDb={updateDb} 
        calculateTotalTime={calculateTotalTime} 
        fetchLearnings={fetchLearnings} email={email} />
        }
       />

      <Route
        path="*"
        element={
          <Flex justifyContent='center' alignItems='center' p='5'>
            <Card p='10'>
              みつかりません<br />
              <Box
                as='span'
                textDecoration='none'
                _hover={{ textDecoration: 'underline' }}
              >
                <Link to='/'>トップページへ</Link>
              </Box>
            </Card>
          </Flex>
        }
      />
    </Routes>
  )

JSXの箇所は、React Routerのルート設定を記載しています。
<Routes>で囲まれた箇所に
<Route path=’設定したいパス’ element={読み込むコンポーネント+渡すPorps、もしくはJSXの記載} />
のような書き方をします。

上記のコードでは、/homeのパスとして、Home.tsxをセットしてます。Home.tsxは先述の通り、従来App.tsxに記載していた、Supabaseのテーブルデータ表示及び、その中にセットしていた、編集機能のEdit.tsx、削除機能のDelete.tsx、新規データ登録のNewEntry.tsxの埋め込みを移管しますので、App.tsxからHome.tsxに渡すPropsも他のコンポーネント(Edit.tsx,Delete.tsx,NewEntry.tsx)に渡すPropsも合わせてHome.tsxに渡します。他のコンポーネントはHome.tsx経由でPropsを受け取る形です。と言うことで<Home …の箇所は長いPropsの羅列になってます。

次にルート設定として<Route path=”*” を設定しています。これは、ルート設定のいずれも当てはまらない全てのURLの表示を指定しています。いわゆる、404ページです。記載内容としては、みつかりません、としてトップページへのリンクを表示しています。

なお、この段階では、ルート設定は/homeしか無いので、たとえ、ルート直下の/にアクセスしても、みつかりません。が表示されることになります。

続いて、Home.tsxに以下コードを記載します(以下はコード全体です)。

// /src/pages/Home.tsx
import { useEffect } from "react"
import { Box, Card, CardBody, CardHeader, Flex, Heading, Spinner, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react"
import Edit from "../components/Edit"
import Delete from "../components/Delete"
import NewEntry from "../components/NewEntry"
import { StudyData } from "../studyData"


type Props = {
    learnings: StudyData[],
    insertDb: (learning: StudyData) => Promise<void>
    updateDb: (learning: StudyData) => Promise<void>,
    deleteDb: (learning: StudyData) => Promise<void>
    loading: boolean,
    setLoading: React.Dispatch<React.SetStateAction<boolean>>,
    error: string,
    setError: React.Dispatch<React.SetStateAction<string>>,
    calculateTotalTime: () => number,
    fetchLearnings: () => Promise<void>,
    email: string,
}
const Home: React.FC<Props> = ({ learnings, insertDb, updateDb, deleteDb, loading, setLoading, error, setError, calculateTotalTime, fetchLearnings, email }) => {

    // useEffectを使ってコンポーネントのマウント時にデータを取得、Home.tsxマウント時に実行
    useEffect(() => {
        fetchLearnings();
    }, []);

    return (
        <>
            <Flex alignItems='center' justify='center' p={5}>
                <Card size={{ base: 'sm', md: 'lg' }}>
                    <Box textAlign='center' mb={2} mt={6}>
                        ようこそ!{email} さん
                    </Box>
                    <CardHeader>
                        <Heading size='md' textAlign='center'>Learning Records</Heading>
                    </CardHeader>
                    <CardBody>

                        <Box textAlign='center'>
                            学習記録
                            {loading && <Box p={10}><Spinner /></Box>} {/*ローティング中であれば<Spinner />を表示*/}
                            {error && <Box p={10} color='red'>{error}</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} error={error} setError={setError} />
                                                </Td>
                                                <Td>
                                                    <Delete learning={learning} deleteDb={deleteDb} loading={loading} />
                                                </Td>
                                            </Tr>
                                        ))}
                                    </Tbody>
                                </Table>
                            </TableContainer>
                        </Box>
                        <Box p={5}>
                            <div>合計学習時間:{calculateTotalTime()}</div>
                        </Box>
                        <Box p={25}>
                            <NewEntry learnings={learnings} insertDb={insertDb} updateDb={updateDb} loading={loading} error={error} setError={setError} />
                        </Box>
                    </CardBody>
                </Card>
            </Flex>
        </>

    )
}
export default Home

それぞれの箇所をみてみます。
これまで、App.tsxに記載していた、Supabaseから読み込んだ学習記録データの表示を移管しています。伴い、データの編集・削除・新規登録(Edit.tsx,Delete.tsx,NewEntry.tsx)コンポーネントを読み込んでいます。これにより、Typesの設定及び、Propsも、Edit.tsx,Delete.tsx,NewEntry.tsxに渡すもの含めた定義となっています。また、新しいstate、emailも定義します。

// /src/pages/Home.tsx
import { useEffect } from "react"
import { Box, Card, CardBody, CardHeader, Flex, Heading, Spinner, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react"
import Edit from "../components/Edit"//Edit.tsxのインポート
import Delete from "../components/Delete"//Delete.tsxのインポート
import NewEntry from "../components/NewEntry"//NewEntry.tsxのインポート
import { StudyData } from "../studyData"


type Props = {//各Propsの型定義+新規stateのemailの型定義
    learnings: StudyData[],
    insertDb: (learning: StudyData) => Promise<void>
    updateDb: (learning: StudyData) => Promise<void>,
    deleteDb: (learning: StudyData) => Promise<void>
    loading: boolean,
    setLoading: React.Dispatch<React.SetStateAction<boolean>>,
    error: string,
    setError: React.Dispatch<React.SetStateAction<string>>,
    calculateTotalTime: () => number,
    fetchLearnings: () => Promise<void>,
    email: string,
}
const Home: React.FC<Props> = ({ learnings, insertDb, updateDb, deleteDb, loading, setLoading, error, setError, calculateTotalTime, fetchLearnings, email }) => {//Edit,Delete,NewEntryに渡すProps含め定義+email

元々はApp.tsxに記載していた、マウント時のテーブルデータ取得・表示の処理もHome.tsxで実装しています。

// /src/pages/Home.tsx

    // useEffectを使ってコンポーネントのマウント時にデータを取得、Home.tsxマウント時に実行
    useEffect(() => {
        fetchLearnings();
    }, []);

return後のJSX部分です。App.tsx時のものをそのまま移行している感じです。

// /src/pages/Home.tsx

    return (
        <>
            <Flex alignItems='center' justify='center' p={5}>
                <Card size={{ base: 'sm', md: 'lg' }}>
                    <Box textAlign='center' mb={2} mt={6}>
                        ようこそ!{email} さん
                    </Box>
                    <CardHeader>
                        <Heading size='md' textAlign='center'>Learning Records</Heading>
                    </CardHeader>
                    <CardBody>

                        <Box textAlign='center'>
                            学習記録
                            {loading && <Box p={10}><Spinner /></Box>} {/*ローティング中であれば<Spinner />を表示*/}
                            {error && <Box p={10} color='red'>{error}</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} error={error} setError={setError} />
                                                </Td>
                                                <Td>
                                                    <Delete learning={learning} deleteDb={deleteDb} loading={loading} />
                                                </Td>
                                            </Tr>
                                        ))}
                                    </Tbody>
                                </Table>
                            </TableContainer>
                        </Box>
                        <Box p={5}>
                            <div>合計学習時間:{calculateTotalTime()}</div>
                        </Box>
                        <Box p={25}>
                            <NewEntry learnings={learnings} insertDb={insertDb} updateDb={updateDb} loading={loading} error={error} setError={setError} />
                        </Box>
                    </CardBody>
                </Card>
            </Flex>
        </>

この時点で、http://localhost:5173/ にアクセスしてみます。そうすると、「みつかりません」と表示されます。これは前述の通り、まだ、”/”に対するルートの設定をしていないためです。

次にルート設定のある、http://localhost:5173/home にアクセスしてみます。そうすると、以下の画面が表示されます(test@test.comの箇所は、emailのstateで設定したメールアドレスが表示されます)。
App.tsxで定義した、fetchLearningsにより、Supabaseのテーブルからemailにマッチしたデータを抽出し、表示させています。

また、その他の、編集、削除、新規データ登録も、問題なく動作すると思います。Supabaseのテーブル上も操作内容が反映されると思います。
これで、main.tsx、App.tsx、Home.tsxの下準備は完了です。

なお、Edit.tsx, Delete.tsx, NewEntry.tsx については、特に変更はありません。コードは以下に掲載しておきます。内容については、前回記事を参照ください。

// /src/components/Edit.tsx
import React, { useRef, useState } from 'react';
import { Box, Button, FormControl, FormLabel, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, useDisclosure } from '@chakra-ui/react';
import { FiEdit } from "react-icons/fi"
import { StudyData } from '../studyData';

type Props = {
    learning: StudyData
    updateDb: (learning: StudyData) => Promise<void>
    loading: boolean
    error: string
    setError: (error: string) => void
}

const Edit: React.FC<Props> = ({ learning, updateDb, loading, error, setError }) => {
    const { isOpen, onOpen, onClose } = useDisclosure()
    const initialRef = useRef(null)
    const [localLearning, setLocalLearning] = useState(learning)

    const handleChangeTitle = (e: React.ChangeEvent<HTMLInputElement>) => {
        setLocalLearning({
            ...localLearning,
            title: e.target.value
        })
    }

    const handleChangeTime = (e: React.ChangeEvent<HTMLInputElement>) => {
        setLocalLearning({
            ...localLearning,
            time: Number(e.target.value)
        })
    }

    const handleUpdate = async () => {
        await updateDb(localLearning);
        if (!loading) {
            setTimeout(() => {
                onClose();
            }, 500);
        }
    }

    return (
        <>
            <Button variant='ghost' onClick={() => {
                setLocalLearning(learning)
                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={localLearning.title}
                                onChange={handleChangeTitle}
                                onFocus={() => setError("")}
                            />
                        </FormControl>

                        <FormControl mt={4}>
                            <FormLabel>学習時間</FormLabel>
                            <Input
                                type='number'
                                placeholder='学習時間'
                                name='time'
                                value={localLearning.time}
                                onChange={handleChangeTime}
                                onFocus={() => setError("")}
                            />
                        </FormControl>
                        <div>入力されている学習内容:{localLearning.title}</div>
                        <div>入力されている学習時間:{localLearning.time}</div>
                        {error &&
                            <Box color="red">{error}</Box>}
                    </ModalBody>

                    <ModalFooter>
                        <Button
                            isLoading={loading}
                            loadingText='Loading'
                            spinnerPlacement='start'
                            colorScheme='blue'
                            variant='outline'
                            mr={3}
                            onClick={() => {
                                if (localLearning.title !== "" && localLearning.time > 0) {
                                    handleUpdate()
                                }
                                else setError("学習内容と時間を入力してください")
                            }}
                        >
                            データを更新
                        </Button>
                        <Button onClick={() => {
                            setError("") //エラーを初期化してモーダルクローズ
                            onClose()
                        }}>Cancel</Button>
                    </ModalFooter>
                </ModalContent>
            </Modal>
        </>
    )
}

export default Edit;

// /src/components/Delete.tsx
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 '../studyData';

type Props = {
    learning: StudyData
    deleteDb: (learning: StudyData) => Promise<void>
    loading: boolean
}

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

    const handleDelete = async () => {
        await deleteDb(learning);
        if (!loading) {
            setTimeout(() => {
                onClose();
            }, 500);
        }
    }

    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
                            isLoading={loading}
                            loadingText='Loading'
                            spinnerPlacement='start'
                            ref={initialRef}
                            colorScheme='red'
                            variant='outline'
                            mr={3}
                            onClick={handleDelete}
                        >
                            削除
                        </Button>
                        <Button onClick={onClose}>Cancel</Button>
                    </ModalFooter>
                </ModalContent>
            </Modal>
        </>
    )
}

export default Delete;

// /src/components/NewEntry.tsx
import { useRef, useState } from "react";
import { Box, Button, FormControl, FormLabel, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Stack, useDisclosure } from "@chakra-ui/react";
import { StudyData } from "../studyData";

type Props = {
    learnings: StudyData[]
    insertDb: (learning: StudyData) => Promise<void>
    updateDb: (learning: StudyData) => Promise<void>
    loading: boolean
    error: string
    setError: (error: string) => void
}

const NewEntry: React.FC<Props> = ({ learnings, insertDb, updateDb, loading, error, setError }) => {
    const [learning, setLearning] = useState<StudyData>({ id: "", title: "", time: 0 })
    const { isOpen, onOpen, onClose } = useDisclosure()
    const initialRef = useRef(null)

    const handleEntry = async (data: StudyData) => {
        if (learnings.some((l) => l.title === data.title)) {
            const existingLearning = learnings.find((l) => l.title === data.title);
            if (existingLearning) {
                existingLearning.time += data.time;
                await updateDb(existingLearning);
            }
        } else {
            await insertDb(data);
        }
        setLearning({ id: "", title: "", time: 0 });
        if (!loading) {
            setTimeout(() => {
                onClose();
            }, 500);
        }
    }

    return (
        <>
            <Stack spacing={3}>
                <Button
                    colorScheme='blue'
                    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='newEntryTitle'
                                placeholder='学習内容'
                                value={learning.title}
                                onChange={(e) => {
                                    setLearning({ ...learning, title: e.target.value })
                                }}
                                onFocus={() => setError("")}
                            />
                        </FormControl>

                        <FormControl mt={4}>
                            <FormLabel>学習時間</FormLabel>
                            <Input
                                type='number'
                                name='newEntryTime'
                                placeholder='学習時間'
                                value={learning.time}
                                onChange={(e) => {

                                    setLearning({ ...learning, time: Number(e.target.value) })
                                }}
                                onFocus={() => setError("")}
                            />
                        </FormControl>
                        <div>入力されている学習内容:{learning.title}</div>
                        <div>入力されている学習時間:{learning.time}</div>
                        {error &&
                            <Box color="red">{error}</Box>}
                    </ModalBody>
                    <ModalFooter>
                        <Button
                            isLoading={loading}
                            loadingText='Loading'
                            spinnerPlacement='start'
                            colorScheme='blue'
                            variant='outline'
                            mr={3}
                            onClick={() => {
                                if (learning.title !== "" && learning.time > 0) {
                                    handleEntry(learning)
                                }
                                else setError("学習内容と時間を入力してください")
                            }}
                        >
                            登録
                        </Button>
                        <Button onClick={() => {
                            setError("") //エラーを初期化してモーダルクローズ
                            onClose()
                        }}>Cancel</Button>
                    </ModalFooter>
                </ModalContent>
            </Modal>
        </>
    )
}
export default NewEntry

3. サインアップ機能

続いて、Supabase、ユーザー認証のサインアップ(ユーザー登録)機能を開発していきます。

3.1 サインアップコンポーネントの作成

pagesフォルダ配下に、Register.tsxと言うファイルを作成してください。

Register.tsxに、以下のコードを記載します(下記はコード全文です)。

// /src/pages/Register.tsx
import { Box, Button, Card, CardBody, CardHeader, Flex, Heading, Input, InputGroup, InputLeftElement, Text } from "@chakra-ui/react";
import { useState } from "react";
import { FaUserCheck } from "react-icons/fa";//ユーザーアイコン
import { RiLockPasswordFill } from "react-icons/ri";//パスワードアイコン
import { useNavigate } from "react-router-dom";
import { supabase } from "../supabaseClient";

type Props = {
    email: string;
    setEmail: React.Dispatch<React.SetStateAction<string>>;
    loading: boolean;
    setLoading: React.Dispatch<React.SetStateAction<boolean>>;
}

const Register: React.FC<Props> = ({ email, setEmail, loading, setLoading }) => {
    const [password, setPassword] = useState('')
    const [passwordConf, setPasswordConf] = useState('')//パスワード確認用のstate
    const [error, setError] = useState('')
    const navigate = useNavigate();

    const onSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        if (password !== passwordConf) {
            setError('パスワードが一致しません');
            return;
        } else if (password.length < 6) {
            setError('パスワードは6文字以上にしてください');
            return;
        }
        else {
            setLoading(true);
            try {
                const { error: signUpError } = await supabase.auth.signUp({
                    email: email,
                    password: password,
                })
                if (signUpError) {
                    throw signUpError;
                }
                alert('登録完了メールを確認してください');
                navigate('/'); // signUpが成功した場合にのみページ遷移
            }
            catch (error) {
                setError('サインアップに失敗しました');
            }
            finally {
                setLoading(false);
            }
        }
    };

    return (
        <>
            <Flex alignItems='center' justify='center' p={5}>
                <Card px={5}>
                    <CardHeader>
                        <Heading size='md' textAlign='center'>新規登録</Heading>
                    </CardHeader>
                    <CardBody w={{ base: 'xs', md: 'lg' }}>
                        <form onSubmit={onSubmit}>
                            <InputGroup>
                                <InputLeftElement pointerEvents='none'>
                                    <FaUserCheck color='gray' />
                                </InputLeftElement>
                                <Input
                                    autoFocus
                                    type='email'
                                    placeholder='メールアドレスを入力'
                                    name='email'
                                    value={email}
                                    required
                                    mb={2}
                                    onChange={e => setEmail(e.target.value)}
                                    onFocus={() => setError('')}
                                />
                            </InputGroup>
                            <Text fontSize='12px' color='gray'>パスワードは6文字以上</Text>
                            <InputGroup>
                                <InputLeftElement pointerEvents='none'>
                                    <RiLockPasswordFill color='gray' />
                                </InputLeftElement>
                                <Input
                                    type='password'
                                    placeholder='パスワードを入力'
                                    name='password'
                                    value={password}
                                    required
                                    mb={2}
                                    onChange={e => setPassword(e.target.value)}
                                    onFocus={() => setError('')}
                                />
                            </InputGroup>
                            <InputGroup>
                                <InputLeftElement pointerEvents='none'>
                                    <RiLockPasswordFill color='gray' />
                                </InputLeftElement>
                                <Input
                                    type='password'
                                    placeholder='パスワードを入力(確認)'
                                    name='password'
                                    value={passwordConf}
                                    required
                                    mb={2}
                                    onChange={e => setPasswordConf(e.target.value)}
                                    onFocus={() => setError('')}
                                />
                            </InputGroup>
                            {error && <Box color='red'>{error}</Box>}
                            <Box mt={4} mb={2} textAlign='center'>
                                <Button
                                    type="submit"
                                    isLoading={loading}
                                    loadingText='Loading'
                                    spinnerPlacement='start'
                                    colorScheme='cyan'
                                    variant='outline'
                                    mx={2}>登録する</Button>
                                <Button
                                    colorScheme='gray'
                                    onClick={() => window.history.back()}
                                    mx={2}
                                >戻る</Button>
                            </Box>
                        </form>
                    </CardBody>
                </Card>
            </Flex>
        </>
    )
}

export default Register

Register.tsxの内容について解説していきます。

// /src/pages/Register.tsx

import { Box, Button, Card, CardBody, CardHeader, Flex, Heading, Input, InputGroup, InputLeftElement, Text } from "@chakra-ui/react";
import { useState } from "react";
import { FaUserCheck } from "react-icons/fa";//ユーザーアイコン
import { RiLockPasswordFill } from "react-icons/ri";//パスワードアイコン
import { useNavigate } from "react-router-dom";
import { supabase } from "../supabaseClient";

type Props = {
    email: string;
    setEmail: React.Dispatch<React.SetStateAction<string>>;
    loading: boolean;
    setLoading: React.Dispatch<React.SetStateAction<boolean>>;
}

冒頭のインポートは、それぞれ必要なものをインポートしていきます(前述の通りVScodeの補完で自動インポートさせるやり方もあります)。Chakra UIの利用コンポーネント、useState、アイコンの他、React Routerの機能であるuseNavigate、個別で作成した、Supabase接続処理用のsupabaseClientをインポートします。
type設定については、email, setEmail, loading, setLoading をApp.tsxよりPropsで受け取りますので、その型定義です。set関数の型は、React.Dispatch<React.SetStateAction<セットする定数の型名>>と、こう言う書き方をするものと思ってください。

続いて、コンポーネント関数の定義とstate関連です。

// /src/pages/Register.tsx

const Register: React.FC<Props> = ({ email, setEmail, loading, setLoading }) => {
    const [password, setPassword] = useState('')
    const [passwordConf, setPasswordConf] = useState('')//パスワード確認用のstate
    const [error, setError] = useState('')
    const navigate = useNavigate();

const Registerの型は、React.FC<Props>となります。Propsは先のtypeの箇所で設定した型定義です。

stateについては、パスワード用のpassword、確認用に再入力するパスワード用のpasswordConf、そしてerrorはApp.tsxから受け取るのではなく、(errorのステート管理が複雑になるので)ローカルのものとして定義してます。
そして、const navigate として、React RouterのuseNavigateを使用しています。useNavigateはページ遷移をする際に使用されます。navigate()の引数に遷移先のパスを渡すことでページを遷移する事ができます。

次に、Supabaseのサインアップ処理についてです。

// /src/pages/Register.tsx

    const onSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        if (password !== passwordConf) {//2回入力されたパスワード一致確認
            setError('パスワードが一致しません');
            return;
        } else if (password.length < 6) {//パスワード要件充足確認
            setError('パスワードは6文字以上にしてください');
            return;
        }
        else {
            setLoading(true);//ローディング状態セット
            try {//Supabaseサインアップ処理
                const { error: signUpError } = await supabase.auth.signUp({
                    email: email,
                    password: password,
                })
                if (signUpError) {
                    throw signUpError;
                }
                alert('登録完了メールを確認してください');
                navigate('/'); // signUpが成功した場合にのみページ遷移
            }
            catch (error) {
                setError('サインアップに失敗しました');
            }
            finally {
                setLoading(false);
            }
        }
    };

const onSubmit として定義しています。formイベントをサブミットした際に実行されます。
なお、タイムラグが発生しますので、非同期通信のasync/awaitを用いています。

処理としては、まず、入力したパスワードと確認用に入力したパスワードの一致チェックをしています。次にパスワード要件(任意の6文字以上)を満たしているかのチェックです。この6文字以上は、Supabaseのデフォルト設定です。
パスワードに問題なければ、ローディング状態をセットし、Supabase処理機能として作成したsupabaseClient.tsにより、supabase.auth.signUpを実行しています(コード冒頭のインポート箇所、import { supabase } from “../supabaseClient” でsupabaseをインポートしています)。

処理に成功すれば、’登録完了メールを確認してください’とアラートを表示、次に、navigate(‘/’);でルートURL(/)に飛ばしています。
が、現段階では、’/’のルートは設定していないので、「みつかりません」と表示される事になりますが、後ほど、設定しますので、今はこのままとしておいてください。

エラーの場合は、setErrorでエラー内容をセットし、表示。最後にローディング状態を解除(false)しています。

Supabaseのパスワード要件については、Supabaseサイトのプロジェクトの設定(左側メニューの歯車アイコン) > Authentication > Passwords の箇所で設定されています。カスタマイズする場合はそこを変更してください。

続いて、JSXの箇所です。

// /src/pages/Register.tsx

   return (
        <>
            <Flex alignItems='center' justify='center' p={5}>
                <Card px={5}>
                    <CardHeader>
                        <Heading size='md' textAlign='center'>新規登録</Heading>
                    </CardHeader>
                    <CardBody w={{ base: 'xs', md: 'lg' }}>
                        <form onSubmit={onSubmit}>//submitでonSubmitを実行
                            <InputGroup>
                                <InputLeftElement pointerEvents='none'>
                                    <FaUserCheck color='gray' />
                                </InputLeftElement>
                                <Input
                                    autoFocus
                                    type='email'
                                    placeholder='メールアドレスを入力'
                                    name='email'
                                    value={email}
                                    required
                                    mb={2}
                                    onChange={e => setEmail(e.target.value)}//入力値をmailにセット
                                    onFocus={() => setError('')}//再フォーカスでエラー初期化
                                />
                            </InputGroup>
                            <Text fontSize='12px' color='gray'>パスワードは6文字以上</Text>
                            <InputGroup>
                                <InputLeftElement pointerEvents='none'>
                                    <RiLockPasswordFill color='gray' />
                                </InputLeftElement>
                                <Input
                                    type='password'
                                    placeholder='パスワードを入力'
                                    name='password'
                                    value={password}
                                    required
                                    mb={2}
                                    onChange={e => setPassword(e.target.value)}//入力値をpasswordにセット
                                    onFocus={() => setError('')}//再フォーカスでエラー初期化
                                />
                            </InputGroup>
                            <InputGroup>
                                <InputLeftElement pointerEvents='none'>
                                    <RiLockPasswordFill color='gray' />
                                </InputLeftElement>
                                <Input
                                    type='password'
                                    placeholder='パスワードを入力(確認)'
                                    name='password'
                                    value={passwordConf}
                                    required
                                    mb={2}
                                    onChange={e => setPasswordConf(e.target.value)}//入力値をpasswordConfにセット
                                    onFocus={() => setError('')}//再フォーカスでエラー初期化
                                />
                            </InputGroup>
                            {error && <Box color='red'>{error}</Box>}//エラーがあれば、エラー表示
                            <Box mt={4} mb={2} textAlign='center'>
                                <Button
                                    type="submit"
                                    isLoading={loading}
                                    loadingText='Loading'
                                    spinnerPlacement='start'
                                    colorScheme='cyan'
                                    variant='outline'
                                    mx={2}>登録する</Button>//ボタンクリックでsubmit
                                <Button
                                    colorScheme='gray'
                                    onClick={() => window.history.back()}
                                    mx={2}
                                >戻る</Button>
                            </Box>
                        </form>
                    </CardBody>
                </Card>
            </Flex>
        </>
    )
}

新規登録用のformを設置しています。その中にメールアドレス、パスワード、パスワード(確認用)のinputフィールドを設けています。それぞれのinputフィールドは、onChangeイベントで、mail、password、passwordConfをset関数でstate変更を実施しています。
エラーが発生した場合は、{error && <Box color=’red’>{error}</Box>}でエラー表示します。なお、エラー発生した場合、各inputフィールドに再フォーカス(カーソルを移動)することでエラー表示を消す処理をしています。
「登録する」ボタンクリックで、formがサブミットされ、先のSupabaseサインアップ処理のonSubmitが実行されます。

なお、formによるサブミットで処理を実行は、ボタンなどのonClickイベント等でも実現可能ですが、inputフィールドのrequiredオプションを機能させる(requiredが入力されていない場合、ブラウザの機能で入力エラーを表示する)ためには、formサブミットが必要となりますので、この実装としました。

3.2 サインアップ機能の確認

では、実際にRegister.tsxを利用してSupabaseのサインアップ機能の実行を確認してきます。
まず、Register.tsxを表示出来るようにして上げる必要がありますので、App.tsxのルート設定を追加します。App.tsxを以下のように変更します。

// /src/App.tsx
import { useState } from "react";
import { Link, Route, Routes } from "react-router-dom";
import { Box, Card, Flex } from "@chakra-ui/react"
import { StudyData } from "./studyData";
import { supabase } from "./supabaseClient";
import Home from "./pages/Home";
import Register from "./pages/Register";//Register.tsxのインポート追加

まずインポートの箇所に、Register.tsxのインポートを追加します。

// /src/App.tsx
    <Routes>

      <Route path='/home' element={
        <Home
          learnings={learnings} loading={loading} setLoading={setLoading} error={error}
          setError={setError} deleteDb={deleteDb}
          insertDb={insertDb} updateDb={updateDb}
          calculateTotalTime={calculateTotalTime}
          fetchLearnings={fetchLearnings} email={email} />
      }/>
      <Route path='/register' element={//Registerの追加
      <Register
        email={email} setEmail={setEmail} loading={loading} setLoading={setLoading} />
      }/>
      <Route
        path="*"
        element={
          <Flex justifyContent='center' alignItems='center' p='5'>
            <Card p='10'>
              みつかりません<br />
              <Box
                as='span'
                textDecoration='none'
                _hover={{ textDecoration: 'underline' }}
              >
                <Link to='/'>トップページへ</Link>
              </Box>
            </Card>
          </Flex>
        }
      />
    </Routes>

続いて、JSX内に、Routeとして、パス’/register’を追加し、パス先として、Registerをセットする記述をします。以下内容です。
<Route path=’/register’ element={
<Register
email={email} setEmail={setEmail} loading={loading} setLoading={setLoading} />
} />
URLパスは/register、Registerに渡すPropsは、email, setEmail, loading, setLoading となります。

この時点でのApp.tsxコード全体は以下となります。

// /src/App.tsx
import { useState } from "react";
import { Link, Route, Routes } from "react-router-dom";
import { Box, Card, Flex } from "@chakra-ui/react"
import { StudyData } from "./studyData";
import { supabase } from "./supabaseClient";
import Home from "./pages/Home";
import Register from "./pages/Register";//Register.tsxのインポート追加

function App() {
  const [learnings, setLearnings] = useState<StudyData[]>([]);
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<string>('');
  const [email, setEmail] = useState('test@test.com');

  // Supabaseからデータを取得する関数
  const fetchLearnings = async () => {
    setLoading(true);
    const { data, error } = await supabase
      .from('learning_record_auth')
      .select('*')
      .eq('email', [email])//email追加、配列として渡す
    if (error) {
      console.error('Error fetching data:', error);
      console.log('データの読込に失敗しました', error);
      setError(`データの読込に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      console.log('データ読み込み完了', data);
      setLearnings(data);
      setLoading(false);
    }
  };


  //DB新規登録
  const insertDb = async (learning: StudyData) => {
    setLoading(true);
    const { data, error } = await supabase
      .from('learning_record_auth')
      .insert([
        { title: learning.title, time: learning.time, email: email },//email追加
      ])
      .select();
    if (error) {
      setError(`データの更新に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      console.log('insert', data);
      fetchLearnings();//登録後、データ全体を再表示、ローディング解除
    }
  }

  //DB更新
  const updateDb = async (learning: StudyData) => {
    setLoading(true);

    const { data, error } = await supabase
      .from('learning_record_auth')
      .update({ title: learning.title, time: learning.time })
      .eq('id', learning.id)
      .select()

    if (error) {
      console.error('Error insert data:', error);
      setError(`データの更新に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      console.log('update', data);
      fetchLearnings();//登録後、データ全体を再表示、ローディング解除
    }
  }

  //DBデータ削除
  const deleteDb = async (learning: StudyData) => {
    setLoading(true);
    const { error } = await supabase
      .from('learning_record_auth')
      .delete()
      .eq('id', learning.id);
    if (error) {
      console.error('Error delete data:', error);
      setLoading(false);
    } else {
      console.log('delete', learning.id);
      fetchLearnings();//登録後、データ全体を再表示、ローディング解除
    }
  }
  const calculateTotalTime = () => {
    return learnings.reduce((total, learning) => total + learning.time, 0);
  };

  return (
    <Routes>

      <Route path='/home' element={
        <Home
          learnings={learnings} loading={loading} setLoading={setLoading} error={error}
          setError={setError} deleteDb={deleteDb}
          insertDb={insertDb} updateDb={updateDb}
          calculateTotalTime={calculateTotalTime}
          fetchLearnings={fetchLearnings} email={email} />
      } />
      <Route path='/register' element={
        <Register
          email={email} setEmail={setEmail} loading={loading} setLoading={setLoading} />
      } />
      <Route
        path="*"
        element={
          <Flex justifyContent='center' alignItems='center' p='5'>
            <Card p='10'>
              みつかりません<br />
              <Box
                as='span'
                textDecoration='none'
                _hover={{ textDecoration: 'underline' }}
              >
                <Link to='/'>トップページへ</Link>
              </Box>
            </Card>
          </Flex>
        } />
    </Routes>
  )
}

export default App

では、実際に、Register.tsxの動きを確認しましょう。
http://localhost:5173/register にアクセスします(開発サーバを停止している場合は、ターミナルでnpm run devを実行し、起動してください)。

以下の画面が表示されますので、メールアドレスは、先の1.4 テーブルの準備で設定したメールアドレスを入力します。パスワードは適切なものを入力してください。

入力後、登録するをクリックします。Supabaseでの処理が正常終了すれば、「登録完了メールを確認してください」とアラートが表示されます。

この時点で、Supabaseのサイトを確認してみましょう。1.2 新規登録時のメールテンプレートの箇所で記載した通り、Supabase上のAuthentication > Usersを選択すると、登録されたユーザ一覧が表示されますが、まだリンクでの認証が済んでいない場合は、Last Sign Inの列でWaiting gor verificationと黄色で表示されてると思います。

続いて、入力したメールアドレスにてSupabaseから送信されたメールを確認します。下記のような登録確認メールが届きますので、登録完了をクリックしましょう。クリック後は、http://localhost:5173/にリダイレクトされます。この時点では、「/」のルート設定はされていない為、「みつかりません」と表示されますが、問題ありません。

登録完了クリック後、Supabase上のAuthentication > Usersを確認すると、Last Sign Inの列でWaiting gor verificationの表示が変わり、登録完了した時間が、サインイン時間として表示されていると思います。これで登録認証完了です。

3.3 Supabaseポリシーの変更

ユーザー登録が完了しましたので、1.5 ポリシーの作成の箇所で、動作確認の為に暫定設定としていたポリシー設定を変更します。
Target Roles:anonと、authenticated
としてましたが、
Target Roles:authenticated
のみとします。

以上でサインアップ機能の開発・実装は完了です。前編はここまでとします。
次回、後編では、ログイン・ログアウト、パスワードリセット機能について解説します。

後編公開しました!こちらです。

チュートリアル:学習記録アプリシリーズ

No responses yet

コメントを残す

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

AD




TWITTER


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