devise_token_authを用いたパスワードリセット機能の実装




### パスワードリセット機能の全体像の理解

https://qiita.com/fumi238000/items/048990b343661048f6f8

### 実装するコードの参考にした記事

https://zenn.dev/naok_1207/books/ba7243f1460b9e/viewer/4fbad2

### リクエスト失敗の対応
パスワードリセット画面でなぜかPUTリクエストが成功しないのでdevise_token_authのupdateアクションの中身を確認。
リクエストの送信にはaccess-token,client,uidをヘッダーに持たせる必要があることを確認。
加えて、この時点でreset_password_tokenをupdateアクションに渡せていなかったことにも気づいた。

https://github.com/lynndylanhurley/devise_token_auth/blob/master/app/controllers/devise_token_auth/passwords_controller.rb

### 最終的なフォームとフックのコード
リセットパスワードフォーム
```
import { type FC } from 'react';
import useResetPassword from '../../features/auth/hooks/useResetPassword';
import Button from '../atoms/button';
import InputField from '../atoms/inputfield';

const ResetPasswordForm: FC = () => {
  const {
    password,
    setPassword,
    passwordConfirmation,
    setPasswordConfirmation,
    errorMessage,
    handleResetPassword,
  } = useResetPassword();

  const onSubmit = async (event: React.FormEvent) => {
    event.preventDefault();
    await handleResetPassword();
  };

  return (
    <form onSubmit={onSubmit}>
      <InputField
        type="password"
        placeholder="新しいパスワード"
        value={password}
        onChange={(e) => {
          setPassword(e.target.value);
        }}
      />
      <InputField
        type="password"
        placeholder="パスワード確認"
        value={passwordConfirmation}
        onChange={(e) => {
          setPasswordConfirmation(e.target.value);
        }}
      />
      <Button type="submit" label="パスワードをリセット" />
      {errorMessage !== null && errorMessage !== '' && (
        <p className="error-message">{errorMessage}</p>
      )}
    </form>
  );
};

export default ResetPasswordForm;

```

### リセットパスワードフック
アクセストークンなどを持っているとルート保護をかけているページにもアクセスできてしまうので、reset_password_tokenを保持している時はアクセスできないようにしている。
そのため、パスワードリセットが成功したらそれらのトークンなどを削除する処理を加えている。
```
import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { PASSWORD_RESET_REQUEST_URL } from 'urls';

// ローカルストレージからトークンを取得し、ヘッダーに追加する関数
const getAuthHeaders = () => {
  return {
    'Content-Type': 'application/json',
    'access-token': localStorage.getItem('access-token') ?? '',
    client: localStorage.getItem('client') ?? '',
    uid: localStorage.getItem('uid') ?? '',
  };
};

const useResetPassword = (): {
  password: string;
  setPassword: (password: string) => void;
  passwordConfirmation: string;
  setPasswordConfirmation: (passwordConfirmation: string) => void;
  errorMessage: string | null;
  handleResetPassword: () => Promise<void>;
} => {
  const [searchParams] = useSearchParams();
  const [password, setPassword] = useState('');
  const [passwordConfirmation, setPasswordConfirmation] = useState('');
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  const navigate = useNavigate();

  useEffect(() => {
    // 認証メールからリダイレクトした際にURLからトークンなどの情報を取得し、ローカルストレージに保存
    const accessToken = searchParams.get('access-token');
    const client = searchParams.get('client');
    const uid = searchParams.get('uid');
    const resetPasswordToken = searchParams.get('token');

    if (
      accessToken !== null &&
      client !== null &&
      uid !== null &&
      resetPasswordToken !== null
    ) {
      localStorage.setItem('access-token', accessToken);
      localStorage.setItem('client', client);
      localStorage.setItem('uid', uid);
      localStorage.setItem('reset_password_token', resetPasswordToken);
    }
  }, [searchParams]);

  // パスワードリセット処理
  const handleResetPassword = async () => {
    setErrorMessage(null);

    try {
      const resetPasswordToken =
        localStorage.getItem('reset_password_token') ?? '';
      const response = await fetch(PASSWORD_RESET_REQUEST_URL, {
        method: 'PUT',
        headers: {
          ...getAuthHeaders(),
        },
        body: JSON.stringify({
          password,
          password_confirmation: passwordConfirmation,
          reset_password_token: resetPasswordToken,
        }),
      });

      if (response.ok) {
        localStorage.removeItem('reset_password_token');
        localStorage.removeItem('access-token');
        localStorage.removeItem('client');
        localStorage.removeItem('uid');
        navigate('/reset-password-complete'); // リセット成功時の処理
      } else {
        setErrorMessage(
          'パスワードリセットに失敗しました。もう一度お試しください。',
        );
      }
    } catch (error) {
      setErrorMessage('エラーが発生しました。もう一度お試しください。');
    }
  };

  return {
    password,
    setPassword,
    passwordConfirmation,
    setPasswordConfirmation,
    errorMessage,
    handleResetPassword,
  };
};

export default useResetPassword;

```

### ルート保護
パスワードリセットをするまでルート保護にひっかかる設定を追加。
```
import { useContext, type ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { AuthContext } from '../../../App';

interface ProtectedRouteProps {
  children: ReactNode;
}

const useProtectedRoute = ({
  children,
}: ProtectedRouteProps): ReactNode | null => {
  const { loading, isSignedIn } = useContext(AuthContext);
  const resetPasswordToken = localStorage.getItem('reset_password_token');

  if (resetPasswordToken !== null) {
    return <Navigate to="/reset-password" replace />;
  }

  if (!loading) {
    if (isSignedIn) {
      return children;
    } else {
      return <Navigate to="/" replace />;
    }
  } else {
    return null; // ローディング中は何も表示しない
  }
};

export default useProtectedRoute;

```