WEBOPIXEL

Laravel10 + ReactQuery + ReactRouterでSPAのログイン認証機能の実装方法

Posted: 2023.04.16 / Category: javascript, PHP / Tag: , ,

Laravel10とReactQueryとReactRouterでSPAのログイン認証機能を作ってみます。

Sponsored Link

環境・使用ライブラリ

PHP 8.2
Laravel 10
laravel breeze 1.2
React Router 6.10
React Query 4.28

Laravelのインストール

$ composer create-project --prefer-dist laravel/laravel laravel-react-spa
$ cd laravel-react-spa

Laravelの初期設定

configのapp.phpを開き、言語設定をします。

config/app.php

return [
	// ...
	'timezone' => 'Asia/Tokyo',
	'locale' => 'ja',
	'faker_locale' => 'ja_JP',

次はデータベースの設定です。
デフォルトではMySQLですが、今回はSQLiteを使用します。
.envを開いて、DB_CONNECTION=sqliteを追記して既存のMySQLの設定部分は削除します。

.env

DB_CONNECTION=sqlite

#DB_CONNECTION=mysql
#DB_HOST=127.0.0.1
#DB_PORT=3306
#DB_DATABASE=homestead
#DB_USERNAME=homestead
#DB_PASSWORD=secret

touchコマンドでsqliteファイルを作成。

$ touch database/database.sqlite

Laravel Breezeの設定

Laravel Breezeはログイン、ユーザー登録、パスワードリセットはスキャフォルドで作成します。
実はBreezeだけでフロントの画面も作ってくれるのですが、Laravel独自になりすぎてあまり汎用的ではないので今回はバックエンドの機能だけを利用します。

コンポーザーでインストールしましょう。

$ composer require laravel/breeze --dev

インストールコマンドを実行します。
apiオプションを指定してPHPファイルだけ生成するようにしましょう。

$ php artisan breeze:install api

これでいろいろファイルが書き出されます。
「routes/auth.php」開いてください。
ログインが「/login」、ログアウトが「/logout」に割り当ててありますが、このパスはフロントで使用するので、バックエンドでは「/api/login」「/api/logout」になるように編集します。

routes/auth.php

Route::prefix('api')->name('api.')->group(function () {
	Route::post('login', [AuthenticatedSessionController::class, 'store'])
		->middleware('guest')
		->name('login');

	Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
		->middleware('auth')
		->name('logout');
});

ルートの確認をしてみます。

$ php artisan route:list

login,logoutとかが追加されているのがわかります。

GET|HEAD   / ...............................................................................................................
POST       _ignition/execute-solution ........ ignition.executeSolution › Spatie\LaravelIgnition › ExecuteSolutionController
GET|HEAD   _ignition/health-check .................... ignition.healthCheck › Spatie\LaravelIgnition › HealthCheckController
POST       _ignition/update-config ................. ignition.updateConfig › Spatie\LaravelIgnition › UpdateConfigController
GET|HEAD   api/user ........................................................................................................
POST       api/email/verification-notification .......... verification.send › Auth\EmailVerificationNotificationController@store
POST       api/forgot-password ......................................... password.email › Auth\PasswordResetLinkController@store
POST       api/login ......................................................... login › Auth\AuthenticatedSessionController@store
POST       api/logout ..................................................... logout › Auth\AuthenticatedSessionController@destroy
POST       api/register ......................................................... register › Auth\RegisteredUserController@store
POST       api/reset-password ................................................ password.store › Auth\NewPasswordController@store
GET|HEAD   api/verify-email/{id}/{hash} ....................................... verification.verify › Auth\VerifyEmailController
GET|HEAD   sanctum/csrf-cookie ........................... sanctum.csrf-cookie › Laravel\Sanctum › CsrfCookieController@show

PHPファイルを生成すると同時にpackage.jsonとかvite.config.jsファイルが削除されてしまいます。
これはフロントの開発に使うので戻しておきましょう。

.envのSANCTUM_STATEFUL_DOMAINSも設定します。
今回はビルドインサーバーの初期設定のまま使うので「localhost:8000」です。

.env

SANCTUM_STATEFUL_DOMAINS=localhost:8000

データベースの構築

シーダーファイルにユーザーデータを投入するfactoryがコメントアウトしてありますので外して有効にします。

database/seeders/DatabaseSeeder.php

class DatabaseSeeder extends Seeder
{
	public function run(): void
	{
		\App\Models\User::factory(10)->create();

		\App\Models\User::factory()->create([
			'name' => 'Test User',
			'email' => 'test@example.com',
		]);
	}
}

migrateコマンドを実行してテーブルの作成&データを投入します。

$ php artisan migrate --seed

フロント開発環境の構築

Viteの設定をしてReactの開発環境を構築します。
package.jsonファイルがあることを確認してyarnコマンドを実行してnodeモジュールをインストールします。

$ yarn

必要なモジュールを追加します。

$ yarn add -D react react-dom @types/react @types/react-dom @vitejs/plugin-react @vitejs/plugin-react-refresh react-router-dom @tanstack/react-query

現在(2023/3/26)インストールするとバージョンはこのようになります。

package.json

{
	"private": true,
	"scripts": {
		"dev": "vite",
		"build": "vite build"
	},
	"devDependencies": {
		"@tanstack/react-query": "^4.28.0",
		"@types/react": "^18.0.29",
		"@types/react-dom": "^18.0.11",
		"@vitejs/plugin-react": "^3.1.0",
		"@vitejs/plugin-react-refresh": "^1.3.6",
		"axios": "^1.1.2",
		"laravel-vite-plugin": "^0.7.2",
		"react": "^18.2.0",
		"react-dom": "^18.2.0",
		"react-router-dom": "^6.9.0",
		"vite": "^4.0.0"
	}
}

TypeScriptとReactが使えるようにViteの設定をします。

vite.config.js

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import react from '@vitejs/plugin-react';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/ts/app.tsx'],
            refresh: true,
        }),
        react()
    ],
    resolve: {
        alias: {
            '@': '/resources/ts',
        }
    }
});

「resources/ts/index.tsx」を読み込む設定にしたので仮で作成しておきます。

resources/ts/index.tsx

import React from 'react'
import { createRoot } from 'react-dom/client'

const root = createRoot(
	document.getElementById('app') as HTMLElement
)

root.render(
	<React.StrictMode>
		<h1>Hello React !</h1>
	</React.StrictMode>
)

ベースとなるbladeファイルを作成します。

resources/views/app.blade.php

<!DOCTYPE html>
<html lang="ja">
<head>
	<meta charset="UTF-8">
	<meta http-equiv="X-UA-Compatible" content="IE=edge">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>Laravel React SPA</title>
</head>
<body>
<div id="app"></div>
@viteReactRefresh
@vite(['resources/css/app.css', 'resources/ts/index.tsx'])
</body>
</html>

SPAの場合は設定したエンドポイント以外はすべてこのbladeファイルが表示されるようにします。

routes/web.php

Route::any('{all}', fn () => view('app'))
    ->where(['all' => '^(?!api/*).*']);

ここまできたら表示の確認をしてみましょう。
今回はビルドインサーバーで簡易的に確認します。

$ yarn run dev
$ php artisan serv

「http://localhost:8000」にアクセスして「Hello React !」と表示されるか確認できたら設定は完了です。

設定方法は下記のページも参考にしてください。

Laravelに新しく導入されたフロントビルドツール「Vite」でTypeScript+React開発環境を構築する方法

React Routerの設定

次はReact Routerでページのベースを作っていきます。

最初にページコンポーネントを作ります。
作成するのはログインページとログインしないと見れないページのダッシュボードの2ページです。

resources/ts/pages/Login.tsx

import React from 'react'

const LoginPage: React.FC = () => {
	return (
		<div>
			<h1>ログイン</h1>
		</div>
	)
}

export default LoginPage

resources/ts/pages/Dashboard.tsx

import React from 'react'

const DashboardPage: React.FC = () => {
	return (
		<div>
			<h1>ダッシュボード</h1>
		</div>
	)
}

export default DashboardPage

このページを表示する為のルーターファイルを作成

resources/ts/routes.tsx

import { createBrowserRouter } from 'react-router-dom'
import DashboardPage from '@/pages/Dashboard'
import LoginPage from '@/pages/Login'

export const router = createBrowserRouter([
	{
		path: 'login',
		element: <LoginPage />
	}, {
		path: '/',
		element: <DashboardPage />
	}
])

作成したルーターをApp.tsxで設定します。

resources/ts/App.tsx

import React from 'react'
import { RouterProvider } from 'react-router-dom'
import { router } from './routes'

const App: React.FC = () => {
	return (
		<RouterProvider router={router} />
	)
}

export default App

index.tsxで作成したApp.tsxを表示するようにします。

resources/ts/index.tsx

import React from 'react'
import { createRoot } from 'react-dom/client'
import App from '@/App'

const root = createRoot(
	document.getElementById('app') as HTMLElement
)

root.render(
	<React.StrictMode>
		<App />
	</React.StrictMode>
)

ここまでできたらブラウザで「/」と「/login」にアクセスして作成したページが表示されることを確認してください。

React Query設定

React Queryを使えるようにします。
新しくQueryClientを初期化するファイルを作成します。
基本的な設定はここで行います。

resources/ts/queryClient.ts

import { QueryClient } from '@tanstack/react-query'

export const queryClient = new QueryClient({
	defaultOptions: {
		queries: {
			retry: false,
			refetchOnWindowFocus: false
		},
		mutations: {
			retry: false
		}
	}
})

App.tsxで作成したqueryClientをインポートしてQueryClientProviderの設定をします。

resources/ts/App.tsx

import React from 'react'
import { RouterProvider } from 'react-router-dom'
import { router } from './routes'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '@/queryClient'

const App: React.FC = () => {
	return (
		<QueryClientProvider client={queryClient}>
			<RouterProvider router={router} />
		</QueryClientProvider>
	)
}

export default App

これでReact Queryを使用する準備ができました。

ログイン中の判定

初期のLaravelの状態だと「api/user」にアクセスするとログイン中のユーザー情報が取得できるので、この情報が取れるかでどうかでログイン中かを判定します。

最初の取得する情報のtypeファイルを作成します。

resources/ts/types/User.ts

export type User = {
    id: number
    name: string
    email: string
}

AxiosでAPIと通信する処理です。

resources/ts/api/authAPI.ts

import axios from 'axios'
import type { User } from '@/types/User'

export const getUser = async () => {
	const { data } = await axios.get<User>('/api/user')
	return data
}

AxiosのReactQueryに設定します。

resources/ts/hooks/useAuth.ts

import * as api from '@/api/authAPI'
import { queryClient } from '@/queryClient'

const authUserQuery = () => ({
	queryKey: ['user'],
	queryFn: api.getUser
})

export const useAuthUser = async () => {
	const query = authUserQuery()

	return queryClient.getQueryData(query.queryKey)
		?? (await queryClient.fetchQuery(query).catch(() => undefined))
}

ReactRouterのLoader機能を使用してページを表示する前にAPIからデータを取得できるかできないかでページのリダイレクトを行います。

resources/ts/hooks/useAuth.ts

import { createBrowserRouter, redirect } from 'react-router-dom'
import { useAuthUser } from '@/hooks/useAuth'
import DashboardPage from '@/pages/Dashboard'
import LoginPage from '@/pages/Login'

/**
 * ログイン済みのみアクセス可能
 */
const guardLoader = async () => {
	const user = await useAuthUser()
	return user ? true : redirect('/login')
}

/**
 * ログインしていない場合のみアクセス可能
 */
const guestLoader = async () => {
	const user = await useAuthUser()
	return user ? redirect('/') : true
}

export const router = createBrowserRouter([
	{
		path: 'login',
		element: <LoginPage />,
		loader: guestLoader
	}, {
		path: '/',
		element: <DashboardPage />,
		loader: guardLoader
	}
])

「/」にアクセスすると「/login」にリダイレクトされることを確認してください。

ログインとログアウト

APIに接続するところから作ります。

resources/ts/api/authAPI.ts

export const login = async ({ email, password }: {
    email: string,
    password: string
}) => {
    const { data } = await axios.post<User>(
        '/api/login', { email, password }
    )
    return data
}

export const logout = async () => {
    const { data } = await axios.post<User>('/api/logout')
    return data
}

hooksにも追加します。
ログインできなかったときの処理も必要になりますが、今回は省略します。

resources/ts/hooks/useAuth.ts

export const useLogin = () => {
    const queryClient = useQueryClient()

    return useMutation({
        mutationFn: api.login,
        onError: (error: AxiosError) => {
            console.log(error)
        },
        onSuccess: (data) => {
            console.log(data)
            queryClient.invalidateQueries(['auth'])
            window.location.href = '/'
        }
    })
}

export const useLogout = () => {
    const queryClient = useQueryClient()

    return useMutation({
        mutationFn: api.logout,
        onError: (error: AxiosError) => {
            console.log(error)
        },
        onSuccess: (data) => {
            console.log(data)
            queryClient.invalidateQueries(['auth'])
            window.location.href = '/login'
        }
    })
}

ログイン画面を修正して、作成したuseLoginを実行できるようにします。

resources/ts/pages/Login.tsx

import React, { useState } from 'react'
import { useLogin } from '@/hooks/useAuth'

const LoginPage: React.FC = () => {
	const login = useLogin()

	const [email, setEmail] = useState('')
	const [password, setPassword] = useState('')

	const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
		e.preventDefault()

		login.mutate({
			email: email,
			password: password,
		})
	}

	return (
		<div>
			<h1>ログイン</h1>
			<form onSubmit={onSubmit}>
				<fieldset>
					<div>
						<label htmlFor="email">メールアドレス:</label>
						<input
							type="email"
							id="email"
							onChange={e => setEmail(e.target.value)}
							defaultValue={email}
						/>
					</div>
					<div>
						<label htmlFor="password">パスワード:</label>
						<input
							type="password"
							id="password"
							onChange={e => setPassword(e.target.value)}
							defaultValue={password}
						/>
					</div>
					<button type="submit">送信</button>
				</fieldset>
			</form>
		</div>
	)
}

export default LoginPage

ダッシュボードではログアウトボタンを追加して、useLogoutを実行できるようにします。

resources/ts/pages/Dashboard.tsx

import React from 'react'
import { useLogout } from '@/hooks/useAuth'

const DashboardPage: React.FC = () => {
	const logout = useLogout()

	const onLogout = () => {
		logout.mutate()
	}

	return (
		<div>
			<h1>ダッシュボード</h1>
			<button type="button" onClick={onLogout}>ログアウト</button>
		</div>
	)
}

export default DashboardPage

これで完成です。
ログイン画面からログインできることを確認してください。
初期データのメールアドレスは「test@example.com」、パスワードは「password」です。

ログインしている状態ではダッシュボードが見れて、ログイン画面が見れない。
ログインしていない状態の場合、逆にログイン画面が見れて、ダッシュボードが見れないという状態になっていると思います。