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