ユーザー認証
前回終了時のソースコード
Laravelを使ったアプリケーション開発のソースコードは以下のようにしてダウンロードすることができます。
$ git clone https://kiku3.tsbio.info/git/study-laravel.git study_laravel
この章開始時点のソースコードは chapt6 ブランチにあります。
$ git switch chapt6
ソースコードを自分で書いていく場合は、自分用のブランチをつくるとよいです。
$ git switch -c my6 chapt6
Laravelのコードはsrcディレクトリからの相対パスになっています。
エラーへの対処
-
Laravelのパッケージがない
composer install を実行してください。開発段階に応じて不足するパッケージがあるときは、再度 composer install が必要な場合があります。
-
ディレクトリのパーミッションが正しくない
tempnam()が実行できないなどのエラーが出るときは、storageとbootstrap/cacheディレクトリの所有者とパーミッションを確認してください(第1章を参照)
-
Vite manifest not found
開発中なら npm run dev、本番環境なら npm run buildを実行してください。
-
npm run devが動かない
npm install を実行してください。
認証に必要なパッケージをインストールする
ユーザー認証に使うLaravel Breezeをインストールします。これは特に追加パッケージをインストールするものではなく、ルートなどを設定します。 これをインストールすると、次のファイルが上書きされてしまうので、コピーをつくっておきます。(web.phpはないとエラーがでる)
- app.css -> lab.css
- app.js -> lab.css
- app.blade.php -> lab.blade.php
- web.php -> oligo.php
# composer require laravel/breeze --dev # artisan breeze:install blade
breezeに変更されたファイルの修正
resources/views/layouts/app.blade.php を breeze.blade.phpに変更します。ついで、app/View/Components/AppLayout.php
の return view('layouts.app'); を return view('layouts.blade'); に修正します。lab.blade.phpをapp.blade.phpに戻します。
app/View/Components/AppLayout.php
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class AppLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.breeze'); // layouts/app.blade.phpは自分用に
}
}
resources/css/app.css
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
@import 'lab.css';
@import 'oligo.css';
resources/css/lab.css
.module-tab {
@apply
border border-black /* 枠線の設定 */
first:rounded-l /* first: によって、最初の要素に限定する。左側(-l)のみ丸める(rounded)。 */
last:rounded-r /* 同様に最後の枠線の右側(-r)を丸める。 */
font-sans font-bold
px-6 bg-white /* 文字の左右の余白(padding x)を6px、背景色を白に設定 */
hover:bg-blue-500 hover:text-white /* ホバー時の背景色を青、文字色を白に設定 */
;
}
.feature-tabs {
@apply flex;
}
.feature-tab {
@apply border-t border-x border-black rounded-t-md mx-1 px-6 font-sans font-bold;
}
.features {
@apply bg-white p-2 min-h-screen;
}
button {
@apply border-2 py-[0.05em] px-2 rounded shadow-sm shadow-gray-400;
}
button:active {
@apply shadow-none translate-y-[0.05em] translate-x-[0.05em];
}
input[type=checkbox] {
@apply w-4 h-4;
}
lab.jsはそのままapp.jsに戻す
ユーザーの登録
/registerを使ったユーザーの登録はLaravelからメールを送信できるようにしないといけないので、今回はパス。
代わりに、Model/Userから直接登録する。
# ./artisan tinker > $user = User::create(['name' => 'test user', 'email' => 'test@example.com', 'password' => 'secret']) > exit
これで/loginでログインできるようになる。
ログインのしくみ
routes/web.phpを見てみると次のようになっている。
routes/web.php
<?php
use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::get('/dashboard', function () {
return view('dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');
Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});
require __DIR__.'/auth.php';
routes/auth.php
<?php
use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\ConfirmablePasswordController;
use App\Http\Controllers\Auth\EmailVerificationNotificationController;
use App\Http\Controllers\Auth\EmailVerificationPromptController;
use App\Http\Controllers\Auth\NewPasswordController;
use App\Http\Controllers\Auth\PasswordController;
use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () {
Route::get('register', [RegisteredUserController::class, 'create'])
->name('register');
Route::post('register', [RegisteredUserController::class, 'store']);
Route::get('login', [AuthenticatedSessionController::class, 'create'])
->name('login');
Route::post('login', [AuthenticatedSessionController::class, 'store']);
Route::get('forgot-password', [PasswordResetLinkController::class, 'create'])
->name('password.request');
Route::post('forgot-password', [PasswordResetLinkController::class, 'store'])
->name('password.email');
Route::get('reset-password/{token}', [NewPasswordController::class, 'create'])
->name('password.reset');
Route::post('reset-password', [NewPasswordController::class, 'store'])
->name('password.store');
});
Route::middleware('auth')->group(function () {
Route::get('verify-email', EmailVerificationPromptController::class)
->name('verification.notice');
Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
->middleware(['signed', 'throttle:6,1'])
->name('verification.verify');
Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
->middleware('throttle:6,1')
->name('verification.send');
Route::get('confirm-password', [ConfirmablePasswordController::class, 'show'])
->name('password.confirm');
Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']);
Route::put('password', [PasswordController::class, 'update'])->name('password.update');
Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
->name('logout');
});
/login は routes/auth.php に定義されている。ここで
Route::middleware('guest')->group(function () {
Route::get('login', [AuthenticatedSessionController::class, 'create'])
->name('login');
});
loginの情報は app/Http/Controllers/Auth/AuthenticatedSessionController.php に送られる。この中の store メソッド中で、 LoginRequest $response->authenticate() が認証を行っている。
さらに中身をみていくと
src/app/Http/Requests/Auth/LoginRequest.php で、Auth::attempt($this->only('email’, 'password’) という処理があり、ここで認証が行われる。
ヒント: ログインにemailではなくnameを使いたいときは、LoginRequestのrulesでnameを設定し、attemptでそれを渡すようにする。ユーザーにIDを設定したいときは、migration/create_users_table.phpを書き換えて、usersにuidなどのカラムを追加し、それをLoginRequestで受け取るようにする。
認証済みユーザーに限定する
/oligoへのアクセスに認証を必要とするように変更する。
routes/oligo.php
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\OligoController;
// オリゴDNAのデータベース
Route::middleware(['auth', 'verified']) //
/* authとverifiedというミドルウェアを通過することを要求
* これらのミドルウェアは vendor/laravel/framework/src/Illuminate/Auth/Middleware/ に存在する。
* auth -> Authenticate.php
* verified -> EnsureEmailIsVerified.php
*/
->prefix('oligo')
->as('web.oligo.')
->group(function (){
Route::get('/', [OligoController::class, 'index'])->name('index');
Route::get('search', [OligoController::class, 'search'])->name('search');
Route::get('create', [OligoController::class, 'create'])->name('create');
Route::post('/', [OligoController::class, 'store'])->name('store');
// 編集フォームとデータ更新
Route::get('edit', [OligoController::class, 'edit'])->name('edit');
Route::put('/{item}', [OligoController::class, 'update'])->name('update');
});
これをweb.phpで読み込むようにする
routes/web.php
<?php
use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::get('/dashboard', function () {
return view('dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');
Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});
require __DIR__.'/auth.php';
require __DIR__.'/oligo.php';
誰が承認されているのかわかるようにヘッダー部分にいれる。ついでにログアウトできるようにする。
resources/views/layouts/app.blade.php
@extends('layouts.html')
@section('body')
<header class='bg-blue-500 text-white py-2 px-2 flex justify-between items-start'>
<h1 class='text-xl leading-none'>{{ config('app.name') }}</h1>
<div class="flex items-baseline">
@auth {{-- @if(auth()->check()) と同じで、認証済みの場合だけ @endauth までが表示される。 --}}
{{-- auth() で認証オブジェクトを取得する。 ->user() で認証されたユーザーオブジェクトを取得し、その name プロパティを取得する --}}
<span class='px-2 text-sm'>{{ auth()->user()->name }}</span>
<form method="POST" action="{{ route('logout') }}" class="inline">
@csrf
<button type="submit" class="text-sm text-black bg-gray-200 border-gray-400 px-2 hover:bg-gray-400 hover:text-white">
ログアウト
</button>
</form>
@endauth
@guest {{-- @if(! auth()->check()) と同じ。 --}}
<span class='px-2 text-sm'>ゲスト</span>
<form method="GET" action="{{ route('login') }}" class="inline">
<button type="submit" class="text-sm text-black bg-gray-200 border-gray-400 px-2 hover:bg-gray-400 hover:text-white">
ログイン
</button>
</form>
@endguest
</div>
</header>
<nav class='bg-yellow-100 py-2 px-2'>
<ul id='module-tabs' class='flex'> {{-- タブが横並びになるようにflexを追加 --}}
<li class='module-tab'><a id='oligo-tab' href='/oligo'>オリゴDNA</a></li>
{{-- モジュールが複数並んだときの様子がわかるようにダミーを追加 --}}
<li class='module-tab'><a id='dummy1-tab' href='/dummy1'>ダミー1</a></li>
<li class='module-tab'><a id='dummy2-tab' href='/dummy2'>ダミー2</a></li>
<li class='module-tab'><a id='dummy3-tab' href='/dummy3'>ダミー3</a></li>
</ul>
</nav>
<main id='modules' class='bg-yellow-100 px-2 min-h-screen'>
@yield('module')
</main>
@endsection
/loginを直接URLに指定したとき、ログイン後に/dashboardへリダイレクトされます。これはapp/Http/Controllers/Auth/AuthenticatedSessionController.phpに設定があり、リダイレクト先を変更できます。
ガードを使ってアクセス可能な範囲を設定する
ガード(guard: 守衛、門番の意)は認証方法によってユーザーを区別する仕組みです。 ここではログインのURLを一般ユーザー用(/login)と管理者用(/admin/login)に分けて、アクセスできるページを分けるようにします。
今のところ認証が同じなので、ログインURLを知っていれば誰でもガードを通過できます。 将来的にはLDAP認証とか、内部利用者用のユーザーテーブルを用意するとかして、ユーザーを分離するようにできます。
ガードを追加する
config/auth.phpに新しいガード (admin) を加えます。
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'admin' => [ // ガードの名前
'driver' => 'session', // webと同じくセッションを使う
'provider' => 'users', // webと同じくusersテーブル(Userモデル)を使う
],
],
新しいガードを使うページ(ルート)をつくる
ルートの設定
adminガードを使うために必要な以下のルートを設定します。
- /admin/login でログイン用のフォームを表示
- /admin にadmin専用ページを表示
- POST /admin/logout でログアウト
routes/admin.php
<?php
use App\Http\Controllers\Auth\AdminSessionController as Controller;
use Illuminate\Support\Facades\Route;
Route::prefix('admin') // この名前は別にガード名とは関係ない
->as('admin.')
->group(function () {
Route::middleware('guest:admin') // ミドルウェアguestにadminガードによる認証を使わせる
->group(function ()
{
Route::get('login', [Controller::class, 'create'])
->name('login');
Route::post('login', [Controller::class, 'store']);
});
Route::middleware('auth:admin') // ミドルウェアauthにadminガードによる認証を使わせる
->group(function ()
{
Route::post('logout', [Controller::class, 'destroy'])
->name('logout');
Route::get('/', function (){
return view('layouts.app'); // とりあえず中身のないレイアウトを表示させる。
})->name('index');
});
});
これを routes/web.php でrequireします。
コントローラーの作成
続いて対応するコントローラー app/Http/Controllers/Auth/AdminSessionController.php を作成します。
(app/Http/Controllers/Auth/AuthenticatedSessionController.php を元に作成)
app/Http/Controllers/Auth/AdminSessionController.php
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AdminSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): View
{
return view('auth.login', ['guard' => 'admin']); // 修正。今のところ$guardはビューで使用していない
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate('admin'); // 修正
$request->session()->regenerate();
return redirect()->intended(route('admin.index', absolute: false)); // これは今後の検討
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('admin')->logout();
# $request->session()->invalidate(); // 他のガードのログインも消えてしまうので削除
$request->session()->regenerateToken();
return redirect('admin.login'); // 修正
}
}
リクエストの作成
src/app/Http/Requests/Auth/LoginRequest.php を修正して、ガードを設定できるようにする。
--- a/src/app/Http/Requests/Auth/LoginRequest.php
+++ b/src/app/Http/Requests/Auth/LoginRequest.php
@@ -38,11 +38,13 @@ public function rules(): array
*
* @throws ValidationException
*/
- public function authenticate(): void
+ public function authenticate(string $guard = 'web'): void // 引数をつくる
{
$this->ensureIsNotRateLimited();
- if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
+ if (! Auth::guard($guard) // 認証に使用するガードを指定する
+ ->attempt($this->only('email', 'password'), $this->boolean('remember'))
+ ) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
ビューの作成
resources/views/auth/login.blade.php を修正して、認証情報の送り先を /admin/login にする。
--- a/src/resources/views/auth/login.blade.php
+++ b/src/resources/views/auth/login.blade.php
@@ -2,7 +2,7 @@
<!-- Session Status -->
<x-auth-session-status class="mb-4" :status="session('status')" />
- <form method="POST" action="{{ route('login') }}">
+ <form method="POST" action="{{ route( $guard ?? null == 'admin' ? 'admin.login' : 'login') }}">
@csrf
<!-- Email Address -->
resources/views/layouts/app.blade.php を修正して、adminガードを使用していることをわかるようにする。
--- a/src/resources/views/layouts/app.blade.php
+++ b/src/resources/views/layouts/app.blade.php
@@ -1,7 +1,15 @@
@extends('layouts.html')
@section('body')
- <header class='bg-blue-500 text-white py-2 px-2 flex justify-between items-start'>
+ @php
+ if(auth('admin')->check()){
+ $color = 'bg-rose-500';
+ }else{
+ $color = 'bg-blue-500';
+ }
+ @endphp
+
+ <header class='{{ $color }} text-white py-2 px-2 flex justify-between items-start'>
<h1 class='text-xl leading-none'>{{ config('app.name') }}</h1>
<div class="flex items-baseline">
@auth {{-- @if(auth()->check()) と同じで、認証済みの場合だけ @endauth までが表示される。 --}}
開発用の自動ログイン
認証機能をいれてしまうと、アプリケーションの開発・動作確認が面倒になります。 そこで、自動ログインの機能を作りました。 当然ですが、本番環境では無効にしてください。
- .envのAPP_ENVをtestかproductionにする。
- routesでauto.loginの引数を空にする(ユーザー名がnullになって、認証をとばされる)
- DevAutoLoginのreturn $next($request); を最初に持ってくる。
app/Http/Middleware/DevAutoLogin.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/*
* 特定ユーザーでログインしてテストする
*/
class DevAutoLogin
{
public function handle(Request $request, Closure $next, ?string $name = null, ?string $guard = 'web')
{
if(app()->environment('local') && Auth::guard($guard)->guest()){
if($name){
$user = \App\Models\User::where('name', $name)->first();
if ($user) {
logger(sprintf("%s at %d: name = '%s' guard = '%s'", __CLASS__, __LINE__, $name, $guard));
Auth::guard($guard)->login($user);
}
}
}
return $next($request);
}
}
作成したミドルウェアを使えるようにします。
- auto.loginという名前をつける。これをしない場合は、ルートに長いクラス名をいれる必要があります。
- ミドルウェアの優先順位(priority)を設定する。これをしないと、Authenticateが必ず先に実行されてしまう。
bootstrap/app.php
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// ルートでクラス名を簡単に書くための設定
$middleware->alias([
'auto.login' => \App\Http\Middleware\DevAutoLogin::class,
]);
/* ミドルウェアの実行順は vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php に設定されている。
* DevAutoLoginをAuthenticateの前に持ってこないと意味がない。
*/
$middleware->priority([
\App\Http\Middleware\DevAutoLogin::class,
\Illuminate\Auth\Middleware\Authenticate::class,
\Illuminate\Auth\Middleware\EnsureEmailIsVerified::class
]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
})->create();
middlewareにauto.loginを追加した。 テスト用のユーザーを設定する。
routes/oligo.php
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\OligoController;
// オリゴDNAのデータベース
Route::middleware(['auth', 'verified', 'auto.login:test user'])
// auto.loginという名前は DevAutoLogin の別名として bootstrap/app.php で設定してある。
->prefix('oligo')
->as('web.oligo.')
->group(function (){
Route::get('/', [OligoController::class, 'index'])->name('index');
Route::get('search', [OligoController::class, 'search'])->name('search');
Route::get('create', [OligoController::class, 'create'])->name('create');
Route::post('/', [OligoController::class, 'store'])->name('store');
// 編集フォームとデータ更新
Route::get('edit', [OligoController::class, 'edit'])->name('edit');
Route::put('/{item}', [OligoController::class, 'update'])->name('update');
});
ユーザーの一覧、追加、編集、削除
以下は第7章、第8章の後に書いています。
ルートの設定
routes/user.php にユーザー設定で使用するルートを書きます。
routes/user.php
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\UserController as Controller;
// ユーザー管理のデータベース
$module = 'user';
Route::prefix($module)
->middleware(["can:$module.read"])
->as("$module.")
->group(function (){
Route::get('/', [Controller::class, 'index'])->name('index');
// パスワード設定のためのフォーム
foreach(['search', 'create', 'edit', 'password'] as $name){
Route::get($name, [Controller::class, $name])->name($name);
}
// 編集フォームとデータ更新
Route::post('/', [Controller::class, 'store'])->name('store');
Route::post('password', [Controller::class, 'issuePasswordResetUrl'])->name('password.issue');
Route::put('/{item}', [Controller::class, 'update'])->name('update');
});
作成したものをroutes/admin.phpを通じて読み込ませます。
routes/admin.php
<?php
use App\Http\Controllers\Auth\AdminSessionController as Controller;
use Illuminate\Support\Facades\Route;
Route::prefix('admin')
->as('admin.')
->group(function () {
Route::middleware('guest:admin')
->group(function ()
{
Route::get('login', [Controller::class, 'create'])
->name('login');
Route::post('login', [Controller::class, 'store']);
});
Route::middleware(['auth:admin', 'verified', 'auto.login:test admin,admin'])
->group(function ()
{
require __DIR__.'/user.php';
Route::post('logout', [Controller::class, 'destroy'])
->name('logout');
Route::get('/', function (){
return view('layouts.app');
})->name('index');
});
});
コントローラーの作成
MaterialController.phpを継承するものから適当にコピーしてつくります。
app/Http/Controllers/UserController.php
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\UserService;
use App\Http\Requests\UserRequest;
use App\Http\Requests\UserSearchRequest;
use App\Http\Requests\UsersRequest;
use App\Http\Requests\BulkItemIdsRequest;
use Illuminate\Support\Facades\Password;
use Illuminate\Foundation\Http\FormRequest;
class UserController extends MaterialController
{
protected string $singleRequest = UserRequest::class;
protected string $multiRequest = UsersRequest::class;
protected string $queryRequest = UserSearchRequest::class;
public function __construct(
protected UserService $service,
protected User $model
) {}
public function password(){
return $this->respond();
}
public function issuePasswordResetUrl(BulkItemIdsRequest $request){
$ids = $request->validated('id');
try{
return $this->service->issuePasswordResetUrl($ids);
}catch(\Throwable $e){
return $this->json(['message' => $e->getMessage()], 400);
}
}
}
リクエストの作成
8章でSeedRequestなどをつくったのと同じ。
サービスの作成
ユーザーを追加するときはパスワードが必要になるので、適当なランダムな文字列をつくっていれている。 ユーザーに連絡するときはパスワード再発行のためのURLを生成し、それをメールで送るのがよい。
app/Services/UserService.php
<?php
namespace App\Services;
use App\Models\User;
use App\TableDefinitions\UserTableDefinition;
use App\TableSearch\SearchFactory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Password;
class UserService extends MaterialService
{
public function __construct(
protected User $model,
public UserTableDefinition $table,
protected SearchFactory $searchFactory
){}
public function create(array $users)
{
$users = array_map(function ($user) {
$user['password'] = Hash::make(Str::random(32));
return $user;
}, $users);
return parent::create($users);
}
public function issuePasswordResetUrl(array $user_ids){
return ['users' => DB::transaction(function () use ($user_ids){
$users = [];
foreach($user_ids as $id){
$user = User::findOrFail($id);
$token = Password::broker()->createToken($user);
$user->url = route('password.reset', [
'token' => $token,
'email' => $user->email,
]);
$users[] = $user;
}
return $users;
})];
}
}
ビューの作成
モジュールのタブではこれまで route('web.oligo.index')などしていた。
ここに route('admin.user.index') を加えようとすると、モジュールごとにガードを設定することになる。
どのガードを通るのかはroutesディレクトリ以下のファイルで設定されているので、それと不整合になることは避けたい。
そこで、モジュール名を含むルート名からルートをつくる module_route関数をつくる。 これはビュー以外でも使うことができるので、app/helpers.phpに作成する。
app/helpers.php
<?php
use Illuminate\Support\Facades\Route;
if(! function_exists('module_route')){
function module_route($module, $feature = 'index') {
static $routes;
if(is_null($routes)){
$routes = collect(Route::getRoutes())
->map(fn ($route) => $route->getName())
->filter()
->all();
}
$target = "{$module}.{$feature}";
foreach($routes as $route){
if($route === $target || str_ends_with($route, ".$target")){
return route($route);
}
}
return '#';
}
}
これを使えるようにするために、autoloadの設定をする。
composer.json
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
},
"files":[ /* autoloadにfilesを追加する */
"app/helpers.php"
]
},
composer.json を書き換えたあとは composer dump-autoloadを実行する。
# composer dump-autoload Generating optimized autoload files > Illuminate\Foundation\ComposerScripts::postAutoloadDump > @php artisan package:discover --ansi (以下省略)
これまでroute()を使っていたところをmodule_routeに置き換える。主なファイルは以下の通り。
- components/module-nav.blade.php
- layouts/app.blade.php
- oligo,plant,seed/module.blade.php
- components/material/edit.blade.php
resources/views/layouts/app.blade.php
@extends('layouts.html')
@section('body')
@php
if(! isset($module_id)) $module_id = '';
@endphp
<header class='bg-blue-500 text-white py-2 px-2 flex justify-between items-start'>
<h1 class='text-xl leading-none'>{{ config('app.name') }}</h1>
<div class="flex items-baseline">
@auth
<span class='px-2 text-sm'>{{ auth()->user()->name }}</span>
<form method="POST" action="{{ route('logout') }}" class="inline">
@csrf
<button type="submit" class="text-sm text-black bg-gray-200 border-gray-400 px-2 hover:bg-gray-400 hover:text-white">
{{ __('ログアウト') }}
</button>
</form>
@endauth
@guest
<span class='px-2 text-sm'>ゲスト</span>
<form method="GET" action="{{ route('login') }}" class="inline">
<button type="submit" class="text-sm text-black bg-gray-200 border-gray-400 px-2 hover:bg-gray-400 hover:text-white">
{{ __('ログイン') }}
</button>
</form>
@endguest
</div>
</header>
<main id='modules' class='bg-yellow-100 px-2 min-h-screen'
x-data="{
activeModule: '{{ $module_id }}',
/* activeModuleの値を変更し、そのHTML要素がないときに取得する */
selectModule(moduleId){
/* タブのhref属性からurlを取得する */
const tab = document.getElementById(`${moduleId}-tab`);
if(!tab) return;
const url = tab.href;
/* URLを変更する。これによってブラウザーのリロードをしても、以前のページを取得できるようになる */
history.pushState({}, '', url);
/* activeModuleにidを設定 */
this.activeModule = moduleId;
return new Promise((resolve) => {
/* idで要素を取得 */
const panel = document.getElementById(moduleId);
/* 要素を取得できたときは resolve() を呼び出す -> thenに進む */
if(panel !== null) {
resolve();
return;
}
/* 要素を取得できなかったときにhtmxで部分HTMLを取得する */
// 奇妙に見えるがこれはhanderという関数を定義している。handlerはrevolve()をするだけ。
const handler = () => {console.log('handler was called'); resolve()};
// htmxの置き換えが終わったら、handlerが呼ばれる(=> revolve()を実行する)。
document.body.addEventListener('htmx:afterSettle', handler, { once: true });
htmx.ajax('GET', url, {
target:'main', /* 取得したHTMLの置き換え場所 */
swap: 'beforeend', /* 今回はdiv.featuresの子要素として追加するのでbeforeend */
headers: {
'HX-Target': 'module-' + moduleId,
}
});
}) // return new Promiseの終わり
} // selectModuleの終わり
}"
>
<nav class='py-2 px-2'>
<ul id='module-tabs' class='flex'>
@foreach([
'seed' => ['label' => '種子', 'active' => 'bg-seed', 'inactive' => 'hover:bg-seed-light'],
'plant' => ['label' => '植物', 'active' => 'bg-plant', 'inactive' => 'hover:bg-plant-light'],
'oligo' => ['label' => 'オリゴDNA', 'active' => 'bg-oligo', 'inactive' => 'hover:bg-oligo-light'],
'user' => ['label' => 'ユーザー', 'active' => 'bg-user', 'inactive' => 'hover:bg-user-light'],
] as $module => $info)
@can("$module.read")
<li class='module-tab'
:class="{
'{{ $info['active'] }} text-white': activeModule === '{{ $module }}',
'{{ $info['inactive'] }} bg-white text-gray-600' : activeModule !== '{{ $module }}'
}"
>
<a id='{{ $module }}-tab' role='tab'
href='{{ module_route($module) }}'
@click.prevent="selectModule('{{ $module }}', event)"
>
{{ $info['label'] }}
</a>
</li>
@endcan
@endforeach
<li x-text="activeModule" class='module-tab bg-gray-400'></li>
</ul>
</nav>
@yield('module')
</main>
@endsection
resources/views/oligo を userにコピーして、適宜修正
パスワード設定用URLを生成するページを作成する。
resources/views/user/password.blade.php
@extends('user.module')
@section('feature')
<x-feature id='user-password'
x-data="{
tokens: '' // パスワード再設定用のURL。一番下のtextareaで提示する。
}"
>
<p x-show="!hasChecked" class="pb-10 pl-4"
@if(1) {{-- デバッグ用コード --}}
x-init="
query.name.value='test';
search().then(() => {
items.map(item => item.checked=true)
});
"
x-text="'Query: ' + JSON.stringify(query)"
@endif
>
<a
href="{{ module_route($module_id) }}"
@click.prevent="selectFeature('{{ "${module_id}-index" }}', event)"
>
先にパスワード設定が必要なユーザーにチェックをいれてください。
</a>
</p>
<table class='material-list border-cyan-600 border-b-2 border-t-2 w-full my-2'
x-show="hasChecked"
>
<thead>
<tr>
<th class='w-4'></th>
@foreach($table->columns('index') as $col)
<x-material.table-th :column=$col />
@endforeach
</tr>
</thead>
<tbody>
<!-- 検索結果 -->
<template x-for="item in checkedItems" :key="item.id">
<tr>
<td><input x-model="item.checked" type='checkbox'></td>
@foreach($table->columns('index') as $key => $col)
<td x-text="item.{{ $key }}" class="{{ $col['class'] }}"></td>
@endforeach
</tr>
</template>
</tbody>
</table>
<p class="py-2 pl-4">
<button type="submit" :disabled="checkedItems.length <= 1"
title='パスワード再設定のためのURLを発行します'
{{-- チェックの入ったユーザーのidを配列にしてPOSTする。
-- サーバー側ではBulkItemIdsRequestで受け取るので、キーをitemsにする
--}}
@click="api.post('{{ module_route($module_id, "password.issue") }}',
{
items: checkedItems.map(item => item.id),
}
).then((response) => {
{{-- 受け取ったデータから名前、メールアドレス、URLをタブ区切りの文字列にして、
-- textarea (=tokens)に入れる
--}}
tokens = response.users.map(user => [user.name, user.email, user.url].join('\t')).join('\n');
})
"
>パスワード再設定</button>
</p>
<textarea x-model="tokens" class="border-1 w-full h-[10em]"
x-show="tokens"
>
</textarea>
</x-feature>
@endsection