Chapter 10

Auth:メールアドレスとパスワード変更

masalib
masalib
2020.12.26に更新

概要

本来ありえないのですが、パスワードが流出したみたいな事(笑)があったら必要な機能なので作ります。ついでにメールアドレスの変更も実装します。ガラゲー時代と違ってあまり変更がないのであまり需要がないかと思います。(Firebaseのコンソールで直接メールアドレスを変更できないので作っておいて損はないです)

メールアドレス変更とは

現在使用しているメールアドレスから違うメールアドレスに変更する処理になります。変更をするとメールアドレス有効化のフラグ(emailVerified)が「false」になります。また変更前のアドレスに変更された旨のメールが送信されます。そのメールに記載されているキャンセルリンクを押すとメールアドレスの変更がキャンセルされます。

パスワード変更とは

userがログインしている状態でパスワードを変更する処理になります。ログインチェック(Firebase側)が厳密になります。

同時アクセスの制御について

普通はありえないのですが2台同時にログインしてメールアドレス変更をすると
先にログインした端末側では処理が失敗します。
(エラーコードはauth/requires-recent-loginです。)

メールアドレス変更とパスワード変更処理

パスワードを忘れた処理と同様にcontextに処理をつくってその関数を共有させます。

プログラム全文(長文なので注意)
/src/contexts/AuthContext.js
import React, { useContext, useState, useEffect } from "react";
import { auth } from "../firebase";

const AuthContext = React.createContext();

export function useAuth() {
  return useContext(AuthContext);
}

export function AuthProvider({ children }) {
  const [currentUser, setCurrentUser] = useState();
  const [loading, setLoading] = useState(true);

  function signup(email, password) {
    return auth.createUserWithEmailAndPassword(email, password);
  }
  function login(email, password) {
    return auth.signInWithEmailAndPassword(email, password);
  }
  function logout() {
    return auth.signOut();
  }

  function resetPassword(email) {
    // .env use case      url: process.env.REACT_APP_MAIL_URL + '?email=' + email,
    // local dev case     url: "http://localhost:3000/?email=" + email,
    // product case     url: "https://you-domain/?email=' + email,
    const actionCodeSettings = {
      url: "https://sszxu.csb.app/"
    };
    //return auth.sendPasswordResetEmail(email, actionCodeSettings);
    return auth.sendPasswordResetEmail(email, actionCodeSettings);
  }

   function sendEmailVerification() {
     // .env use case      url: process.env.REACT_APP_MAIL_URL + 'dashboard'
     // local dev case     url: "http://localhost:3000/dashboard"
     // product case     url: "https://you-domain/dashboard'
     const actionCodeSettings = {
       url: "https://sszxu.csb.app/dashboard"
     };
     return currentUser.sendEmailVerification(actionCodeSettings);
   }
+ function updatePassword(password) {
+   return currentUser.updatePassword(password)
+ }
+ function updateEmail(email) {
+   return currentUser.updateEmail(email)
+ }

  const value = {
    currentUser,
    signup,
    login,
    logout,
    resetPassword,
    sendEmailVerification,
+     updatePassword,
+     updateEmail


};

  useEffect(() => {
    // Firebase Authのメソッド。ログイン状態が変化すると呼び出される
    auth.onAuthStateChanged((user) => {
      setCurrentUser(user);
      setLoading(false);
    });
  }, []);

  return (
    <AuthContext.Provider value={value}>
      {!loading && children}
    </AuthContext.Provider>
  );
}

プロフィール変更画面作成

このソースはSingUp処理をもとに作っています。cardではなく通常のフォームです。
SignUPはポップアップとかでも使えるようにしたかったのでcardにしています。

プログラム全文(長文なので注意)
/src/components/UpdateProfile.js
import React, { useState, useReducer, useEffect } from "react";
import { useForm } from "react-hook-form";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import { Typography, Paper, Button, TextField } from "@material-ui/core";

import { useAuth } from "../contexts/AuthContext";
import { Link, useHistory } from "react-router-dom";

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    container: {
      padding: 16,
      margin: "auto",
      maxWidth: 480
    },
    signupBtn: {
      marginTop: theme.spacing(2),
      flexGrow: 1,
      color: "primary"
    }
  })
);

//state type
type State = {
  email: string,
  password: string,
  passwordconfirm: string,
  isButtonDisabled: boolean,
  helperText: string,
  isError: boolean
};

const initialState: State = {
  email: "",
  password: "",
  passwordconfirm: "",
  isButtonDisabled: true,
  helperText: "",
  isError: false
};

type Action =
  | { type: "setEmail", payload: string }
  | { type: "setPassword", payload: string }
  | { type: "setPasswordConfirm", payload: string }
  | { type: "setIsButtonDisabled", payload: boolean }
  | { type: "signupSuccess", payload: string }
  | { type: "signupFailed", payload: string }
  | { type: "setIsError", payload: boolean };

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case "setEmail":
      return {
        ...state,
        email: action.payload
      };
    case "setPassword":
      return {
        ...state,
        password: action.payload
      };
    case "setPasswordConfirm":
      return {
        ...state,
        passwordconfirm: action.payload
      };
    case "setIsButtonDisabled":
      return {
        ...state,
        isButtonDisabled: action.payload
      };
    case "signupSuccess":
      return {
        ...state,
        helperText: action.payload,
        isError: false
      };
    case "signupFailed":
      return {
        ...state,
        helperText: action.payload,
        isError: true
      };
    case "setIsError":
      return {
        ...state,
        isError: action.payload
      };
    default:
      return state;
  }
};

const UpdateProfile = () => {
  const { currentUser, updatePassword, updateEmail } = useAuth();
  const classes = useStyles();
  //const email = currentUser.email
  const [state, dispatch] = useReducer(reducer, {
    ...initialState,
    email: currentUser.email
  });

  const [error, setError] = useState("");
  const [successMessage, setSuccessMessage] = useState("");
  const { register, handleSubmit, errors, formState } = useForm();
  const history = useHistory();

  useEffect(() => {
    if (state.password.trim() !== state.passwordconfirm.trim()) {
      setError("");
      dispatch({
        type: "setIsButtonDisabled",
        payload: true
      });
    } else if (state.email.trim()) {
      setError("");
      dispatch({
        type: "setIsButtonDisabled",
        payload: false
      });
    } else {
      setError("");
      dispatch({
        type: "setIsButtonDisabled",
        payload: true
      });
    }
    if (state.email !== currentUser.email && state.password) {
      setError("メールアドレスとパスワードを同時に変更する事はできません");
      dispatch({
        type: "setIsButtonDisabled",
        payload: true
      });
    }
  }, [state.email, state.password, state.passwordconfirm, currentUser.email]);

  async function handleUpdateProfile(data) {
    //react-hook-formを導入したためevent -> dataに変更
    //event.preventDefault()      //react-hook-formを導入したため削除

    setError("");
    setSuccessMessage("");
    //sing up ボタンの無効化
    dispatch({
      type: "setIsButtonDisabled",
      payload: true
    });

    //処理の初期化
    const promises = [];

    //更新処理をセット
    if (state.password) {
      console.log("updatePassword");
      promises.push(updatePassword(state.password));
    }
    if (state.email !== currentUser.email) {
      console.log("updateEmail");
      promises.push(updateEmail(state.email));
    }

    Promise.all(promises)
      .then(() => {
        setSuccessMessage(
          "プロフィールを更新しました。ダッシュボードにリダレクトします"
        );
        //ボタンの有効化
        dispatch({
          type: "setIsButtonDisabled",
          payload: false
        });
        //history.push("/")
        setTimeout(function () {
          console.log("リダレクト処理");
          history.push("/dashboard");
        }, 2000);
      })
      .catch((e) => {
        console.log(e);

        switch (e.code) {
          case "auth/network-request-failed":
            setError(
              "通信がエラーになったのか、またはタイムアウトになりました。通信環境がいい所で再度やり直してください。"
            );
            break;
          case "auth/weak-password":
            setError("パスワードが正しくないです。");
            break;
          case "auth/invalid-email":
            setError("メールアドレスが正しくないです。");
            break;
          case "auth/requires-recent-login":
            setError(
              "別の端末でログインしているか、セッションが切れたので再度、ログインしてください。(ログインページにリダイレクトします)"
            );
            setTimeout(function () {
              console.log("リダレクト処理");
              history.push("/login");
            }, 3000);
            break;
          case "auth/user-disabled":
            setError("入力されたメールアドレスは無効(BAN)になっています。");
            break;
          default:
            //想定外
            setError(
              "失敗しました。通信環境がいい所で再度やり直してください。"
            );
        }

        //ボタンの有効化
        dispatch({
          type: "setIsButtonDisabled",
          payload: false
        });
      })
      .finally(() => {
        dispatch({
          type: "setIsButtonDisabled",
          payload: false
        });
      });
  }

  const handleEmailChange: React.ChangeEventHandler<HTMLInputElement> = (
    event
  ) => {
    dispatch({
      type: "setEmail",
      payload: event.target.value
    });
  };

  const handlePasswordChange: React.ChangeEventHandler<HTMLInputElement> = (
    event
  ) => {
    dispatch({
      type: "setPassword",
      payload: event.target.value
    });
  };

  const handlePasswordConfirmChange: React.ChangeEventHandler<HTMLInputElement> = (
    event
  ) => {
    dispatch({
      type: "setPasswordConfirm",
      payload: event.target.value
    });
  };

  formState.isSubmitted = false; //一回submittedになるとレンダリングが遅くなり、変な動きするので強制的にfalseにする

  return (
    <div className={classes.container}>
      <Typography variant="h4" align="center" component="h1" gutterBottom>
        プロフィールの更新
      </Typography>
      <form noValidate autoComplete="off">
        <Paper style={{ padding: 16 }}>
          {error && <div style={{ color: "red" }}>{error}</div>}
          {successMessage && <div variant="danger">{successMessage}</div>}
          <TextField
            error={state.isError}
            fullWidth
            id="email"
            name="email"
            type="email"
            label="Email"
            //placeholder="Email"
            margin="normal"
            value={state.email}
            onChange={handleEmailChange}
            inputRef={register({
              pattern: /^[A-Za-z0-9]{1}[A-Za-z0-9_.-]*@{1}[A-Za-z0-9_.-]{1,}\.[A-Za-z0-9]{1,}$/
            })}
          />
          {errors.email?.type === "pattern" && (
            <div style={{ color: "red" }}>
              メールアドレスの形式で入力されていません
            </div>
          )}
          <TextField
            error={state.isError}
            fullWidth
            id="password"
            name="password"
            type="password"
            label="Password"
            placeholder="Password"
            margin="normal"
            onChange={handlePasswordChange}
            inputRef={register({ minLength: 6 })}
          />
          {errors.password?.type === "minLength" && (
            <div style={{ color: "red" }}>
              パスワードは6文字以上で入力してください
            </div>
          )}
          <TextField
            error={state.isError}
            fullWidth
            id="password-confirm"
            name="password-confirm"
            type="password"
            label="Password-confirm"
            placeholder="Password-confirm"
            margin="normal"
            onChange={handlePasswordConfirmChange}
            inputRef={register}
          />
          <Button
            variant="contained"
            size="large"
            fullWidth
            color="primary"
            className={classes.signupBtn}
            onClick={handleSubmit(handleUpdateProfile)}
            disabled={state.isButtonDisabled}
          >
            プロフィールを更新
          </Button>
        </Paper>
        <Typography paragraph>
          ※表示名とアバター以外は公表される事はありません
        </Typography>
        <Typography paragraph>
          <Link to="/dashboard">dashboard</Link>に戻る
        </Typography>
      </form>
    </div>
  );
};

export default UpdateProfile;

ソース解説

今までは単純な処理だったの1つの関数で済んだのですが
プロフィール変更などは複数の関数を実行させる必要があります。
そのため、promisesで対応します

ちなみに1ヶ月前までは同時実行にできたのですが、なぜか同時にできなくなったので
まとめてやる必要はないです。今後のデータ更新などもあるのでこの形でいきます

//処理の初期化
const promises = []

//更新処理をセット
if (state.email !== currentUser.email) {
    console.log("updateEmail")
    promises.push(updateEmail(state.email))
}
if (state.password) {
    console.log("updatePassword")
    promises.push(updatePassword(state.password))
}

Promise.all(promises)
.then(() => {

わからない事

お恥ずかしいのですが「react-hook-form」のsubmittedがtrueになると
無駄なレンダリング処理が走って処理が遅延します。
formState.isSubmitted = falseにしました。

たぶん1文字を入力するごとにreact-hook-formのバリデーションが走っていると思う。
useMemoとかで対応するのが正しいのかな

ダッシュボードにプロフィールのリンクを追加

最終的にはユーザーのプロフィールの画面などで有効化のボタンを配置したいのですが、手っ取り早くダッシュボードに設置しました

/src/components/Dashboard.js
import React, { useState } from "react";
import { Link, useHistory } from "react-router-dom";
import Button from "@material-ui/core/Button";
import { useAuth } from "../contexts/AuthContext";

const Dashboard = () => {
  const { currentUser, logout } = useAuth();
  const history = useHistory();
  const [error, setError] = useState("");

  async function handleLogout() {
    setError("");

    try {
      await logout();
      history.push("/");
    } catch {
      setError("Failed to log out");
    }
  }

  return (
    <div>
      Dashboard:
      {error && <div style={{ color: "red" }}>{error}</div>}
      <div>
        <strong>Email:</strong> {currentUser.email}
      </div>
      <div>
        <strong>ハンドル名:</strong> {currentUser.displayName}
      </div>
      <h2>
        <Link to="/login">Login</Link>
      </h2>
      <h2>
        <Link to="/signup">signup</Link>
      </h2>
+      <h2>
+        <Link to="/updateprofile">プロフィール変更</Link>
+      </h2>
     
      <Button color="primary" onClick={handleLogout}>
        Logout
      </Button>
    </div>
  );
};
export default Dashboard;

プロフィールをルーティングに追加

追加したプロフィールをルーティングに追加します。もちろんログイン認証はつけます

/src/components/App.js
import React from "react";
import Signup from "./Signup";
import Home from "./Home";
import Login from "./Login";
import Dashboard from "./Dashboard";
import NoEmailVerified from "./NoEmailVerified";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import { AuthProvider } from "../contexts/AuthContext";
import AuthFirebaseRoute from "./AuthFirebaseRoute"; //ログイン認証
import ForgotPassword from "./ForgotPassword";
+ import UpdateProfile from "./UpdateProfile";


export default function App() {
  return (
    <Router>
      <AuthProvider>
        <Switch>
          <Route path="/signup" component={Signup} />
          <Route path="/forgotPassword" component={ForgotPassword} />
          <Route exact path="/" component={Home} />

          <Route path="/login" component={Login} />
          <Route path="/noemailverified" component={NoEmailVerified} />

          <AuthFirebaseRoute path="/dashboard" component={Dashboard} />
+           <AuthFirebaseRoute path="/updateprofile" component={UpdateProfile} />
 
        </Switch>
      </AuthProvider>
    </Router>
  );
}

結果の画面

プロフィールの変更画面ができました。

終了時点のソース

  • 前回、メール有効化しないとNoEmailVerifiedにリダイレクトする処理を作ったのですが
    テストの時ははずしていた方が楽です。今回のソースではその部分はコメントアウトしているのでご了承してください