ユーザー認証

前回終了時のソースコード

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');
});
となっている。middleware('guest’)とすることで、/loginに到達できるのはguest (認証されていない)に限られる。

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);
    }
}

作成したミドルウェアを使えるようにします。

  1. auto.loginという名前をつける。これをしない場合は、ルートに長いクラス名をいれる必要があります。
  2. ミドルウェアの優先順位(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