laravelの認証機構を見てみよう(6) : ログインの記録とactivity log


ログインタイムの記録

まあここでも書いてあるんだけど、今回はlast_login_atというカラムにしてみよう。

        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->timestamp('last_login_at')->nullable(); // これ 
            $table->rememberToken();
            $table->timestamps();
        });

適当にリスナーつくって

artisan make:listener UpdateLastLoginDate

これはsailのログだが

% ./vendor/bin/sail artisan make:listener UpdateLastLoginDate

   INFO  Listener [app/Listeners/UpdateLastLoginDate.php] created successfully.

app/Listeners/UpdateLastLoginDate.php が出来るので

<?php

namespace App\Listeners;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Auth\Events\Login;

class UpdateLastLoginDate
{
    /**
     * Create the event listener.
     */
    public function __construct()
    {
        //
    }

    /**
     * Handle the event.
     */
    public function handle(Login $event): void
    {
        $event->user->last_login_at = now();
        $event->user->save();
    }
}

としておいて

app/Providers/EventServiceProvider.php

use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Events\Verified;
use Illuminate\Auth\Events\Login; // これと
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
use App\Listeners\SendEmailToAdminsWhenRegistered;
use App\Listeners\SendEmailToAdminsWhenVerified;
use App\Listeners\UpdateLastLoginDate; // これと

class EventServiceProvider extends ServiceProvider
{
    /**
     * The event to listener mappings for the application.
     *
     * @var array<class-string, array<int, class-string>>
     */
    protected $listen = [
        Registered::class => [
            SendEmailVerificationNotification::class,
            SendEmailToAdminsWhenRegistered::class
        ],
        Verified::class => [
            SendEmailToAdminsWhenVerified::class,
        ],
        // ↓このへん
        Login::class => [
            UpdateLastLoginDate::class,
        ],
    ];

そうすればこのように更新されている

> User::all()
[!] Aliasing 'User' to 'App\Models\User' for this Tinker session.
= Illuminate\Database\Eloquent\Collection {#7479
    all: [
      App\Models\User {#7481
        id: 1,
        name: "Admin 1",
        email: "admin1@example.com",
        email_verified_at: "2023-09-21 17:49:04",
        #password: "$2y$10$ULaF4cgMe3j.GG3EvrP09ukkmOiEzIE6wwMPxFQ2S9AKnxzZpPocm",
        last_login_at: "2023-09-21 17:53:52",
        #remember_token: "Kyo12UZxy7",
        created_at: "2023-09-21 17:49:04",
        updated_at: "2023-09-21 17:53:52",
      },
      App\Models\User {#7482
        id: 2,
        name: "Admin 2",
        email: "admin2@example.com",
        email_verified_at: "2023-09-21 17:49:04",
        #password: "$2y$10$Jxq14c1XGZlz0kiDmCPPe.xPdGNIvQVUqOSqQS1.lMAJsB2WSHfoq",
        last_login_at: null,
        #remember_token: "ChLDOYUgpU",
        created_at: "2023-09-21 17:49:04",
        updated_at: "2023-09-21 17:49:04",
      },
    ],
  }

Userのリストで表示させてもいいかも

<table className="min-w-full divide-y divide-gray-200">
    <thead className="bg-gray-50">
        <tr>
            {/* ... 他の <th> タグ */}
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Last Login
            </th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Action
            </th>
        </tr>
    </thead>
    <tbody className="bg-white divide-y divide-gray-200">
        {users.map((user, index) => (
            <tr key={index}>
                {/* ... 他の <td> タグ */}
                <td className="px-6 py-4 whitespace-nowrap">
                    {user.last_login_at ? (
                        <span>{new Date(user.last_login_at).toLocaleString()}</span>
                    ) : (
                        <span className="text-gray-500">Never</span>
                    )}
                </td>
                <td className="px-6 py-4 whitespace-nowrap">
                    <Link href={route('users.show', user.id)} className="text-blue-600 hover:text-blue-900">
                        <button className="bg-blue-500 hover:bg-blue-700 text-white py-1 px-2 rounded flex items-center">
                            <VscInfo className="mr-2"/>
                            Details
                        </button>
                    </Link>
                </td>
            </tr>
        ))}
    </tbody>
</table>


最終ログインが表示されている

この辺の日付のフォーマットがあやしいとかはまあおいおい解決しましょう。

dayjs

余力があれば

npm install dayjs
<td className="px-6 py-4 whitespace-nowrap">
    {user.last_login_at ? (
        <>
            <span>{dayjs(user.last_login_at).format('YYYY-MM-DD HH:mm:ss')}</span>
            <small className="ml-2 text-sm text-gray-600">({dayjs(user.last_login_at).fromNow()})</small>
        </>
    ) : (
        <span className="text-gray-500">Never</span>
    )}
</td>

全てのユーザーに対し全てのアクティビティーを記録する

とりあえずactivity logを記録し、またそれを表示してみる。これには patie/laravel-activitylogを使う

install

composer require spatie/laravel-activitylog

以下sailのログ

% ./vendor/bin/sail composer require spatie/laravel-activitylog
Info from https://repo.packagist.org: #StandWithUkraine
./composer.json has been updated
Running composer update spatie/laravel-activitylog
Loading composer repositories with package information
Updating dependencies
Lock file operations: 1 install, 0 updates, 0 removals
  - Locking spatie/laravel-activitylog (4.7.3)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 1 install, 0 updates, 0 removals
  - Downloading spatie/laravel-activitylog (4.7.3)
  - Installing spatie/laravel-activitylog (4.7.3): Extracting archive
Generating optimized autoload files
> Illuminate\Foundation\ComposerScripts::postAutoloadDump
> @php artisan package:discover --ansi

   INFO  Discovering packages.

  diglactic/laravel-breadcrumbs .......................................................................... DONE
  inertiajs/inertia-laravel .............................................................................. DONE
  laravel-lang/lang ...................................................................................... DONE
  laravel-lang/publisher ................................................................................. DONE
  laravel/breeze ......................................................................................... DONE
  laravel/sail ........................................................................................... DONE
  laravel/sanctum ........................................................................................ DONE
  laravel/tinker ......................................................................................... DONE
  nesbot/carbon .......................................................................................... DONE
  nunomaduro/collision ................................................................................... DONE
  nunomaduro/termwind .................................................................................... DONE
  robertboes/inertia-breadcrumbs ......................................................................... DONE
  spatie/laravel-activitylog ............................................................................. DONE
  spatie/laravel-ignition ................................................................................ DONE
  spatie/laravel-permission .............................................................................. DONE
  tightenco/ziggy ........................................................................................ DONE

94 packages you are using are looking for funding.
Use the `composer fund` command to find out more!
> @php artisan vendor:publish --tag=laravel-assets --ansi --force

   INFO  No publishable resources for tag [laravel-assets].

No security vulnerability advisories found
Using version ^4.7 for spatie/laravel-activitylog

で、

artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider"

を行う。以下sailのログ

% ./vendor/bin/sail artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider"

   INFO  Publishing assets.

  Copying file [vendor/spatie/laravel-activitylog/config/activitylog.php] to [config/activitylog.php] .... DONE
  Copying file [vendor/spatie/laravel-activitylog/database/migrations/create_activity_log_table.php.stub] to [database/migrations/2023_09_21_024514_create_activity_log_table.php]  DONE
  Copying file [vendor/spatie/laravel-activitylog/database/migrations/add_event_column_to_activity_log_table.php.stub] to [database/migrations/2023_09_21_024515_add_event_column_to_activity_log_table.php]  DONE
  Copying file [vendor/spatie/laravel-activitylog/database/migrations/add_batch_uuid_column_to_activity_log_table.php.stub] to [database/migrations/2023_09_21_024516_add_batch_uuid_column_to_activity_log_table.php]  DONE

このように、configはさることながらmigrationがコピーされる

        database/migrations/2023_09_21_024514_create_activity_log_table.php
        database/migrations/2023_09_21_024515_add_event_column_to_activity_log_table.php
        database/migrations/2023_09_21_024516_add_batch_uuid_column_to_activity_log_table.php

DBとかの設定

ってわけなのでmigrateする。まあ今回はfreshしてしまっている

% ./vendor/bin/sail artisan migrate:fresh --seed

  Dropping all tables .............................................................................. 160ms DONE

   INFO  Preparing database.

  Creating migration table .......................................................................... 31ms DONE

   INFO  Running migrations.

  2014_10_12_000000_create_users_table .............................................................. 67ms DONE
  2014_10_12_100000_create_password_reset_tokens_table .............................................. 74ms DONE
  2019_08_19_000000_create_failed_jobs_table ........................................................ 52ms DONE
  2019_12_14_000001_create_personal_access_tokens_table ............................................. 95ms DONE
  2023_09_18_101626_create_permission_tables ....................................................... 682ms DONE
  2023_09_21_024514_create_activity_log_table ...................................................... 110ms DONE
  2023_09_21_024515_add_event_column_to_activity_log_table .......................................... 26ms DONE
  2023_09_21_024516_add_batch_uuid_column_to_activity_log_table ..................................... 26ms DONE

ミドルウェアの設定

全ての事象を記録するとかいう場合はmiddlewareを新設するのがいい

% ./vendor/bin/sail artisan make:middleware RecordActivity

   INFO  Middleware [app/Http/Middleware/RecordActivity.php] created successfully.

app/Http/Middleware/RecordActivity.php

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Auth;

class RecordActivity
{
    /**
     * Handle an incoming request.
     *
     * @param  \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response)  $next
     */
    public function handle(Request $request, Closure $next): Response
    {
        activity()
            ->causedBy(Auth::user())
            ->withProperties([
                'url' => $request->fullUrl(),
                'method' => $request->method(),
                'ip' => $request->ip(),
                'agent' => $request->userAgent(),
            ])
        ->log('User activity');

        return $next($request);
    }
}

そしたら登録する

app/Http/Kernel.php

    protected $middlewareGroups = [
        'web' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            \App\Http\Middleware\VerifyCsrfToken::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
            \App\Http\Middleware\HandleInertiaRequests::class,
            \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
            \App\Http\Middleware\RecordActivity::class,
        ],

確認

この状態でmiddlewareが発動して

        activity()
            ->causedBy(Auth::user())
            ->withProperties([
                'url' => $request->fullUrl(),
                'method' => $request->method(),
                'ip' => $request->ip(),
                'agent' => $request->userAgent(),
            ])

されている。ベタに確認UIを作ってみよう

確認UI

users.showに作ってあげればいいかも。

まず、こちらもlast login atを追加した

import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link } from '@inertiajs/react';
import { VscVerifiedFilled, VscUnverified } from "react-icons/vsc";

import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/ja';
dayjs.extend(relativeTime);
dayjs.locale('ja'); // TODO ja-fix

export default function UserShow({ auth, user }) {

で、この塊

<div>
    <h3 className="text-lg font-medium leading-6 text-gray-900">Last Login At</h3>
    <p className="mt-2 text-lg text-gray-500">
        {user.last_login_at ? (
            <>
            <span>{dayjs(user.last_login_at).format('YYYY-MM-DD HH:mm:ss')}</span>
            <small className="ml-2 text-sm text-gray-600">({dayjs(user.last_login_at).fromNow()})</small>
            </>
        ) : (
            <span className="text-gray-500">Never</span>
        )}
    </p>
    <div>
        <Link href={route('users.show', user.id)} className="text-blue-600 hover:text-blue-900">
            <button className="bg-blue-500 hover:bg-blue-700 text-white py-1 px-2 rounded flex items-center">
                View Activity Log
            </button>
        </Link>
    </div>
</div>

そうするとまあこんな感じになるだろう。

activity logのrouteとかもろもろ

とりあえず今回は非常に雑に作るので、実際にはログがたまってくるとつかいもんにならん気もしますナ。そんときはカスタムしてみてください。

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

    Route::resource('users', UserController::class);
    Route::get('users/{user}/activity-log', [UserController::class, 'showActivityLog'])->name('users.activity-log');
});

こんな感じで追加して

app/Http/Controllers/UserController.php

    /**
     * Show user's activity log
     */
    public function showActivityLog(User $user)
    {
        dd($user);
    }
<Link href={route('users.activity-log', user.id)} className="text-blue-600 hover:text-blue-900">
    <button className="bg-blue-500 hover:bg-blue-700 text-white py-1 px-2 rounded flex items-center">
        View Activity Log
    </button>
</Link>

こんな感じでLink先を変更し View Activity Log を押したらユーザーの情報が出てきたらとりあえず前段okだ。

showActivityLog の実装

これは単純にviewを返しとけばokだけど、userとactivityはわけといた方がよさそう。

    public function showActivityLog(User $user): Response
    {
        $activities = $user->activities()->orderBy('created_at', 'desc')->get();

        return Inertia::render('Users/ActivityLog', [
            'user' => $user,
            'activities' => $activities,
        ]);

もちろん

        $user->load(['activities' => function ($query) {
            $query->orderBy('created_at', 'desc');
        }]);

こういったloadの方法もあるんだけど、あえてそうする必要はない。というのもpaginateし辛くなるから。

長くなってきたんで解説もなしにコンポーネントを貼りつけ。まあわかるでしょう
resources/js/Pages/Users/ActivityLog.jsx

import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head } from '@inertiajs/react';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/ja';
dayjs.extend(relativeTime);
dayjs.locale('ja'); // TODO ja-fix

export default function ActivityLog({ auth, user, activities }) {
    return (
        <AuthenticatedLayout
            user={auth.user}
            header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">Activity Log</h2>}
        >
            <Head title="User Activity Log" />

            <div className="py-12">
                <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
                    <div className="bg-white overflow-hidden shadow-sm sm:rounded-lg p-4">
                        <section>
                            <header>
                                <h2 className="text-lg font-medium text-gray-900">User: {user.name}</h2>
                            </header>

                            <div className="mt-6">
                                {activities && activities.length > 0 ? (
                                    <table className="min-w-full divide-y divide-gray-200">
                                        <thead className="bg-gray-50">
                                            <tr>
                                                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
                                                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
                                                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created At</th>
                                                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Properties</th>
                                            </tr>
                                        </thead>
                                        <tbody className="bg-white divide-y divide-gray-200">
                                            {activities.map((activity, index) => (
                                                <tr key={index}>
                                                    <td className="px-6 py-4 whitespace-nowrap">{activity.id}</td>
                                                    <td className="px-6 py-4 whitespace-nowrap">{activity.description}</td>
                                                    <td className="px-6 py-4 whitespace-nowrap">
                                                        <span>{dayjs(activity.created_at).format('YYYY-MM-DD HH:mm:ss')}</span>
                                                        <small className="ml-2 text-sm text-gray-600">({dayjs(activity.created_at).fromNow()})</small>
                                                    </td>
                                                    <td className="px-6 py-4 whitespace-nowrap">
                                                        <table className="min-w-full divide-y divide-gray-200">
                                                            <tbody className="bg-white divide-y divide-gray-200">
                                                                {Object.entries(activity.properties).map(([key, value], idx) => (
                                                                    <tr key={idx}>
                                                                        <td className="px-2 py-1 text-sm text-gray-500">{key}</td>
                                                                        <td className="px-2 py-1 text-sm text-gray-900">{value}</td>
                                                                    </tr>
                                                                ))}
                                                            </tbody>
                                                        </table>
                                                    </td>
                                                </tr>
                                            ))}
                                        </tbody>
                                    </table>
                                ) : (
                                    <p className="text-gray-500">No activities recorded.</p>
                                )}
                            </div>
                        </section>
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );
}


さすがにpaginateくらいはしたい

かもしれないし、そうでないかもしれんが、ここまでpaginateを実装してなかったので、サンプルの意味でもやっとこう

    public function showActivityLog(User $user): Response
    {
        $activities = $user->activities()->orderBy('created_at', 'desc')->paginate(5);

このようにgetからpaginateに変更する。引数は表示したい件数である。とりあえずテストする場合は小さい単位でもいいだろう。

そうすると、どうしてもtoArrayするとactivitiesという渡し方ではjsでは取得できないのでactivities.dataに変更する必要がある。具体的には

{activities.data && activities.data.length > 0 ? (

{activities.data.map((activity, index) => (

とか。これで一応表示は5件に絞られたはずだ

でpagerをあとは置いてやればok

{activities.links && (
    <nav className="flex justify-end space-x-4 my-4">
        {activities.links.map((link, index) => (
            <Link
                key={index}
                href={link.url || '#'}
                className={
                    link.active
                        ? 'px-4 py-2 bg-blue-500 text-white border border-blue-500 rounded'
                        : 'px-4 py-2 hover:underline border border-transparent rounded hover:border-gray-300'
                }
            >
                <span dangerouslySetInnerHTML={{ __html: link.label }} />
            </Link>
        ))}
    </nav>
)}


こういうのは最終的にコンポーネントにしたらいいと思いますよ。

breadcrumbs route

Breadcrumbs::for('users.activity-log', function (BreadcrumbTrail $trail, User $user) {
    $trail->parent('users.show', $user);
    $trail->push(__('Activity'), route('users.activity-log', $user));
});

こんな感じで書くだけ

次回

まあここまででユーザー管理としては結構納得できるものになってきてるんじゃまいか(作成と更新ができないとはいえ、見る側では必要にして十分な機能である)。次回は一般ユーザーが管理機能に入ってくるのを防いだりそのテストとかやろう。

この記事が気に入ったらサポートをしてみませんか?