react+Supabase

Reactチュートリアル、学習記録アプリのユーザー認証実装、後編です。Supabaseの認証機能を利用したユーザー認証の仕組みを実装します。
前編でユーザーサインアップ機能の開発まで行いました。
後編では、ログイン、ログアウト、パスワードリセットの機能を実装していきます。
前編はこちらです。

はじめに

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

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のユーザー認証の実装にあたって、参考にしたサイトは以下となります。

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

4. ログイン機能

後編と言うことで、4章から始めます。前編でサインアップ機能が出来上がり、実際にログイン可能なユーザー作成できましたので、ここではログイン機能を開発していきます。
開発の前にひとつ確認します。前編のサインアップ機能でユーザー登録を行った際、Supabase上のユーザー情報ではログイン(サインイン)済となっていました。Supabaseはこのログインしている状態=セッション状態をどのように管理しているかです。
Supabaseではセッション管理に、JWTを利用しています。

JWTは、JSON Web Tokenの略語です。JWTはトークンベースの認証の仕組みで、トークンベースの認証では、アプリケーションはユーザーのログイン状態を保持しません。サーバはクライアントに対して「ログイン済」という情報を含んだトークンを生成することができ、そのトークンをセッションで利用・確認することで、ログイン状態を把握します。そしてこのトークンは、ローカルストレージに保管されます。

先のユーザー登録完了の状態で、ブラウザで右クリック > 検証 と選択し、下に表示されるエリアで、アプリケーション > ローカルストレージと選択して、中身を確認してみましょう(これはChromeでの操作です。他のブラウザでは違うかも知れませんが似たような操作・構造だと思います)。
そうすると、下図のように、sb-xxxxxxxxxx-auth-token の名前で情報が保管されていることが分かります。これがSupabaseのJWTです。ログイン処理では、この情報も利用して制御を行います。

4.1 ログインコンポーネントの作成

ログインコンポーネントのファイルを作成します。pagesフォルダ配下に、Login.tsxと言うファイルを作成してください。

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

// /src/pages/Login.tsx
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, Card, CardBody, CardHeader, Flex, Heading, Input, InputGroup, InputLeftElement } from "@chakra-ui/react";
import { FaUserCheck } from "react-icons/fa";//アイコンインポート
import { RiLockPasswordFill } from "react-icons/ri";//アイコンインポート
import { supabase } from "../supabaseClient";//Supabase処理機能インポート

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

const Login: React.FC<Props> = ({ email, setEmail, loading, setLoading }) => {
    const [password, setPassword] = useState('')
    const [error, setError] = useState('')
    const [loginState, setLoginState] = useState(false)
    const navigate = useNavigate();

    const getItemSupabaseSession = (key: string) => {
        const itemStr = localStorage.getItem(key);

        // キーが存在しない場合
        if (!itemStr) {
            return null;
        }

        const item = JSON.parse(itemStr);
        return item.user.email;//セッションユーザーのemailアドレスを取得
    };

    const onLogin = async (e: React.FormEvent) => {
        e.preventDefault();
        setLoading(true);  // ローディング状態を開始

        try {
            const { data, error } = await supabase.auth.signInWithPassword({
                email: email,
                password: password,
            });

            // ローディング状態を終了
            setLoading(false);

            // エラーが存在する場合、エラーメッセージを設定
            if (error) {
                setError('ログインに失敗しました');
            } else if (data.session) {
                // 認証されたユーザーのemailを保存
                setEmail(data.session.user.email || '');
                setLoginState(true);
            }

        } catch (error) {
            // 予期しないエラーが発生した場合にエラーメッセージを設定
            setError('サーバーエラーが発生しました。後でもう一度お試しください。');
            console.error("エラーが発生しました:", error);
        } finally {
            // ローディング状態を終了(tryでもcatchでも確実に実行される)
            setLoading(false);
        }
    };


    // 認証状態のチェックとセッション確認
    useEffect(() => {
        const loggedInEmail = getItemSupabaseSession('sb-YourSupabaseInfo-auth-token');//ローカルストレージ格納データの確認、YourSupabaseInfoの箇所はご自身の環境に合わせた情報を記載

        if (loggedInEmail) {
            setEmail(loggedInEmail);
            setLoginState(true);
        }

        supabase.auth.getSession().then(({ data: { session } }) => {//リアルタイムのセッション情報の確認
            if (session) {
                setEmail(session.user.email || '');
                setLoginState(true);
            }
        });

        const { data: authListener } = supabase.auth.onAuthStateChange((_event, session) => {//リアルタイムのセッション状態変移の確認
            if (session) {
                setEmail(session.user.email || '');
                setLoginState(true);
            }
        });

        return () => {
            authListener?.subscription.unsubscribe(); // authListener.subscription の中に unsubscribe が存在します
        };
    }, [setEmail]);


    // ログインしていればホームにリダイレクト
    if (loginState) {
        setTimeout(() => {
            navigate('/home');
        }, 500)
    }

    return (
        <>
            <Flex alignItems='center' justify='center' p={5}>
                <Card>
                    <CardHeader>
                        <Heading size='md' textAlign='center'>ログイン</Heading>
                    </CardHeader>
                    <CardBody w={{ base: 'xs', md: 'lg' }}>
                        <form onSubmit={onLogin}>
                            <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>
                            <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>
                            {error && <Box color='red'>{error}</Box>}
                            <Box mt={4} >
                                <Button
                                    type='submit'
                                    isLoading={loading}
                                    loadingText='Loading'
                                    spinnerPlacement='start'
                                    colorScheme='blue'
                                    w='100%'>ログイン</Button>
                            </Box>
                            <Box mt={4}>
                                <Button
                                    colorScheme='blue'
                                    variant='outline'
                                    spinnerPlacement='start'
                                    onClick={() => navigate('/register')}
                                    w='100%'>新規登録</Button>
                            </Box>
                            <Box mt={4} mb={2}>
                                <Button
                                    colorScheme='blue'
                                    variant='ghost'
                                    spinnerPlacement='start'
                                    onClick={() => navigate('/sendReset')}
                                    w='100%'>パスワードを忘れた方</Button>
                            </Box>
                        </form>
                    </CardBody>
                </Card>
            </Flex>
        </>
    )
}
export default Login;

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

// /src/pages/Login.tsx
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, Card, CardBody, CardHeader, Flex, Heading, Input, InputGroup, InputLeftElement } from "@chakra-ui/react";
import { FaUserCheck } from "react-icons/fa";//アイコンインポート
import { RiLockPasswordFill } from "react-icons/ri";//アイコンインポート
import { supabase } from "../supabaseClient";//Supabase処理機能インポート

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

const Login: React.FC<Props> = ({ email, setEmail, loading, setLoading }) => {
    const [password, setPassword] = useState('')
    const [error, setError] = useState('')
    const [loginState, setLoginState] = useState(false)
    const navigate = useNavigate();

冒頭のインポートは、それぞれ必要なものをインポートしていきます。Chakra UIの利用コンポーネント、useEffect、useState、アイコンの他、React Routerの機能であるuseNavigate、個別で作成した、Supabase接続処理用のsupabaseClientをインポートします。
type設定については、email, setEmail, loading, setLoading をApp.tsxよりPropsで受け取りますので、その型定義です。
コンポーネント関数の定義は、先のtypeの箇所で設定した型定義Propsを定義します。React.FC<Props>です。
state関連は、パスワード用のpassword、そしてerrorはApp.tsxから受け取るのではなく、(errorのステート管理が複雑になるので)ローカルのものとして定義してます。また、ログイン状態を判定するstate、loginStateを設けています。
そして、Register.tsxと同様、const navigate として、React RouterのuseNavigateを使用しています。

// /src/pages/Login.tsx

    const getItemSupabaseSession = (key: string) => {
        const itemStr = localStorage.getItem(key);

        // キーが存在しない場合
        if (!itemStr) {
            return null;
        }

        const item = JSON.parse(itemStr);
        return item.user.email;//セッションユーザーのemailアドレスを取得
    };

次に、getItemSupabaseSessionです。
これは、先述したローカルストレージに保管されたSupabaseのセッション情報の取得処理です。キーが存在する場合は、その情報の中のuser.emailを取得しています。user.emailはセッションユーザーのemailアドレスです。

// /src/pages/Login.tsx

    const onLogin = async (e: React.FormEvent) => {
        e.preventDefault();
        setLoading(true);  // ローディング状態を開始

        try {
            const { data, error } = await supabase.auth.signInWithPassword({
                email: email,
                password: password,
            });

            // ローディング状態を終了
            setLoading(false);

            // エラーが存在する場合、エラーメッセージを設定
            if (error) {
                setError('ログインに失敗しました');
            } else if (data.session) {
                // 認証されたユーザーのemailを保存
                setEmail(data.session.user.email || '');
                setLoginState(true);
            }

        } catch (error) {
            // 予期しないエラーが発生した場合にエラーメッセージを設定
            setError('サーバーエラーが発生しました。後でもう一度お試しください。');
            console.error("エラーが発生しました:", error);
        } finally {
            // ローディング状態を終了(tryでもcatchでも確実に実行される)
            setLoading(false);
        }
    };

続いて、onLoginです。
こちらが、Supabaseへのログイン処理となります。formイベントをサブミットした際に実行されます。
非同期通信のasync/awaitを用いています。

まず、ローディング状態をtrueにセットします。
次に、Supabase処理機能として作成したsupabaseClient.tsにより、supabase.auth.signInWithPasswordを実行しています。emailとpassword情報で認証します。

認証に成功した場合は、認証されたユーザーのemailアドレス(data.session.user.email)をsetEmailでemailステートにセットしています。
エラーが発生した場合は、エラーメッセージをセット。
最終的に、ローディング状態を解除(false)しています。

次に、useEffectによる処理についてです。

// /src/pages/Login.tsx

    // 認証状態のチェックとセッション確認
    useEffect(() => {
        const loggedInEmail = getItemSupabaseSession('sb-YourSupabaseInfo-auth-token');//ローカルストレージ格納データの確認、YourSupabaseInfoの箇所はご自身の環境に合わせた情報を記載

        if (loggedInEmail) {
            setEmail(loggedInEmail);
            setLoginState(true);
        }

        supabase.auth.getSession().then(({ data: { session } }) => {//リアルタイムのセッション情報の確認
            if (session) {
                setEmail(session.user.email || '');
                setLoginState(true);
            }
        });

        const { data: authListener } = supabase.auth.onAuthStateChange((_event, session) => {//リアルタイムのセッション状態変移の確認
            if (session) {
                setEmail(session.user.email || '');
                setLoginState(true);
            }
        });

        return () => {
            authListener?.subscription.unsubscribe(); // authListener.subscription の中に unsubscribe が存在します
        };
    }, [setEmail]);

useEffectにより、コンポーネントマウント時、及び、setEmail動作時に、処理を実行しています。
これらは、ログイン操作以外に、ページのリロードや、ブラウザウィンドウを閉じたあとに再度表示した際などに、セッション状態の維持とセッションemail情報の維持の為に実装しています。

まず、loggedInEmailで、getItemSupabaseSession(‘sb-YourSupabaseInfo-auth-token’)を実行しています。これは先に挙げたローカルストレージからのSupabaseのセッションemail情報の取得処理です。引数、sb-YourSupabaseInfo-auth-tokenの「YourSupabaseInfo」の箇所はご自身の環境でローカルストレージに保管されているキー名を参照して記載してください。

ローカルストレージから値が取得出来れば、setEmailでemailをセット、setLoginStateでloginState(ログイン状態)をtrueにしています。

続いて、supabase.auth.getSession()〜 で、リアルタイムのセッション情報取得、
const { data: authListener } = supabase.auth.onAuthStateChange〜 で、リアルタイムのセッション状態変移のチェックを行っています。いずれもtrueであればローカルストレージデータ読み込み時と同じ処理を実行しています。これは、例えば、別のウィンドウから別のアカウントでログインした場合等への対処の為です。

// /src/pages/Login.tsx

    // ログインしていればホームにリダイレクト
    if (loginState) {
        setTimeout(() => {
            navigate('/home');
        }, 500)
    }

次の処理ですが、loginStateがtrue、つまりログイン状態であると認識されれば、useNavigateを利用したnavigate(‘/home’)で、Home.tsxのリダイレクトしています。つまり、ログイン状態である場合は、ログイン画面から自動で/homeに遷移する仕組みです。
なお、この処理は少し間を持たせたかった為、setTimeoutで0.5秒後に遷移するようにしています。後述するHome.tsxからのリダイレクト処理との兼ね合いもあります。

最後にJSX箇所です。

// /src/pages/Login.tsx

        <>
            <Flex alignItems='center' justify='center' p={5}>
                <Card>
                    <CardHeader>
                        <Heading size='md' textAlign='center'>ログイン</Heading>
                    </CardHeader>
                    <CardBody w={{ base: 'xs', md: 'lg' }}>
                        <form onSubmit={onLogin}>
                            <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>
                            <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>
                            {error && <Box color='red'>{error}</Box>}
                            <Box mt={4} >
                                <Button
                                    type='submit'
                                    isLoading={loading}
                                    loadingText='Loading'
                                    spinnerPlacement='start'
                                    colorScheme='blue'
                                    w='100%'>ログイン</Button>
                            </Box>
                            <Box mt={4}>
                                <Button
                                    colorScheme='blue'
                                    variant='outline'
                                    spinnerPlacement='start'
                                    onClick={() => navigate('/register')}
                                    w='100%'>新規登録</Button>
                            </Box>
                            <Box mt={4} mb={2}>
                                <Button
                                    colorScheme='blue'
                                    variant='ghost'
                                    spinnerPlacement='start'
                                    onClick={() => navigate('/sendReset')}
                                    w='100%'>パスワードを忘れた方</Button>
                            </Box>
                        </form>
                    </CardBody>
                </Card>
            </Flex>
        </>

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

その他のページ遷移メニューとして、useNavigateによるnavigate()にて、新規登録は、先に作成したRegister.tsxへのリンク(‘/register’)を設定しています。
また、パスワードリセット用に、’/sendReset’を設定しています。が、ここはまだ未作成なので、今時点は「みつかりません」の404に遷移することになります。パスワードリセット機能は後ほど実装します。

4.2 App.tsx, Home.tsxの変更

ログイン画面、Login.tsxが出来ましたので、これをApp.tsxとHome.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";
import Login from "./pages/Login";//Login.tsxのインポート追加

まずは、冒頭のインポート文です。Loginコンポーネントを新たに追加してます。

// /src/App.tsx

function App() {
  const [learnings, setLearnings] = useState<StudyData[]>([]);
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<string>('');
  const [email, setEmail] = useState(''); //emailステートの初期値を固定値から''に変更

続いて、emailステートについて、初期値として固定値を設定してましたが、”の空値をセットします。

// /src/App.tsx

    <Routes>
    
    //Loginのルートを'/'として設定
      <Route path='/' element={ 
        <Login
          email={email} setEmail={setEmail}
          loading={loading} setLoading={setLoading} />
      } />
      

      <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} />
      } />

最後にJSXの箇所です。ルートとしてLoginをルート直下’/’に設定します。
またLoginに必要な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";
import Login from "./pages/Login";//Login.tsxのインポート追加

import SendReset from "./pages/SendReset";
import PasswordReset from "./pages/PasswordReset";

function App() {
  const [learnings, setLearnings] = useState<StudyData[]>([]);
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<string>('');
  const [email, setEmail] = useState(''); //emailステートの初期値を固定値から''に変更

  // 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='/' element={ //Loginのルートを'/'として設定
        <Login
          email={email} setEmail={setEmail}
          loading={loading} setLoading={setLoading} />
      } />
      <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

次は、Home.tsxです。変更は以下内容となります。

// /src/pages/Home.tsx
import { useEffect } from "react"
import { useNavigate } from "react-router-dom"//useNavigate 追加

.
.
.
.

const Home: React.FC<Props> = ({ learnings, insertDb, updateDb, deleteDb, loading, setLoading, error, setError, calculateTotalTime, fetchLearnings, email }) => {

    const navigate = useNavigate()//useNavigate追加

    useEffect(() => {
        fetchLearnings();
    }, []);

    if (!email) {//emailが空であれば、/(ログイン画面)に遷移
        navigate('/')
    }

新たに、useNavigateを導入、インポートしています。
また、useEffectの中に、useNavigateによるnavigate(‘/’)を追加しています。email情報が空の場合は、’/’つまりログイン画面に遷移させています。これはHome画面を表示中にリロード等した場合に、emailステート情報が空になる為、ログイン画面に遷移し、セッション情報の取得を行っています。
これにより、セッション維持中であれば、自動でまたHomeに遷移します。

現時点のHome.tsxのコード全体は以下なります。

// /src/pages/Home.tsx
import { useEffect } from "react"
import { useNavigate } from "react-router-dom"//useNavigate 追加
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 Logout from "./Logout"
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 }) => {

    const navigate = useNavigate()//useNavigate追加

    useEffect(() => {
        fetchLearnings();
    }, []);

    if (!email) {//追加、emailが空であれば、/(ログイン画面)に遷移
        navigate('/')
    }

    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

4.3 ログイン機能の確認

それでは作成したログイン機能が動作するか確認します。
まず、ローカルストレージに格納されている、Supabaseのセッション情報を一度削除します(sb-xxxxxxxxxx-auth-tokenのキー情報)。

その上で、http://localhost:5173/ にアクセスします。
ログイン画面が表示されますので、Supabaseにユーザー登録したメールアドレスとパスワードでログインします。ログインが完了すると、自動でHome画面に遷移し、メールアドレスとマッチした学習記録データが表示されれば成功です。

また、ログイン画面に表示される、新規登録ボタンをクリックすると、3. サインアップ機能で作成したサインアップ画面に遷移するのも確認出来ると思います。

5. ログアウト機能

次にログイン状態からログアウトする機能を作成していきます。本機能はHomeコンポーネントに実装します。ログアウトボタンを設け、モーダルでログアウトコンポーネントを表示させる形です。下図のイメージです。

5.1 ログアウトコンポーネントの作成

ログアウトコンポーネントのファイルを作成します。pagesフォルダ配下に、Logout.tsxと言うファイルを作成してください。

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

// /src/pages/Logout.tsx
import { useRef } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, useDisclosure } from "@chakra-ui/react";
import { supabase } from "../supabaseClient";//Supabase処理機能インポート

type Props = {
    loading: boolean;
    setLoading: React.Dispatch<React.SetStateAction<boolean>>;
}
const Logout: React.FC<Props> = ({ loading, setLoading }) => {
    const navigate = useNavigate();
    const initialRef = useRef(null)
    const { isOpen, onOpen, onClose } = useDisclosure()

    const onLogout = async (e: React.FormEvent) => {
        e.preventDefault();
        setLoading(true);
        try {
            const { error: logoutError } = await supabase.auth.signOut()
            if (logoutError) {
                throw logoutError;
            }
            navigate('/');
        } catch {
            alert('エラーが発生しました');
        } finally {
            setLoading(false);
        }

    }

    return (
        <>
            <Button
                colorScheme='gray'
                onClick={onOpen}
            >
                ログアウト</Button>

            <Modal
                isOpen={isOpen}
                onClose={onClose}
            >
                <ModalOverlay />
                <ModalContent>
                    <ModalHeader>ログアウト</ModalHeader>
                    <ModalCloseButton />
                    <ModalBody pb={6}>
                        <Box>
                            ログアウトしますか
                        </Box>
                    </ModalBody>
                    <ModalFooter>
                        <form onSubmit={onLogout}>
                            <Button
                                type="submit"
                                isLoading={loading}
                                loadingText='Loading'
                                spinnerPlacement='start'
                                ref={initialRef}
                                colorScheme='red'
                                variant='outline'
                                mr={3}
                            >
                                ログアウト
                            </Button>
                            <Button onClick={onClose}>Cancel</Button>
                        </form>
                    </ModalFooter>
                </ModalContent>
            </Modal>
        </>
    )
}
export default Logout;

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

// /src/pages/Logout.tsx
import { useRef } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, useDisclosure } from "@chakra-ui/react";
import { supabase } from "../supabaseClient";//Supabase処理機能インポート

type Props = {
    loading: boolean;
    setLoading: React.Dispatch<React.SetStateAction<boolean>>;
}
const Logout: React.FC<Props> = ({ loading, setLoading }) => {
    const navigate = useNavigate();
    const initialRef = useRef(null)
    const { isOpen, onOpen, onClose } = useDisclosure()

冒頭のインポートは、それぞれ必要なものをインポートしていきます。Chakra UIのModalで利用する、UseRef、React Routerの機能であるuseNavigate、Chakra UIの利用コンポーネント、個別で作成した、Supabase接続処理用のsupabaseClientをインポートします。
type設定については、loading, setLoading をApp.tsxよりPropsで受け取りますので、その型定義です。
コンポーネント関数の定義は、先のtypeの箇所で設定した型定義Propsを定義します。React.FC<Props>です。
そしてRegister.tsx、Login.tsx等と同様、const navigate として、React RouterのuseNavigateを使用しています。その下の、initialRef、{ isOpen, onOpen, onClose }は、Chakra UIのModalコンポーネントで利用するものです。

続いて、ログアウト処理についてです。

// /src/pages/Logout.tsx

    const onLogout = async (e: React.FormEvent) => {
        e.preventDefault();
        setLoading(true);
        try {
            const { error: logoutError } = await supabase.auth.signOut()
            if (logoutError) {
                throw logoutError;
            }
            navigate('/');
        } catch {
            alert('エラーが発生しました');
        } finally {
            setLoading(false);
        }

    }

onLogoutとしてSupabaseからのログアウト処理を定義しています。formイベントをサブミットした際に実行されます。非同期通信のasync/awaitを用いています。

これまでと同様ですが、まず、ローディング状態をtrueにセットします。
次に、Supabase処理機能として作成したsupabaseClient.tsにより、supabase.auth.signOutを実行しログアウト処理をしています。

処理に成功すれば、navigate(‘/’) で、’/’(ログイン画面)に遷移、エラーが発生した場合は、アラートでエラー表示の内容です。
最終的に、ローディング状態を解除(false)しています。

最後にJSXの箇所です。

// /src/pages/Logout.tsx

        <>
            <Button //Home.tsxに表示するログアウトボタン箇所
                colorScheme='gray'
                onClick={onOpen}
            >
                ログアウト</Button>

            <Modal //モーダル表示はここから
                isOpen={isOpen}
                onClose={onClose}
            >
                <ModalOverlay />
                <ModalContent>
                    <ModalHeader>ログアウト</ModalHeader>
                    <ModalCloseButton />
                    <ModalBody pb={6}>
                        <Box>
                            ログアウトしますか
                        </Box>
                    </ModalBody>
                    <ModalFooter>
                        <form onSubmit={onLogout}>
                            <Button
                                type="submit"
                                isLoading={loading}
                                loadingText='Loading'
                                spinnerPlacement='start'
                                ref={initialRef}
                                colorScheme='red'
                                variant='outline'
                                mr={3}
                            >
                                ログアウト
                            </Button>
                            <Button onClick={onClose}>Cancel</Button>
                        </form>
                    </ModalFooter>
                </ModalContent>
            </Modal>
        </>

Chakra UIのModalコンポーネントを利用し、モーダル形式の処理としています。冒頭の<Button…/>の箇所は、Homeコンポーネントに表示される、ログアウトボタン部分。それをクリックすると、モーダルがオープンする仕組みです。

モーダル箇所は、「ログアウト」と「Cansel」のボタン配置。「ログアウト」をクリックすると、onLogoutが実行され、ログアウト処理を実行、正常終了すれば、ログイン画面に遷移の動きとなります。

5.2 Home.tsxの変更

Logout.tsxが完了しましたので、続いて、ログアウトコンポーネントを実装するHome.tsxに変更を加えます。

// /src/pages/Home.tsx
import { useEffect } from "react"
import { useNavigate } from "react-router-dom"
import { Box, Card, CardBody, CardHeader, Flex, Heading, Spinner, Stack, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react"//Stack追加
import Edit from "../components/Edit"
import Delete from "../components/Delete"
import NewEntry from "../components/NewEntry"
import Logout from "./Logout"//ログアウトコンポーネント、インポート追加
import { StudyData } from "../studyData"

まず、冒頭のインポートの箇所です。
Logoutコンポーネント配置の為に、新たにChakra UIのStackをインポートしてます。
また、Logoutコンポーネントのインポートを追加しています。

// /src/pages/Home.tsx
                        <Box p={25}>
                            <NewEntry learnings={learnings} insertDb={insertDb} updateDb={updateDb}
                             loading={loading} error={error} setError={setError} />
                        </Box>
                        {/*ログアウトコンポーネント追加*/}
                        <Box px={25} mb={4}>
                            <Stack spacing={3}>
                                <Logout loading={loading} setLoading={setLoading} />
                            </Stack>
                        </Box>
                        {/*ログアウトコンポーネント追加ここまで*/}
                    </CardBody>
                </Card>

続いて、JSXの箇所です。
NewEntryコンポーネント(新規データ登録)の下にLogoutコンポーネントを配置しています。Propsとして、loading, setLoading を渡しています。

現時点でのHome.tsxのコード全体は以下の通りです。

// /src/pages/Home.tsx
import { useEffect } from "react"
import { useNavigate } from "react-router-dom"
import { Box, Card, CardBody, CardHeader, Flex, Heading, Spinner, Stack, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react"//Stack追加
import Edit from "../components/Edit"
import Delete from "../components/Delete"
import NewEntry from "../components/NewEntry"
import Logout from "./Logout"//ログアウトコンポーネント、インポート追加
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 }) => {

    const navigate = useNavigate()

    useEffect(() => {
        fetchLearnings();
    }, []);

    if (!email) {
        navigate('/')
    }

    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>
                        {/*ログアウトコンポーネント追加*/}
                        <Box px={25} mb={4}>
                            <Stack spacing={3}>
                                <Logout loading={loading} setLoading={setLoading} />
                            </Stack>
                        </Box>
                        {/*ログアウトコンポーネント追加ここまで*/}
                    </CardBody>
                </Card>
            </Flex>
        </>

    )
}
export default Home

5.3 ログアウト機能の確認

ログアウト関連のコードの実装が完了したので、実際に動きを確認していきます。
ログイン機能確認時と同様、一度ローカルストレージにSupabaseのセッション情報を一度します(sb-xxxxxxxxxx-auth-tokenのキー情報)。

その上で、http://localhost:5173/ にアクセスし、ログイン→Homeに遷移→ログアウトを実施してみましょう。ログアウト処理が正常に行われ、ログイン画面に遷移出来ればOKです。ブラウザの検証ツールでローカルストレージのセッション情報保管状況も確認してみましょう。ログアウトすれば自動的にセッション情報も削除されます。

6. パスワードリセット機能

最後にパスワードリセットの機能を実装していきます。パスワードリセットは2つのプロセスで構成されます。

  1. パスワードリセットの申請
    メールアドレスを入力しパスワードリセット申請を実施。申請後、メールでリセットURLを案内。
  2. パスワードリセットの実行
    メールに記載のURLをクリックし、リセット画面にアクセス。パスワードリセット処理を実行。

6.1 パスワードリセット申請

まず、パスワードリセット申請を行うためのコンポーネントを作成します。pagesフォルダ配下に、SendReset.tsxと言うファイルを作成します。

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

import { Box, Button, Card, CardBody, CardHeader, Flex, Heading, Input, InputGroup, InputLeftElement, Stack, Text } from "@chakra-ui/react";
import { FaUserCheck } from "react-icons/fa";
import { RiMailSendLine } from "react-icons/ri";
import { supabase } from "../supabaseClient";

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

const SendReset: React.FC<Props> = ({ email, setEmail, loading, setLoading }) => {

    const onSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        setLoading(true);
        try {
            const { error: sendEmailError } = await supabase.auth.resetPasswordForEmail(email, {
                redirectTo: `${window.location.origin}/passwordReset/`,
            });
            if (sendEmailError) {
                throw sendEmailError;
            }
            alert('パスワード設定メールを確認してください');
        } catch (error) {
            alert('エラーが発生しました');
        } finally {
            setLoading(false);
        }
    };

    return (
        <>
            <Flex alignItems='center' justify='center' p={5}>
                <Card px={5}>
                    <CardHeader>
                        <Heading size='md' textAlign='center'>パスワードリセット申請</Heading>
                    </CardHeader>
                    <Text textAlign='center' fontSize='12px' color='gray'>入力したメールアドレス宛にパスワードリセットURLの案内をお送りします。</Text>
                    <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)}
                                />
                            </InputGroup>
                            <Box mt={4} mb={2} textAlign='center'>
                                <Button
                                    type="submit"
                                    isLoading={loading}
                                    loadingText='Loading'
                                    spinnerPlacement='start'
                                    colorScheme='blue'
                                    variant='outline'
                                    mx={2}>
                                    <Stack mr={2}><RiMailSendLine /></Stack>リセット申請する</Button>
                                <Button
                                    colorScheme='gray'
                                    onClick={() => window.history.back()}
                                    mx={2}
                                >戻る</Button>
                            </Box>
                        </form>
                    </CardBody>
                </Card>
            </Flex>
        </>
    )
}

export default SendReset;

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

// /src/pages/SendReset.tsx
import { Box, Button, Card, CardBody, CardHeader, Flex, Heading, Input, InputGroup, InputLeftElement, Stack, Text } from "@chakra-ui/react";
import { FaUserCheck } from "react-icons/fa";//アイコンインポート
import { RiMailSendLine } from "react-icons/ri";//アイコンインポート
import { supabase } from "../supabaseClient";//Supabase処理機能インポート

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

const SendReset: React.FC<Props> = ({ email, setEmail, loading, setLoading }) => {

冒頭のインポートは、それぞれ必要なものをインポートしていきます。Chakra UIの利用コンポーネント、アイコンの他、React Routerの機能であるuseNavigate、個別で作成した、Supabase接続処理用のsupabaseClientをインポートします。
type設定については、email, setEmail, loading, setLoading をApp.tsxよりPropsで受け取りますので、その型定義です。
コンポーネント関数の定義は、先のtypeの箇所で設定した型定義Propsを定義します。React.FC<Props>です。
state関連は、ローカルのステートは特にありません。

// /src/pages/SendReset.tsx

    const onSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        setLoading(true);// ローディング状態を開始
        try {
            const { error: sendEmailError } = await supabase.auth.resetPasswordForEmail(email, {
                redirectTo: `${window.location.origin}/passwordReset/`, // パスワードリセット画面のURLをセット
            });
            if (sendEmailError) {
                throw sendEmailError;
            }
            alert('パスワード設定メールを確認してください');
        } catch (error) {
            alert('エラーが発生しました');
        } finally {
            setLoading(false);// ローディング状態を解除
        }
    };

続いて、onSubmitです。
こちらが、Supabaseへのパスワードリセット申請機能となります。formイベントをサブミットした際に実行されます。
非同期通信のasync/awaitを用いています。

まず、ローディング状態をtrueにセットします。
次に、Supabase処理機能として作成したsupabaseClient.tsにより、Supabaseの機能、supabase.auth.resetPasswordForEmailを実行しています。email情報で処理します。
redirectTo: ${window.location.origin}/passwordReset/
で、パスワードリセット画面の設定をしています。’/passwordReset/’がリセット画面です。
${window.location.origin} は、JavaScriptのインタフェースプロパティで、そのサイトのURLオリジンを指定できます。

処理に成功した場合は、入力されたユーザーのemailアドレス宛に、パスワードリセット画面のURLが記載されたメールを送信します。
エラーが発生した場合は、エラーメッセージをセット。
最終的に、ローディング状態を解除(false)しています。

 // /src/pages/SendReset.tsx
        <>
            <Flex alignItems='center' justify='center' p={5}>
                <Card px={5}>
                    <CardHeader>
                        <Heading size='md' textAlign='center'>パスワードリセット申請</Heading>
                    </CardHeader>
                    <Text textAlign='center' fontSize='12px' color='gray'>入力したメールアドレス宛にパスワードリセットURLの案内をお送りします。</Text>
                    <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)}
                                />
                            </InputGroup>
                            <Box mt={4} mb={2} textAlign='center'>
                                <Button
                                    type="submit"
                                    isLoading={loading}
                                    loadingText='Loading'
                                    spinnerPlacement='start'
                                    colorScheme='blue'
                                    variant='outline'
                                    mx={2}>
                                    <Stack mr={2}><RiMailSendLine /></Stack>リセット申請する</Button>
                                <Button
                                    colorScheme='gray'
                                    onClick={() => window.history.back()}
                                    mx={2}
                                >戻る</Button>
                            </Box>
                        </form>
                    </CardBody>
                </Card>
            </Flex>
        </>

JSXの箇所です。
formを設置しています。その中にメールアドレスのinputフィールドを設けています。inputフィールドは、onChangeイベントで、mailをset関数でstate変更を実施しています。
エラーが発生した場合は、{error && <Box color=’red’>{error}</Box>}でエラー表示します。
「リセット申請する<」ボタンクリックで、formがサブミットされ、先のSupabaseパスワードリセット申請処理のonSubmitが実行されます。

その他のページ遷移メニューとして、「戻る」ボタンで、JavaScriptのwindow.history.back()にて、前のページに戻る処理をしています。

6.2 パスワードリセット

続いて、パスワードリセット用のコンポーネントを作成します。前述のSendResetの処理によりメールでこのパスワードリセット画面のURLが送付され、パスワードリセット画面にてリセット処理を行います。
まず、pagesフォルダ配下に、PasswordReset.tsxと言うファイルを作成します。

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

// /src/pages/PasswordReset.tsx
import { Box, Button, Card, CardBody, CardHeader, Flex, Heading, Input, InputGroup, InputLeftElement, Text } from "@chakra-ui/react";
import { useState } from "react";
import { RiLockPasswordFill } from "react-icons/ri";//アイコンインポート
import { useNavigate } from "react-router-dom";//アイコンインポート
import { supabase } from "../supabaseClient";//Supabase処理機能インポート

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

const PasswordReset: React.FC<Props> = ({ loading, setLoading }) => {
    const [password, setPassword] = useState('')
    const [passwordConf, setPasswordConf] = useState('')
    const [error, setError] = useState('')
    const navigate = useNavigate();

    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: passwordResetError } = await supabase.auth.updateUser({
                    password
                });
                if (passwordResetError) {
                    throw passwordResetError;
                }
                alert('パスワード変更が完了しました');
                navigate('/'); // 処理が成功した場合にのみページ遷移
            } 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}>
                            <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={() => navigate('/')}
                                    mx={2}
                                >トップページ</Button>
                            </Box>
                        </form>
                    </CardBody>
                </Card>
            </Flex>
        </>
    )
}
export default PasswordReset;

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

// /src/pages/PasswordReset.tsx
import { Box, Button, Card, CardBody, CardHeader, Flex, Heading, Input, InputGroup, InputLeftElement, Text } from "@chakra-ui/react";
import { useState } from "react";
import { RiLockPasswordFill } from "react-icons/ri";//アイコンインポート
import { useNavigate } from "react-router-dom";//アイコンインポート
import { supabase } from "../supabaseClient";//Supabase処理機能インポート

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

const PasswordReset: React.FC<Props> = ({ loading, setLoading }) => {
    const [password, setPassword] = useState('')
    const [passwordConf, setPasswordConf] = useState('')
    const [error, setError] = useState('')
    const navigate = useNavigate();

冒頭のインポートは、それぞれ必要なものをインポートしていきます。Chakra UIの利用コンポーネント、useState、アイコンの他、React Routerの機能であるuseNavigate、個別で作成した、Supabase接続処理用のsupabaseClientをインポートします。
type設定については、loading, setLoading をApp.tsxよりPropsで受け取りますので、その型定義です。
コンポーネント関数の定義は、先のtypeの箇所で設定した型定義Propsを定義します。React.FC<Props>です。

state関連は、パスワード用のpassword、パスワード確認用のpasswordConf、そしてerrorはApp.tsxから受け取るのではなく、(errorのステート管理が複雑になるので)ローカルのものとして定義してます。
そして、const navigate として、React RouterのuseNavigateを使用しています。

次にSupabaseのパスワードリセット処理についてです。

// /src/pages/PasswordReset.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: passwordResetError } = await supabase.auth.updateUser({
                    password
                });
                if (passwordResetError) {
                    throw passwordResetError;
                }
                alert('パスワード変更が完了しました');
                navigate('/'); // 処理が成功した場合にのみページ遷移
            } catch (error) {
                setError('パスワード変更に失敗しました。')
            } finally {
                setLoading(false);
            }
        }
    };

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

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

処理に成功すれば、’パスワード変更が完了しました’とアラートを表示、次に、navigate(‘/’);でルートURL(/)に飛ばしています。

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

続いてJSXの箇所です。

 // /src/pages/PasswordReset.tsx
       <>
            <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}>
                            <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>
                                <Button
                                    colorScheme='gray'
                                    onClick={() => navigate('/')}
                                    mx={2}
                                >トップページ</Button>
                            </Box>
                        </form>
                    </CardBody>
                </Card>
            </Flex>
        </>

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

6.3 App.tsxの変更

パスワードリセット申請機能、パスワードリセット機能の作成が完了しましたので、これを機能するようにしていきます。パスワードリセット処理は、冒頭のアプリ構成イメージのうち、下図の通りとなります。

ログイン画面のメニュー(パスワードを忘れた方)から、パスワードリセット申請を呼び出しています。ログインコンポーネント、Login.tsxにこのメニュー設定(navigate設定)は「4.1 ログインコンポーネントの作成」にて既に作成していますので、機能するように、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";
import Login from "./pages/Login";
import SendReset from "./pages/SendReset";//SendReset.tsxのインポート追加
import PasswordReset from "./pages/PasswordReset";//PasswordReset.tsxのインポート追加

冒頭のインポートの箇所に、SendReset、PasswordResetのインポートを追加します。

// /src/App.tsx

    <Routes>
      <Route path='/' element={
        <Login
          email={email} setEmail={setEmail}
          loading={loading} setLoading={setLoading} />
      } />
      <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='/sendReset' element={
        <SendReset
          email={email} setEmail={setEmail} loading={loading} setLoading={setLoading} />
      } />
      <Route path='/passwordReset' element={
        <PasswordReset loading={loading} setLoading={setLoading} />} />
      {/*ルート設定追加、ここまで*/}

そしてJSXの箇所に、SendReset、PasswordResetのルート設定を追加します。
SendResetは、パス、/sendResetで、Propsは email, setEmail, loading, setLoadingを渡します。
PasswordResetは、パス、/passwordResetで、Propsは 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";
import Login from "./pages/Login";
import SendReset from "./pages/SendReset";//SendReset.tsxのインポート追加
import PasswordReset from "./pages/PasswordReset";//PasswordReset.tsxのインポート追加

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

  // 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) {
      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='/' element={
        <Login
          email={email} setEmail={setEmail}
          loading={loading} setLoading={setLoading} />
      } />
      <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='/sendReset' element={
        <SendReset
          email={email} setEmail={setEmail} loading={loading} setLoading={setLoading} />
      } />
      <Route path='/passwordReset' element={
        <PasswordReset 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

6.4 パスワードリセットの確認

一連のパスワードリセット機能の開発が完了しましたので、動作を確認してみましょう。
画面遷移は、6.3 App.tsxの変更に掲載した図のイメージです。
ログイン中であれば、Homeコンポーネントのログアウトボタンからログアウトします。
http://localhost:5173/ にアクセスし、ログイン画面を表示します。
「パスワードを忘れた方」をクリックすると、パスワードリセット申請の画面に遷移しますので、そこで、リセットするメールアドレスを入力し、「リセット申請する」をクリックします。

入力したメールアドレス宛に、SupabaseよりリセットURLの案内メールが送付されてきますので、リンクをクリックします。

リンクをクリックすると、ブラウザで、/passwordResetの画面が表示されますので、その画面で新しいパスワードを入力して、「パスワード登録」をクリックします。
「パスワード変更が完了しました」とアラートが表示され、ログイン画面からHome画面に遷移すれば成功です。

7. GitHub、Firebase連携

一通り、コード作成が完了しましたので、リポジトリ作成とGitHubへのコミット(push)、そしてFirebaseとGitHub連携を行い、GitHubプッシュ時にFirebaseに自動デプロイされるようにします。
この一連の作業は、過去投稿した下記記事に詳細を記載してますので、ご参照ください。プロジェクト名等を読み替えていただくだけです。

なお、注意点ですが、Firebaseにデプロイした場合、React Routerは、リロードした場合にパス設定がうまく動かず、404エラーとなります(/homeでリロードした場合等)。
これを回避するためには、下記記事が参考になります。

具体的にはプロジェクト直下にある、firebase.json に以下内容を追加します。

// /firebase.json
{
  "hosting": {
    "public": "dist",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ]
    /*追加ここから*/
    ,
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
    /*追加ここまで*/
  }
}
上記は、TypeScriptのコメントアウト形式で追加内容を明示してますが、jsonファイルはコメントは記載出来ないのでご注意ください(コメントの箇所は省いてください)。

その他の注意点ですが、

  • リポジトリは、Privateをおすすめします。.envの中にSupabase接続の機密情報が含まれるためです。
  • .gitgnoreファイルにGitHub同期除外対象として、.envを定義する事も可能ですが、この場合は、GitHubでのビルド、Firebaseでのデプロイはうまく行きませんので(.env情報が無いため)、ご注意ください。
    一見、ビルド及びデプロイは成功しますが、アプリが動きません(Supabase接続情報が見つからないとエラーになります)
  • SupabaseのサイトURL、リダイレクトURLの変更が必要となります。
    SupabaseのAuthenticationを選択し、URL ConfigurationのSite URL, Redirect URLsの箇所をlocalhostから、FirebaseのURL名に変更しましょう。

Firebaseでデプロイして、ドメインの箇所からリリースされたアプリケーションを確認してみましょう。アプリが表示されれば公開完了です!

Supabaseユーザー認証実装、これで完了です。

No responses yet

コメントを残す

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

AD




TWITTER


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