参考サイト
ディレクトリ構成
.
└── laravel-Breeze-react-spa
├── BACK_END (laravel)
└── FRONT_END (React)
Laravel10のインストール
composer create-project laravel/laravel:^10.0 BACK_END
cd BACK_END
sqliteの作成
touch database/database.sqlite
DB_CONNECTION=sqlite
#DB_CONNECTION=mysql
#DB_HOST=127.0.0.1
#DB_PORT=3306
#DB_DATABASE=homestead
#DB_USERNAME=homestead
#DB_PASSWORD=secret
テーブルとユーザー(ダミー)の作成
public function run(): void
{
// \App\Models\User::factory(10)->create();
// passwordはデフォルトのpasswordになる
\App\Models\User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
php artisan migrate --seed
Laravel Breeze APIのインストール
- ユーザーの認証情報をサーバー側で管理する、セッションベースの認証方法
- トークンベースの認証は、各リクエストに認証トークンを含めることで、ユーザーを認証する方法
- ユーザー登録、ログイン、ログアウトなどの基本的な認証プロセスがAPI経由で行えるようになります
- 必要な認証関連のルーティング、コントローラー、ミドルウェアがセットアップされます
- フロントエンドのビューファイルは不要なので、それらはインストールされません
Laravel Breezeのインストール
- Composerを使用してBreezeパッケージをプロジェクトに追加
- その後、php artisan breeze:install api コマンドを実行して、API用の認証機能をセットアップ
composer require laravel/breeze --dev
👇を実行するとフロントエンドのためのファイル package.json
とか vite.config.js
は削除される。
php artisan breeze:install api
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('index');
});
// ↓web.php に追加される。
require __DIR__.'/auth.php';
routes\auth.phpが作成される
<?php
use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\EmailVerificationNotificationController;
use App\Http\Controllers\Auth\NewPasswordController;
use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route;
// ユーザー登録のためのルート
Route::post('/register', [RegisteredUserController::class, 'store'])
->middleware('guest') // ゲストのみアクセス可能
->name('register'); // ルート名は'register'
// 認証済みでアクセスした場合、302でリダイレクトされる。
// ログインのためのルート
Route::post('/login', [AuthenticatedSessionController::class, 'store'])
->middleware('guest')
->name('login');
// ユーザーがパスワードを忘れたときにパスワードリセットリンクをリクエストするためのものです。
// このルートにメールアドレスをpostしてpostしたメールアドレスにパスワードリセットリンクが送信されます。
Route::post('/forgot-password', [PasswordResetLinkController::class, 'store'])
->middleware('guest')
->name('password.email');
// ユーザーがパスワードリセットリンクを受け取った後、新しいパスワードを設定するために使用されます。
// パスワードリセットリンクからtokenを取得して、emailとnewPasswordをこのルートにポストすると
// passwordが新しくDBにセットされるルート
Route::post('/reset-password', [NewPasswordController::class, 'store'])
->middleware('guest')
->name('password.store');
// メールアドレスの確認のためのルート
Route::get('/verify-email/{id}/{hash}', VerifyEmailController::class)
// 認証済み、署名済み、制限付きアクセス
->middleware(['auth', 'signed', 'throttle:6,1'])
->name('verification.verify');
// 署名済み: URLの{id}{hash}とURLの制限時間が検証され、{id}{hash}と時間が無効である場合は403エラーが返されます
// 制限付きアクセス: 'throttle:6,1' は「1分間に最大6回までとリミットに達した場合の待機時間1分」という制限
// 'throttle:10,5' 「1分間に最大10回までとリミットに達した場合の待機時間5分」という制限
// 上のルートのリンクを作成してメールに送るルート
Route::post('/email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
->middleware(['auth', 'throttle:6,1']) // 認証済み、制限付きアクセス
->name('verification.send');
// ログアウトのためのルート
Route::post('/logout', [AuthenticatedSessionController::class, 'destroy'])
->middleware('auth') // 認証済みのみアクセス可能
->name('logout');
不要なルートの削除
<?php
// コメントアウト
// Route::middleware(['auth:sanctum'])->get('/user', function (Request $request) {
// return $request->user();
// });
ルートリストの確認
php artisan route:list
// ホームページを表示するためのルート
GET|HEAD /
// laravelのデバックのためのルート
POST _ignition/execute-solution
// laravelのデバックのためのルート
GET|HEAD _ignition/health-check
// laravelのデバックのためのルート
POST _ignition/update-config
// メールアドレス確認通知を再送するためのルート
POST email/verification-notification
// postしたメールアドレスにパスワードリセットリンク付きメールを送信
POST forgot-password
// ユーザーログインを処理するためのルート
POST login
// ユーザーログアウトを処理するためのルート
POST logout
// ユーザー登録を処理するためのルート
POST register
// パスワードリセットリンクを受け取った後、新しいパスワードをpostして更新するルート
POST reset-password
// CSRFトークンのクッキーを取得するためのルート
GET|HEAD sanctum/csrf-cookie
// メールアドレスの確認を処理するためのルート
GET|HEAD verify-email/{id}/{hash}
認証済みかそうでないかのチェックをするルートの作成
認証済みのユーザーを取得するルートの作成
//+
Route::get('/is-login', fn() => Auth::check())
->name('is-login');
//+
Route::get('/get-user', fn() => Auth::User())
->middleware('auth')->name('get-user');
login処理の修正
login成功時、jsonでuser情報を返すように修正する
//+
use Illuminate\Http\JsonResponse;
// 戻り値の型を JsonResponse に修正
// public function store(LoginRequest $request): Response
public function store(LoginRequest $request): JsonResponse
{
// 認証処理 失敗すればエラーが飛ぶ
$request->authenticate();
// session_id の再取得
$request->session()->regenerate();
// -
// return response()->noContent();
//+
// Auth ファサードを使用して現在認証されているユーザーを取得
$user = Auth::user();
// ユーザー情報をJSON形式で返す
return response()->json([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'is_login' => true
]);
}
認証済みによる302リダイレクト処理をjsonをレスポンスするように修正する
ログイン中にログイン認証を再度求めると、ミドルウェアの['guest']によって302リダイレクトされる。
<?php
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class RedirectIfAuthenticated
{
/**
* @param \Illuminate\Http\Request $request 受信したリクエスト
* @param \Closure $next 次に呼び出すミドルウェア
* @param string ...$guards 使用する認証ガード(複数指定可能)
* @return \Symfony\Component\HttpFoundation\Response 返すレスポンス
*/
public function handle(Request $request, Closure $next, string ...$guards): Response
{
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
// ユーザーが認証中かどうか?
if (Auth::guard($guard)->check()) {
//+
if ($request->expectsJson()) {
// リクエストがJSONを期待している場合、認証済みであることを示すJSONレスポンスを返します。
return response()->json(['message' => 'You are already authenticated.'], 200);
}
// ユーザーが認証済みでJSONを期待していない場合、ホームページにリダイレクトします。
return redirect(RouteServiceProvider::HOME);
}
}
// ユーザーが認証されていない場合、リクエストを次のミドルウェアに進めます。
return $next($request);
}
}
CORS(クロスオリジンリクエスト)のサーバー側の設定
laravelのBreeze API認証ではセッションベースの認証のため、
cookie送信が必須条件
sactum-csrf-cookieにゲットメソッドでアクセスするときも、
register,login,logoutするときも常にcookieを送信する必要がある
かつ、クロスオリジン間での通信のため
サーバー側でもcors設定が必要
<?php
return [
// CORSポリシーが適用されるパス。'*'はすべてのパスに適用されることを意味します。
'paths' => ['*'],
// 'paths' => ['/sanctum-csrf-cookie','/login'],
// CORSリクエストで許可されるHTTPメソッド。
'allowed_methods' => ['*'],
// 😤 必須
// CORSリクエストを許可するオリジン。
// クロスオリジンの場合 * は 不可
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
// 許可されるオリジンのパターンを正規表現で指定。ここではパターンは指定されていません。
'allowed_origins_patterns' => [],
// リクエストで許可されるHTTPヘッダー。
'allowed_headers' => ['*'],
// レスポンスでクライアントに公開されるヘッダー。
// ここでは公開されるヘッダーは指定されていません。
'exposed_headers' => [],
// ブラウザがCORSレスポンスをキャッシュする最大時間(秒)。
// `0`はキャッシュしないことを意味します。
'max_age' => 0,
// 😤 必須 true
// クロスオリジンリクエストで認証情報(クッキーなど)を送信するかどうか。
// `true`は認証情報を送信することを許可します。
'supports_credentials' => true,
];
FRONTEND_URL=http://localhost:3000
laravelの起動 デフォルトでは localhost:8000
php artisan serve
Reactのインストール
cd ../
npm create vite@4
√ Project name: FRONT_END
√ Package name: react-spa
√ Select a framework: » React
√ Select a variant: » JavaScript
- dom,router,queryのインストール
cd FRONT_END; npm install react react-dom react-router-dom @tanstack/react-query
portを3000に修正
"scripts": {
"dev": "vite --port 3000",
},
reactの起動
npm run dev
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
// コメントアウト
// import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
Reactのファイル名について
コンポーネントのファイル名やクラス名に関するものでパスカルケース(Pascal Case)
API関数などのユーティリティファイルにはキャメルケース(camelCase)が一般的に使用される。
React createBrowserRouterの設定
createBrowserRouterを使用すると、静的に定義されたルートセットを持つルーターをReactツリーの外部で作成できます。これにより、フェッチとレンダリングの分離が可能になる。
import { NavLink, Outlet, createBrowserRouter } from 'react-router-dom'
import LoginPage from './pages/LoginPage'
import DashboardPage from './pages/DashboardPage'
// path,element,childrenがルートセットになる
export const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{
path: 'login',
element: <LoginPage />
}, {
path: '/',
element: <DashboardPage />
}
]
}
])
function Layout() {
return (
<div>
<Navbar />
<Outlet /> {/* childrenのコンポーネントが渡る */}
</div>
)
};
function Navbar() {
return (
<header>
{/* NaviLinkは createBrowserRouterの中で設定する必要がある*/}
<nav>
<ul>
<li>
<NavLink to="/">DashBoardPage</NavLink>
</li>
<li>
<NavLink to="/login">LoginPage</NavLink>
</li>
</ul>
</nav>
</header>
);
}
作成したrouter.jsはRouterProvider
で提供できる。
import { RouterProvider } from 'react-router-dom'
import { router } from './router'
const App = () => {
return (
<>
{/* <Navibar /> */} {/* これは機能しないし、*/}
<RouterProvider router={router} /> {/* コンポーネントはこの中にある */}
{/* web.phpみたいな ルート(path)があって、コントローラー(loader)があって
特定のビュー(element)を戻す */}
{/* コンポーネントはブレードファイルみたいな */}
{/* ただ、レイアウトまでルートで記述するので抵抗がある */}
</>
)
}
export default App;
const DashboardPage = () => {
return (
<div>
<h1>ダッシュボード</h1>
</div>
)
}
export default DashboardPage
const LoginPage = () => {
return (
<div>
<h1>ログイン</h1>
</div>
)
}
export default LoginPage
ルーティングの確認ができればOK
CSRFとクロスオリジンリクエストによるcookieと信用情報の送信のためのクライアント側からの対策
なぜcookieの送信が必要なのか
laravel Breeze Api は セッションベースの認証をしているため、認証関連がサーバーで使用される場合、
クライアント側からサーバー側に認証情報を含むcookieを送信することで、
サーバー側で認証中のユーザーかそうでないかを判定する
tokenの取得と送信について
-
csrf-tokenの送信が必要な理由
-
laravelの
web.php
でルートを設定したため、
web
ミドルウェアでgetメソッド
以外のメソッドで送信する場合
ヘッダーかボディーにX-CSRF-TOKEN
を一緒に送信する必要がある。ない場合リダイレクトされる。
よって
サーバーからcsrfトークンの値を取得する必要がある
-
-
cookieからtokenを取り出してヘッダーかボディーでサーバー側に送信する必要がある
- なぜなら、
/sanctum/csrf-cookie
ルートでtokenを取得するとき
サーバーからは tokenはcookieにセットされて レスポンスされるため
よって、
レスポンスされたcookieからtokenを取得して ヘッダーにセットする必要がある
- なぜなら、
今回はlocalhost:8000と3000のクロスオリジン間のcookieの通信する必要がある
通常、ブラウザのセキュリティポリシーは、異なるオリジン間でのリクエストに対してCookieや認証情報を送信しないように制限している。
しかし、
axiosの場合 withCredentials: trueを設定することで、クロスオリジンリクエストにCookieや認証ヘッダーを含めることができ、また、cookieのtokenを自動でヘッダーにセットしてくれる。
Fetch APIを使用する場合はcredentialsオプションをincludeに設定し、cookieからtokenを取得し、ヘッダーにセットする。
axiosで送信する場合
axiosはxsrfCookieName
プロパティの値を使用してクッキーからCSRFトークンを読み取る
axiosはxsrfHeaderName
プロパティの値を使用してaxios.config.headers
にCSRFトークンを自動セットしてくれる
import axios from 'axios';
const http = axios.create({
baseURL: 'http://localhost:8000',
headers: {
'Content-Type': 'application/json', // レスポンスはJSONを期待する設定
},
// クロスオリジン間の通信のためcookieと認証情報を送信する場合は明示する必要がある
withCredentials: true,
});
// axios1.6.0以上の場合 追加の設定が必要
// axiosからwithCredentials: true,だけでは
// 今回のようなクロスオリジン間の通信ではaxiosからcookieにアクセスできなくなった
const http2 = http.create({
// 公式には掲載されていない axiosでcookieにアクセスできるようになる
withXSRFToken: true
})
// axios1.6.0以上の場合 追加の設定が必要
// クロスオリジン間の時 axiosからcookieにアクセスできなくなったため
// 手動でヘッダーにのせる必要がある。
// スタックオーバーフローで掲載されているメジャーな方法
// http.interceptors.request.use(config => {
// const token = document.cookie.split('; ').find(row => row.startsWith('XSRF-TOKEN=')).split('=')[1];
// if (token) {
// config.headers['X-XSRF-TOKEN'] = decodeURIComponent(token);
// }
// return config;
// });
export const login = async ({ email, password }) => {
// CSRFトークンを取得
await http.get('/sanctum/csrf-cookie');
// ログインリクエストを送信(token(headers) + cookie + email(body) + password(body))
const { data } = await http2.post('/login', { email, password });
console.log(data);
return data;
};
fetchで送信する場合
// fetch APIを使用してHTTPリクエストを行うための設定
const baseUrl = 'http://localhost:8000';
fetch(`${baseUrl}/sanctum/csrf-cookie`, {
// withCredentials: trueに相当する設定
credentials: 'include' //cookieを送信
}).then((res) => {
fetch(`${baseUrl}/login`, {
method: 'POST',
credentials: 'include', // クッキーを含むリクエストを送信する設定
headers: {
'Content-Type': 'application/json', // レスポンスはJSONを期待する設定
// CSRFトークンを直接ヘッダーにセットして送信する必要がある。
// csrfTokenをcookieから取得するのは手間
'X-CSRF-TOKEN': csrfToken,
// 通常metaタグから取得する
// <meta name="csrf-token" content="{{ csrf_token() }}">
// 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
email: 'test@example.com', // ユーザーのメールアドレス
password: 'password' // ユーザーのパスワード
})
}).then((response) => {
return response.json(); // レスポンスのJSONを解析
}).then((data) => {
console.log(data); // レスポンスデータをコンソールに出力
});
});
結論
axosのインストール
npm i axios
ログインとログアウト機能の実装
import axios from 'axios';
const http = axios.create({
baseURL: 'http://localhost:8000',
headers: {
'Content-Type': 'application/json', // application/json を追加
},
//信用情報を含むcookieをサーバーに送信してくれる
withCredentials: true,
});
// axios1.6.0以上の場合 追加の設定が必要
// cookieにアクセスしてconfig.headersにx-xrf-tokenをセットしてくれる
const http2 = http.create({
// 公式には掲載されていない
withXSRFToken: true
})
// axios1.6.0以上の場合 axiosからcookieにはアクセスできなくなっている
// スタックオーバーフローでよく掲載されているメジャーな方法
// http.interceptors.request.use(config => {
// const token = document.cookie.split('; ').find(row => row.startsWith('XSRF-TOKEN=')).split('=')[1];
// if (token) {
// config.headers['X-XSRF-TOKEN'] = decodeURIComponent(token);
// }
// return config;
// });
export const isLogin = async ()=>{
//Route::get('/is-login', fn() => Auth::check())
// セッションベースの認証を利用するため http で送信する必要がある
const {data} = await http.get('is-login');
return data;
}
export const getUser = async ()=>{
// middleware('auth')があるためセッションベースの認証を利用するため
const {data} = await http.get('get-user');
return data;
}
// ログイン関数をエクスポート
export const login = async ({ email, password }) => {
// リクエストは、getメソッド
// token取得のためcookieをおくってリクエストする必要がある
await http.get('/sanctum/csrf-cookie');
// ログインリクエストはポストメソッドでリクエストするため、
// headerにtokenをつけて送信
const { data } = await http2.post('/login', { email, password });
console.log(data);
return data;
};
export const logout = async () => {
await http.get('/sanctum/csrf-cookie')
const { data } = await http2.post('/logout');
return data
}
ログイン、ログアウト機能の確認
import { useState } from 'react'
import * as api from '../api/authApi'
const LoginPage = () => {
const [loginUser, setLoginUser] = useState({});
const onLogin = async () => {
const user = await api.login({
email: 'test@example.com',
password: 'password',
})
setLoginUser(user);
}
const onLogout = async () => {
try{
await api.logout()
setLoginUser({});
}catch(error){
setLoginUser(error.response.data);
}
}
return (
<div>
<h1>ログインユーザー情報</h1>
<ul>
{Object.entries(loginUser).map(([key, value]) => {
return <li key={key}>{key}:{value}</li>
})}
</ul>
<button onClick={onLogin}>ログイン</button>
<button onClick={onLogout}>ログアウト</button>
</div>
)
}
export default LoginPage
ログイン、ログアウトしてユーザー情報を取得・削除できればOK
React Query
- メリット
- Reaqt Queryのメソッドから api を コールするだけで
- サーバーからデータを取得する
- 取得したデータをクライアント側でキャッシュし、再利用できる
- 状態管理: データの読み込み状態、エラー状態などを管理し、UIの更新を効率的に行える
- 設定に基づいてデータを自動的に再フェッチし、最新の状態を保つ自動更新機能が行える
- Reaqt Queryのメソッドから api を コールするだけで
React Queryの設定
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
// クエリのオプション
queries: {
// クエリが失敗した場合の再試行回数
retry: false, // デフォルトでは3回の再試行が行われますが、ここでは無効化しています
// ウィンドウがフォーカスされたときにクエリを再フェッチするかどうか
refetchOnWindowFocus: false, // デフォルトではtrueですが、ここでは無効化しています
// ネットワークがオンラインに戻ったときにクエリを再フェッチするかどうか
refetchOnReconnect: true, // デフォルトではtrueです
// ポーリング間隔(ミリ秒)
refetchInterval: false, // デフォルトでは無効ですが、特定の間隔でデータをポーリングすることができます
// キャッシュされたデータの新鮮さの期間(ミリ秒)
staleTime: 0, // デフォルトでは0です。データが古くなったと見なされるまでの時間を指定します
// クエリがアクティブでなくなった後にキャッシュから削除されるまでの時間(ミリ秒)
cacheTime: 1000 * 60 * 5, // デフォルトでは5分です
},
// ミューテーションのデフォルトオプション
mutations: {
// ミューテーションが失敗した場合の再試行回数
retry: false, // デフォルトでは無効です
},
},
});
// React RouterとReact Queryの必要なモジュールをインポートします
import { RouterProvider } from 'react-router-dom';
import { queryClient } from './queryClient';
import { QueryClientProvider } from '@tanstack/react-query';
import { router } from './router';
const App = () => {
return (
// QueryClientProviderでqueryClientを提供します
// アプリケーションのどこからでもReact Queryの機能を使用できる
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
)
}
export default App;
// これでReact Queryのフックを取得してプロパティやメソッドを利用して
// クエリの発行、キャッシュの取得更新削除ができるようになる。
const {data:todos} = useQuery({qeryKey,queryFn});
const {mutate:getTodo} = useMutation();
const queryClient = useQueryClient();
// 通常React Queryのフックからカスタムフックを作成して使用する
const {data:todos} = useTodos();
authApi + React Query のカスタムフックの作成
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as api from '../api/authApi';
// ログインユーザーのステータスを取得し、キャッシュする
export const useLoginUser = () => {
// api.isLogin関数でログインしているか true of false
const queryOptions = {
// // 初期データを設定する と基本queryFnは実行されなくなる
// initialData: undefined,
// queryを実行した時刻とかの時刻を設定 新鮮さの判断につかう
// デフォルトのundefinedは新鮮さはその他で判断する
// initialDataUpdatedAt: undefined,//Date::now();
// データが古くなったと見なされるまでの時間(ミリ秒)
// Infinityを設定すると、データは決して古くならず、自動的に背景更新されることはありません。
// つまり、一度フェッチしたデータは、新しいデータを取得するための再フェッチがトリガーされない限り、
// 常に「新鮮」と見なされます。
staleTime: Infinity,
// // キャッシュされたデータが削除されるまでの時間(ミリ秒)
// cacheTime: 5 * 60 * 1000,
// // ウィンドウがフォーカスされたときにデータを再フェッチするかどうか
refetchOnWindowFocus: false,
// // ネットワークが再接続されたときにデータを再フェッチするかどうか
// refetchOnReconnect: true,
// // クエリが失敗した場合の再試行回数
// retry: 3,
// // 再試行の遅延時間(ミリ秒)
// retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
// // queryFn を実行するかどうか false 実行しない
// enabled: true ,
};
// v5からオブジェクト形式で渡す必要がある
// return useQuery(['loginUserStatus'], api.getUser, queryOptions);
//optionはスプレッド構文で展開してわたす
return useQuery({queryKey:['loginUserStatus'],queryFn:api.getUser,...queryOptions}
);
// queryKey はキャシュデータを一意に管理する名前
// queryFnに api をセットする
};
// ログイン処理し、loginUserStatusに fetchしたデータをキャッシュする
export const useLogin = () => {
const queryClient = useQueryClient();
const mutationOptions = {
mutationFn: api.login, // api の 登録
// ミューテーションがエラーになった場合
onError: (error) => {
console.error('login error:', error);
},
// ミューテーションが成功した場合
onSuccess: (data) => {
console.log('login success:', data);
// React Query のキャッシュに保存されているデータが更新されたとき、
// そのデータを参照しているコンポーネントが最新の状態を反映するために再レンダリングされる
queryClient.setQueryData(['loginUserStatus'], data);
},
// 失敗時の再試行を行わない
// retry: false,
// ミューテーションが成功または失敗した後に実行される処理
// onSettled: (data, error) => {
// if (error) {
// console.error('Logout failed:', error);
// }
// },
};
return useMutation(mutationOptions);
};
// ログアウト処理を行い、loginUserStatus を更新するカスタムフック
export const useLogout = () => {
const queryClient = useQueryClient();
const mutationOptions = {
mutationFn: api.logout,
onError: (error) => {
console.error('Logout error:', error);
},
onSuccess: (data) => {
console.log('Logout success:', data);
queryClient.setQueryData(['loginUserStatus'], {});
},
};
return useMutation(mutationOptions);
};
// useLogin()が return しているのは?
export const useLogin = () => {
// useQueryの実行結果
return useQuery({queryKey:['loginUserStatus'], queryFn:api.login,...queryOptions});
};
// useLogout()が
// return しているのは
export const useLogout = () => {
const mutationOptions = {
mutationFn: api.logout,
};
// useMutation の実行結果
return useMutation(mutationOptions);
};
const useQueryの実行結果が戻ってくる = useLogin();
const useMutationの実行結果が戻ってくる = useLogout();
useQueryの実行結果が戻してくる戻り値は?
const {
data: undefined, // フェッチされたデータ <ー これがキャシュされる
dataUpdatedAt: 0, // データが最後に更新された時刻
error: null, // エラーオブジェクト
failureCount: 0, // フェッチ失敗の回数
isError: false, // エラーが発生したかどうかのブール値
isFetched: false, // データが一度でもフェッチされたかどうかのブール値
isFetchedAfterMount: false, // コンポーネントマウント後にデータがフェッチされたかどうかのブール値
isFetching: false, // データフェッチ中かどうかのブール値
isPending: true, // ローディング中かどうかのブール値
isPlaceholderData: false, // プレースホルダーデータを使用しているかどうかのブール値
isPreviousData: false, // 前回のデータを使用しているかどうかのブール値
isStale: true, // データが古くなっているかどうかのブール値
isSuccess: false, // データフェッチが成功したかどうかのブール値
refetch: Function, // データを再フェッチするための関数
remove: Function, // クエリをキャッシュから削除するための関数
status: 'loading' // クエリのステータス ('pending', 'error', 'success')
} = useQuery(queryKey,queryFn);
const {
mutate, // ミューテーション関数 mutationFn
mutateAsync, // プロミスを返すミューテーション関数
isPending, // ミューテーションが現在実行中かどうかを示すブール値
isError, // ミューテーションがエラーで終了したかどうかを示すブール値
isSuccess, // ミューテーションが成功したかどうかを示すブール値
error, // ミューテーションが失敗した場合のエラーオブジェクト
data, // ミューテーションの結果として返されるデータ
status, // ミューテーションの状態を示す文字列 ('idle', 'pending', 'error', 'success')
reset, // ミューテーションの状態をリセットする関数
} = useMutation(mutationFn);
// const { data, isPending, error } = useQuery('todos', fetchTodos);
// 普通、上のようにuseQueryのカスタムフックを作って使用する。
const { data, isPending, error } = useTodos();
// この書き方はよくない、todosが更新されるたびに<ul></ul>コンポーネントがアンマウントされる
// if (isPending) return 'Loading...';
// if (error) return 'An error has occurred: ' + error.message;
// データを表示
return (
<>
{isPending ? 'Loading....' : ''}
{error ? 'An error has occurred: '+ error.message : ''}
<ul>
{data.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<>
);
機能の確認
import { useLogin, useLoginUser, useLogout } from '../hooks/useAuthApi';
const LoginPage = () => {
// ユーザー情報はreact-queryでキャッシュされる
const { data: loginUser = {} } = useLoginUser() || {};
const { mutate: login, isPending } = useLogin();
// error情報も取得できる
const { mutate: logout,error:logoutError,reset:logoutReset } = useLogout();
// ので必要がない
// const [loginUser, setLoginUser] = useState({});
const onLogin = () => {
// useLogoutの戻り値をリセットする
logoutReset();
login({
email: 'test@example.com',
password: 'password',
})
// setLoginUser(user); // 不要
}
const onLogout = () => {
// try{
// await api.logout()
// setLoginUser({});
// }catch(error){
// errorメッセージを取得してセットしていた
// setLoginUser(error.response.data);
// }
logout();
}
return (
<div>
<h1>ログインユーザー情報</h1>
<button onClick={onLogin}>ログイン</button>
<button onClick={onLogout}>ログアウト</button>
<ul>
{
isPending ? <li>Loading.....</li>:
Object.entries(loginUser).map(([key, value]) => {
return <li key={key}>{key}:{value}</li>
})}
</ul>
{logoutError? <p style={{color:'red'}}>{logoutError.response.data.message}</p>:''}
</div>
)
}
export default LoginPage
データの更新と一緒にコンポーネントが切り替わっていたらOK
loader関数の作成
loader関数の主な役割
- loaderは、特定のルートにアクセスする際に必要なデータをサーバーから非同期にロードしページに渡すデータフェッチとしての機能
- ユーザー認証が必要なページにアクセスする前に、ユーザーがログインしているかどうかをチェックしそうでない場合は、リダイレクトさせるガード機能
// ユーザープロファイルデータをロードするloader関数
async function userProfileLoader() {
// APIからユーザープロファイルデータを取得
const userProfile = await fetchUserProfile();
// returnした戻り値は 登録した<element/>要素から取得できる
return userProfile;
}
// ルーター設定
export const router = createBrowserRouter([
{
path: 'profile',
element: <UserProfilePage />,
loader: userProfileLoader
},
// 他のルート設定...
]);
// fetch等の副作用をコンポーネントから除外できる。
function UserProfilePage() {
// useLoaderDataフックを使用してloader関数のリターン値を取得できる
const userProfile = useLoaderData();
return (
<div>
<h1>ユーザープロファイル</h1>
<p>名前: {userProfile.name}</p>
<p>メール: {userProfile.email}</p>
</div>
);
}
ページにアクセスする前に非同期でデータを取得する
ため、
ページのレンダリングが開始される前にはデータが揃っている
ので、
ユーザーにスムーズなナビゲーション体験を提供できる
例えば、
ユーザーがダッシュボードページにアクセスしようとしたとき、
コンポーネントにアクセスする前にloader関数でユーザーの認証状態をチェックし、
認証されている場合はユーザーのプロフィールデータを取得してページに渡します。
これにより、コンポーネントが表示されるたときには、データが揃っているため、
アクセスと同時にユーザーに関連する情報が表示される
ため、
ページの読み込み中に発生する可能性のある遅延や不要なレンダリングを防ぐことができる。
また、
データの取得がルートレベルで行われるため、コンポーネントから副作用を除外し、データフェッチングをより効率的にするために役立ちます。
コンポーネントの状態を直接変更することなく、データをコンポーネントに提供する
ため、
これにより、コンポーネントは純粋な表示のロジックに集中でき、データフェッチングの複雑さが分離される
ので、
このアプローチは、特に大規模なアプリケーションやデータ依存の多いアプリケーションにおいて有効になる。
loder関数はページコンポーネントにアクセスするまえに実行される
ため、
データを取得して渡すだけではなく、
ページコンポーネントにアクセスする権利があるかどうかを判定して、ガードする役割も提供する。
しかし、
データの状態を管理してリダイレクトする機能はない。
async function guardLoader() {
if (!await isAuthenticated()) {
// ユーザーがログインしていない場合は、ログインページにリダイレクト
throw redirect('/login'); //return ではなく throwが推奨されている
}
const userData = await getUserData();
return userData;
}
この後、ログインページでログインして !await isAuthenticated()の状態が変化したら、
DashBoadPageに遷移させるという機能まではloader関数では書けない。
リダイレクトさせることで、ルーターを再評価することができる
- useNavigate()を用いしてlogin関数実行後、直接 DashBoadPageにredirectさせる(一般的)
- useStateでデータを管理して状態が更新されると、ルーターがその変更を検出し、適切なルートに再評価を行はせる
- ページの再読み込み等でもルーターは再評価される
loader関数の実装
import { NavLink, Outlet, createBrowserRouter, redirect } from 'react-router-dom'
import LoginPage from './pages/LoginPage'
import DashboardPage from './pages/DashboardPage'
import { isLogin } from './api/authApi';
//+
/**
* ログイン済みのみアクセス可能
* loaderに渡すコールバック関数は非同期関数にする必要がある
*/
const guardLoader = async () => {
const isLoggedIn = await isLogin();
if(!isLoggedIn){
throw redirect('/login');
}
return null;
};
// +
/**
* ログインしていない場合のみアクセス可能
* loaderに渡すコールバック関数は非同期関数にする必要がある
*/
const guestLoader = async () => {
const isLoggedIn = await isLogin();
if(isLoggedIn){
throw redirect('/');
}
return null;
}
export const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{
path: 'login',
element: <LoginPage />,
// +
loader: guestLoader
}, {
path: '/',
element: <DashboardPage />,
//+
loader: guardLoader
}
]
}
])
function Layout() {
return (
<div>
<Navbar />
<Outlet />
</div>
)
};
function Navbar() {
return (
<header>
<nav>
<ul>
<li>
<NavLink to="/">DashBoardPage</NavLink>
</li>
<li>
<NavLink to="/login">LoginPage</NavLink>
</li>
</ul>
</nav>
</header>
);
}
import { useLogin } from '../hooks/useAuthApi';
const LoginPage = () => {
const { mutate: login } = useLogin();
const onLogin = () => {
login({
email: 'test@example.com',
password: 'password',
})
}
return (
<div>
<h1>ログインページ</h1>
<button onClick={onLogin}>ログイン</button>
</div>
)
}
export default LoginPage
import { useNavigate } from "react-router-dom";
import { logout } from "../api/authApi";
import { useLoginUser } from "../hooks/useAuthApi";
const DashboardPage = () => {
const { data: loginUser = {} } = useLoginUser() || {};
const navigate = useNavigate();
const onLogout = async () => {
await logout();
navigate('/login', { replace: true });
}
return (
<div>
<h1>ダッシュボード</h1>
<button onClick={onLogout}>ログアウト</button>
<ul>
{Object.entries(loginUser).map(([key, value]) => {
return <li key={key}>{key}:{value}</li>
})}
</ul>
</div>
)
}
export default DashboardPage
ログイン、ログアウト処理が完了した後にリダイレクトさせて、ルートの再評価で、
今度はDashBoadPageにアクセスできる。
//+
import { useNavigate } from 'react-router-dom';
export const useLogin = () => {
const queryClient = useQueryClient();
//+
const navigate = useNavigate();
const mutationOptions = {
mutationFn: api.login,
onError: (error) => {
console.error('login error:', error);
},
// 非同期処理完了後 + 成功したら 実行されるコールバック関数
onSuccess: (data) => {
console.log('login success:', data);
queryClient.setQueryData(['loginUserStatus'], data);
//+
// replace:true 現在のエントリを新しいもので置き換える
// ことでユーザーがブラウザの「戻る」ボタンをクリックした場合に、
// その前のページに戻ります。
// ユーザーが遷移前のページに戻ることが意図されていない場合に便利です。
navigate('/', { replace: true });
},
};
return useMutation(mutationOptions);
};
export const useLogout = () => {
const queryClient = useQueryClient();
//+
const navigate = useNavigate();
const mutationOptions = {
mutationFn: api.logout,
onError: (error) => {
console.error('Logout error:', error);
},
onSuccess: (data) => {
console.log('Logout success:', data);
queryClient.setQueryData(['loginUserStatus'], {});
//+
navigate('/login', { replace: true });
},
};
return useMutation(mutationOptions);
};
loader関数の確認
ガードが効いているかの確認
ログイン後、ダッシュボードに遷移
ログアウト後、ログインページに遷移
loader関数とReact Queryの使い分け
-
loader
関数: ルートレベルでのデータロードに使用し、ページコンポーネントがレンダリングされる前に必要なデータを提供します。これは、ページ遷移時のユーザーエクスペリエンスを向上させるために特に有効です -
react-query
: コンポーネントレベルでのデータ管理に使用し、データのキャッシング、同期化、および必要に応じた再取得を行います。これにより、データの一貫性を保ちながら、グローバルな状態管理の複雑さを軽減します
データの取得においては、同じデータをloader
関数とreact-query
の両方で取得するのではなく、それぞれのツールが得意とする範囲で使用することが重要です。
例えば、認証データなどのルートレベルで一度だけ取得すればよいデータはloader
関数で取得し、
コンポーネント間で共有される可能性のあるデータや頻繁に更新が必要なデータはreact-query
で管理します
ユーザー登録 機能の作成
/register ルートとナビリンクの作成
//+
import RegisterPage from './pages/RegisterPage';
export const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
//+
{
path: '/register',
element: <RegisterPage />,
loader: guestLoader
},
]
}
])
function Navbar() {
return (
<header>
<nav>
<ul>
{/* + */}
<li>
<NavLink to="/register">RegisterPage</NavLink>
</li>
Reactでフォームのデータを一括して送信する際、
非制御コンポーネントは、フォームのデータをDOMに直接依存させない、
ため、Reactの状態管理から一部解放され、フォームのパフォーマンスがあがる。
ただし、フォームのバリデーションや複雑な状態のロジックを扱う場合は、
制御コンポーネントを使用することが推奨されている
{/* value属性をつけないことで非制御コンポーネントになる */}
<input id="name" name='name' type='text' required />
レジスターフォームの作成
import { useState } from 'react';
import { useRegister } from '../hooks/useAuthApi';
export default function RegisterPage() {
// const [formErrDate,setformErrDate] = useState({
// name:[],
// email:[],
// password:[]
// });
// mutate関数の戻り値はundefined。
// mutationの結果を取得したい場合は、
// Promiseを返すmutateAsync関数を使用する
const {mutate:register,error:registerError} = useRegister();
const {name:nameErr,email:emailErr,password:passwordErr}
= registerError?.response?.data?.errors
?? {name:[],email:[],password:[]};
const handleSubmit = e => {
e.preventDefault();
const formData = new FormData(e.target);
register(formData,{
// onErrorメソッドは呼び出し側で上書きできる
// onError:(error)=>{
// name,email,passwordに[]のデフォルト値を設定している
// const newErrorDate = {name:[],email:[],password:[],...error.response.data.errors}
// setformErrDate(newErrorDate);
// },
});
};
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="name">ユーザー名</label>
{/* value属性をつけないことで非制御型のコントロールフォームになる */}
<input id="name" name='name' type='text' required />
{/* {formErrDate.name.map(err=>{
return <p style={{color:"red"}}>{err}</p>
})} */}
{nameErr ? nameErr.map(err=>{
return <p key={err} style={{color:"red"}}>{err}</p>
}):''}
</div>
<div>
<label htmlFor="email">メールアドレス</label>
<input id="email" name='email' type="email" required/>
{/* {formErrDate.email.map(err=>{
return <p style={{color:"red"}}>{err}</p>
})} */}
{emailErr ? emailErr.map(err=>{
return <p key={err} style={{color:"red"}}>{err}</p>
}):''}
</div>
<div>
<label htmlFor="password">password</label>
<input id="password" name='password' type="password" required/>
{/* {formErrDate.password.map(err=>{
return <p style={{color:"red"}}>{err}</p>
})} */}
{passwordErr ? passwordErr.map(err=>{
return <p key={err} style={{color:"red"}}>{err}</p>
}):''}
</div>
<div>
<label htmlFor="password_confirmation">password-confirmation </label>
<input id="password_confirmation" name='password_confirmation' type="password" required/>
</div>
<div>
<button type="submit">登録</button>
</div>
</form>
);
}
サーバーへのレジスターフォームの送信apiの作成
//+
export const register = async (formDate) => {
await http.get('/sanctum/csrf-cookie')
// headerにtokenをつけて送信。
const { data } = await http2.post('/register',formDate);
return data
}
作成したapiをreact queryで呼び出すフックの作成
//+
export const useRegister = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const mutationOptions = {
mutationFn: api.register,
onError: (error) => {
console.error('login error:', error);
},
onSuccess: (data) => {
console.log('login success:', data); // null
// dataがnullのためloginUserStatusを再フェッチさせてデータを取得させる
// キャッシュを無効化して再度フェッチを強制する。
queryClient.invalidateQueries(['loginUserStatus']);
navigate('/', { replace: true });
},
};
return useMutation(mutationOptions);
};
ログインフォームの作成
// import * as api from '../api/authAPI'
import { useState } from 'react';
import { useLogin } from '../hooks/useAuthApi';
const LoginPage = () => {
const { mutate: login } = useLogin();
const [formErrDate, setformErrDate] = useState({
email: [],
password: []
});
const onLogin = (e) => {
e.preventDefault();
login({
email: e.target.email.value,
password: e.target.password.value,
}, {
onError: (error) => {
const newErrorDate = { email: [], password: [], ...error.response.data.errors }
setformErrDate(newErrorDate);
}
})
}
return (
<div>
<h1>ログインページ</h1>
<form onSubmit={onLogin} noValidate>
<div>
<label htmlFor="email">メールアドレス</label>
<input id="email" name='email' type="email" required />
{formErrDate.email.map(err => {
return <p style={{ color: "red" }}>{err}</p>
})}
</div>
<div>
<label htmlFor="password">password</label>
<input id="password" name='password' type="password" required />
{formErrDate.password.map(err => {
return <p style={{ color: "red" }}>{err}</p>
})}
</div>
<div>
<button type="submit">ログイン</button>
</div>
</form>
</div>
)
}
export default LoginPage
ユーザー登録の確認
ユーザー登録ができればOK
バリデーションが確認できればOK
登録したユーザーでログインできるか
パスワードリセット機能の作成
laravelでのテストメールの設定
登録して
設定を.env
に張り付けたらいいだけ
MAIL_MAILER=smtp
MAIL_HOST=sandbox.smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=2(^_-)-☆3
MAIL_PASSWORD=8(^^)/($・・)/~~~0
larabelでのメール送信
- メイラブルクラスの作成
php artisan make:mail SendTestMail
public function content(): Content
{
// viewsファイルを指定
// BACK_END\resources\views\testmail.blade.php
return new Content('testmail');
}
}
<p>send test email</p>
Route::get('/mail',function(){
// メールアドレスは何でもいい
Mail::to('xxxxx@example.com')->send(new SendTestMail());
return '<h1>send test mail</h1>';
});
メールが送信できているか確認
http://localhost:8000/mail
にアクセスして
MailtrapのinputBoxにメールが送信されていればOK
メールアドレスの送信フォームの作成
ルートとナビリンクの登録
//+
import ResetPasswordPage from './pages/ResetPasswordPage';
import NewPasswordPage from './pages/NewPasswordPage';
export const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
//+
, {
path: '/reset-password',
element: <ResetPasswordPage />,
loader: guestLoader
},
{
path: 'password-reset/:token', // ダイナミックセグメント
element: <NewPasswordPage />,
loader: guestLoader
},
]
}
])
function Navbar() {
return (
<header>
<nav>
<ul>
{/* + メールアドレスを送信するページのリンク */}
<li>
<NavLink to="/reset-password">ResetPasswordPage</NavLink>
</li>
);
}
メールアドレスと、新しいパスワードのサーバーへの送信apiの作成
export const forgotPassword = async ({ email }) => {
await http.get('/sanctum/csrf-cookie')
// headerにtokenをつけて送信。
const { data } = await http2.post('/forgot-password', { email });
return data
}
export const resetPassword = async (formData) => {
await http.get('/sanctum/csrf-cookie')
// headerにtokenをつけて送信。
const { data } = await http2.post('/reset-password', formData);
return data
};
作成したapiをreqct-queryで取得する
export const useForgotPassword = () => {
const mutationOptions = {
mutationFn: api.forgotPassword,
onError: (error) => {
console.error('login error:', error);
},
onSuccess: (data) => {
console.log('login success:', data);
},
};
return useMutation(mutationOptions);
};
export const useResetPassword = () => {
const navigate = useNavigate();
const mutationOptions = {
mutationFn: api.resetPassword,
onError: (error) => {
console.error('login error:', error);
},
onSuccess: (data) => {
console.log('login success:', data);
navigate('/login', { replace: true });
},
};
return useMutation(mutationOptions);
};
メールの送信フォームの作成
import { useState } from "react";
import { useForgotPassword } from "../hooks/useAuthApi";
const ResetPasswordPage = () => {
const [errMessages, setErrMessages] = useState({ email: [] });
const { mutate: forgotPassword, isPending, isSuccess, reset: resetPassword } = useForgotPassword();
const onSendMail = (e) => {
e.preventDefault();
forgotPassword({
email: e.target.email.value
}, {onSuccess(){
setErrMessages({email:[]});
},
onError: (err) => {
const newErrors = { email: [], ...err.response.data.errors };
setErrMessages(newErrors);
}
});
};
const onReForgotPassword = () => {
resetPassword();
};
return (
<div>
<h1>Send Password Reset Emal</h1>
{!isSuccess ?
(!isPending ?
<form onSubmit={onSendMail}>
<label htmlFor="email">EMAIL:</label>
<input type="email" name="email" id="email" />
<button>リンクをメールに送る</button>
{errMessages.email.map(err => {
return <p style={{ color: 'red' }} key={err}>{err}</p>
})}
</form>
:
<p>Sending.....</p>
):
(<>
<p style={{ color: 'green' }}>メールアドレスにリセットリンクを送信しました。</p>
<button onClick={onReForgotPassword}>再送する</button>
</>)
}
</div>
)
}
export default ResetPasswordPage;
新しくパスワードを登録するページの作成
- このページにはメールのリンクをクリックしてlaravelのサーバーへ
- laravelのサーバーからブラウザにリダイレクト要求が送信されて
- ブラウザからこのページにアクセスされる
URLからクエリパラメータを取得するフックとURLパラメータを取得するフックをまとめるカスタムフックの作成
laravelが作成するデフォルトのURL http://frontドメイン/token/?email=xxx@xxxx.com
ここにブラウザにリダイレクトするよう通知する。
/*
フックをまとめるときは単体で動作できる範囲内でまとめる
*/
import {
useParams,
useSearchParams
} from "react-router-dom";
//関数名にuseとつけることで
export default function useTokenAndEmail() {
// コンポーネント以外で組み込みフックを呼び出すことができる関数を作成できる
const { token } = useParams();
const [searchParams] = useSearchParams();
const email = searchParams.get('email');
return { token, email };
}
フォームの作成
import { useState } from "react";
import useTokenAndEmail from "../hooks/useTokenAndEmail";
import { useResetPassword } from "../hooks/useAuthApi";
const NewPasswordPage = () => {
const [formErrDate, setformErrDate] = useState({
password: []
});
useResetPassword
const { mutate: resetPassword, isPending, isSuccess } = useResetPassword();
const { token, email } = useTokenAndEmail();
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
resetPassword(formData,
{
onError: (error) => {
const newErrorDate = { password: [], ...error.response.data.errors }
setformErrDate(newErrorDate);
}
})
};
return (
<div>
<h1>New Password</h1>
{
!isSuccess ?
!isPending ?
(
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="password">password</label>
<input id="password" name='password' type="password" required />
{formErrDate.password.map(err => {
return <p style={{ color: "red" }}>{err}</p>
})}
</div>
<div>
<label htmlFor="password_confirmation">password-confirmation </label>
<input id="password_confirmation" name='password_confirmation' type="password" required />
</div>
<div>
<button type="submit">登録</button>
</div>
<input type="hidden" name="token" value={token} />
<input type="hidden" name="email" value={email} />
</form>)
:
<p>Sending....</p>
: ''
}
</div>
)
}
export default NewPasswordPage
機能の確認
メールアドレスをサーバーに送信できる
サーバーからメールが届く
リンクのサーバー側のアドレスにアクセスするとNewPasswordPageにリダイレクトされる
パスワードのリセットができる
新しいパスワードでログインできたらOK
メール検証済みユーザーを作成する
サーバー側
検証済みユーザーかどうかを通知するルートの作成
// +
Route::get('/verification-check', [EmailVerificationNotificationController::class, 'isVerifiedEmail'])
->middleware(['auth','verified'])
->name('verification.check');
- メールを送るクラスの修正
- 検証済みユーザーがアクセスしてきたら
サーバー側のRouteServiceProvider::HOMEにリダイレクトする
のを変更し
検証済みというjsonのエラーメッセージを送る - 検証済みユーザーかどうかを判定するメソッドの追加
- 検証済みユーザーがアクセスしてきたら
class EmailVerificationNotificationController extends Controller
{
public function store(Request $request): JsonResponse|RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
// 修正
// return redirect()->intended(RouteServiceProvider::HOME);
return response()->json(['error' => 'Email is already verified.'], 400);
}
$request->user()->sendEmailVerificationNotification();
return response()->json(['status' => 'verification-link-sent']);
}
// +
public function isVerifiedEmail(Request $request){
if ($request->user()->hasVerifiedEmail()) {
return true;
}
return false;
}
}
メールのリンクからアクセスされるルートのクラスの確認
<?php
class VerifyEmailController extends Controller
{
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
// 既に検証済みユーザーなら
if ($request->user()->hasVerifiedEmail()) {
// フロント側のRouteServiceProvider::HOMEにクエリをつけてリダイレクトするようブラウザに通知する
return redirect()->intended(
config('app.frontend_url').RouteServiceProvider::HOME.'?verified=1'
);
}
// 検証済みユーザーになれたら この条件文で検証済みユーザーになる or なれない
if ($request->user()->markEmailAsVerified()) {
// なれたら、
// イベントを発火させる Verifiedオブジェクトのユーザープロパティにauthユーザーを登録して発火
event(new Verified($request->user()));
// Verifiedイベントのリスナーにはデフォルトではなにも登録されていない
}
// なれてもなれなくても、最後は フロント側のRouteServiceProvider::HOMEに
// クエリをつけてリダイレクトするようブラウザに通知している
return redirect()->intended(
config('app.frontend_url').RouteServiceProvider::HOME.'?verified=1'
);
}
}
フロント側
サーバに登録したメールにメールを送るよう通知するアクションボタンをDashboardPageに作成
import { useNavigate,useLoaderData } from "react-router-dom";
import { logout } from "../api/authApi";
import { useLoginUser, useVerificationEmail } from "../hooks/useAuthApi";
const DashboardPage = () => {
//+ 既に検証済みのユーザーかどうかをloaderで取得したのを取得
const isVrifiedEmail = useLoaderData();
const { data: loginUser = {} } = useLoginUser() || {};
const navigate = useNavigate();
//+ サーバーにそのアクションを 送る、通信中、成功
const { mutate: verificationEmail, isPending, isSuccess } = useVerificationEmail();
const onLogout = async () => {
await logout();
navigate('/login', { replace: true });
}
//+ ボタンを押したら送る
const onSendEmail = async () => {
verificationEmail();
}
return (
<div>
<h1>ダッシュボード</h1>
<button onClick={onLogout}>ログアウト</button>
{/* + */}
{!isVrifiedEmail ?
!isSuccess ?
!isPending ? (
<button onClick={onSendEmail}>メール検証がまだ完了していません。クリックして検証メールを送信してください。</button>
) :
<p>Sending...</p>
: (
<p style={{ color: 'green' }}>検証メールが送信されました。メール内のリンクをクリックして検証を完了させてください。</p>
) : <p>検証済みユーザー</p>}
<ul>
{Object.entries(loginUser).map(([key, value]) => {
return <li key={key}>{key}:{value}</li>
})}
</ul>
</div>
)
}
export default DashboardPage
ガードローダーの修正
//+ isVerifiedEmail の追加
import { isLogin, isVerifiedEmail } from './api/authApi';
const guardLoader = async () => {
const isLoggedIn = await isLogin();
if (!isLoggedIn) {
throw redirect('/login');
}
return null;
};
// +
const isVerifiedEmailGuardLoader = async () => {
await guardLoader();
return await isVerifiedEmail();
};
export const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{
path: 'login',
element: <LoginPage />,
loader: guestLoader
}, {
path: '/',
element: <DashboardPage />,
// 修正
// loader: guardLoader
loader: isVerifiedEmailGuardLoader
},
サーバーとの通信
// + メールを送るようサーバーに通知する
export const verificationEmail = async (formData) => {
await http.get('/sanctum/csrf-cookie')
// headerにtokenをつけて送信。
const { data } = await http2.post('/email/verification-notification');
return data
};
// 検証済みユーザーかどうかの判定をサーバーから取得
export const isVerifiedEmail = async () => {
const { data } = await http.get('/verification-check');
return data
};
作成したapiをreqct-queryで発火させる
export const useVerificationEmail = () => {
const mutationOptions = {
mutationFn: api.verificationEmail,
onError: (error) => {
console.error('login error:', error);
},
onSuccess: (data) => {
console.log('login success:', data);
},
};
return useMutation(mutationOptions);
};
機能の確認
アクションボタンを押す
届いたメールのリンクをクリックする リンクのあて先はサーバー
サーバーからダッシュボードにリダイレクトされる
ダッシュボードに検証済みユーザーとでていればOK