【laravel】管理ページを作ってみる

初書：2021/09/13
mac : 11.5.2
php：v8.0.6
laravel：v8.57.0

# 前書き

ユーザーのログインページとは別に、記事の作成やユーザーの管理などが出来る管理者ページを作ってみる。
laravel-adminというライブラリはあったものの、最新版に公式対応していないっぽいので、今回は普通にlaravelの機能で考える

# 前提

- laravelの初期ページが表示できる環境とその準備

# 目標

以下の項目の作成

- データベース
- ログインシステム
- ログインページ
- 簡易的なダッシュボード
- アカウント登録システム
- アカウント登録ページ

以下はこの記事ではやらない

- パスワードの変更/リセット
- メール認証/送信
- adminアカウントの管理ページ

# データベース周りの作成

今後この先１つのアカウントで管理することが確約されているのであれば、envファイルにでも認証情報を書けばいいが、
そんなことはないと思うので管理者アカウントを管理するデータベーステーブルを作成する。

今回は通常のユーザーデータがusersというテーブル名なので、admin_usersというテーブルを作成する

## Migrationの作成

```zsh
% php artisan make:migration create_admin_users_table
```

成功すれば、`database/migrations/2021_MM_dd_hhmmss_create_admin_users_table.php`(ファイル名の日付はその時次第)が出来ているはず
出来ていれば、テーブルを作成するための列を作成する

```php:database/migrations/2021_MM_dd_hhmmss_create_admin_users_table.php
    public function up()
    {
        Schema::create('admin_users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->integer('admin_level');
            $table->rememberToken();
            $table->timestamps();
        });
    }
```

今回はuserテーブルを真似て作成。`admin_level`というintegerが存在するが、これは管理アカウントのアクセスレベルを予定している。
0でログイン不可、100で全ての権限持ち…とか、2進数で可否を載せるとか…？

これができればデータベース内にテーブルを作成する

```zsh
% php artisan migrate
```

## Modelの作成

次に、操作しやすくするためのModelを作成する

```zsh
% php artisan make:model AdminUser
```

`app/Models/AdminUser.php`ができるので、中身を書き換える。
なお、login周りのModelは、`Illuminate\Contracts\Auth\Authenticatable`で実装する必要があるらしいので、敬称など一部も追加変更する

```php:app/Models/AdminUser.php
use Illuminate\Foundation\Auth\User as Authenticatable;

class AdminUser extends Authenticatable
{
    use HasFactory;

    protected $table = 'admin_users';

    protected $fillable = [
        'name',
        'email',
        'password',
        'admin_level',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
    ];
}
```

`User.php`とほぼ同じです。
先ほど足した`admin_level`と`$table`だけ付け足し。

これは個人的メモ：
`fillable`は配列による複数同時に変更してもいい列の一覧。
[[Laravel 5.7] Eloquent Modelのfillableとguardedの違い - Qiita](https://qiita.com/mmmmmmanta/items/74b6891493f587cc6599)

`hidden`はModelを使って取得する際に一覧として出力させない列。

`casts`は出力する際にstringではなくそれぞれの型に変換してから取得する。

## providerの追加

普通のログインと同じような感じで認証させるために、providerとguardを記述する。
これはどちらも`config/auth.php`

```php:config/auth.php
    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],
        'admin_users' => [ // ここ追加
            'driver' => 'eloquent',
            'model' => App\Models\AdminUser::class, //make:modelしたクラス名
        ],
    ],

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        'admin' => [   // ここ追加
            'driver' => 'session',
            'provider' => 'admin_users', // providerに追加した名前
        ],
    ],
```

ちなみに両方を簡単に説明すると、
`provider`がどの方法でユーザー情報を管理しているかを指定、
`guards`が`Auth`ファザードで呼び出すための設定をする、感じ。

いい感じに分かりやすく解説してくれてるところ→[Laravel の Guard（認証） って実際何をやっているのじゃ？ - Qiita](https://qiita.com/tomoeine/items/40a966bf3801633cf90f)

# ログインページ周りの作成

データベース周りの作成が終わったので、次はログインを行う画面を作成していく。

## コントローラーを作成

まずは、ログイン処理を行うコントローラーを作成する。
もしJetstreamなどを先に使用している場合は、LoginControllerは既に存在しているかもしれない。

```zsh
% php artisan make:controller LoginController
```

`app/Http/Controllers/LoginController.php`が出来るので、この中身を編集していく。

```php:app/Http/Controllers/LoginController.php
    /**
     * 認証の試行を処理
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function adminLogin(Request $request)
    {
        $credentials = $request->validate([ // 入力内容のチェック
            'email' => ['required', 'email'],
            'password' => ['required'],
        ]);

        if (Auth::guard('admin')->attempt($credentials)) { // ログイン試行
            if ($request->user('admin')?->admin_level > 0) { // 管理権限レベルが0でないか
                $request->session()->regenerate(); // セッション更新

                return redirect()->intended('admin/dashboard'); // ダッシュボードへ
            } else {
                Auth::guard('admin')->logout(); // if文でログインしてしまっているので、ログアウトさせる

                $request->session()->regenerate(); // セッション更新

                return back()->withErrors([ // 権限レベルが0の場合
                    'error' => 'You do not have permission to log in.',
                ]);
            }
        }

        return back()->withErrors([ // ログインに失敗した場合
            'error' => 'The provided credentials do not match our records.',
        ]);
    }
```

intelephense使ってるとattemptでエラー出るが、間違えてはいない。

入力内容をチェックしてログインを行う。
ログイン後に管理レベルのチェックを入れ、もし権限が与えられていない場合はログインさせないようにしている。

## blade.phpの作成

次にいよいよ表示の方を作成する。今回は`adminLogin.blade.php`というadmin専用のログインフォームを作成する。

```php:resources/views/adminLogin.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Laravel</title>
    </head>
    <body>

        @if ($errors->any())  {{--  エラーがあれば出力する --}}
            @foreach ($errors->all() as $error)
                <div>{{ $error }}</div>
            @endforeach
        @endif

        <form method="POST" action="{{ route("admin.login") }}">  {{--  routeはここと同じ --}}
            @csrf
            <label for="email">Mail</label>
            <input type="text" id="email" name="email">
            <label for="password">Password</label>
            <input type="password" id="password" name="password">
            <button type="submit">Login</button>
        </form>
    </body>
</html>
```

css一切入ってない非常にシンプルなForm。
でもログインページ作成のところでcss混ぜられたら見にくくないですか？その装飾おそらく使わないのに。

## web.phpに記述

あとはログインページにアクセスできるように`routes/web.php`を書き換える。

```php:routes/web.php
Route::get('/admin/login', function () {
    return view('adminLogin'); // blade.php
});

Route::post('/admin/login', [\App\Http\Controllers\LoginController::class, 'adminLogin'])->name('admin.login');
```

getとpostで動作が若干変わるため、それぞれを記述している。
また、postの方では先ほど作ったコントローラー内の関数を呼び出し、
また`adminLogin.blade.php`内で`route`を使って呼び出せるように、`name`を振った。

# 一度動作確認する

ここまでできれば、一度動作しているか確認してみる。

```php
% php artisan serve
```

これで`http://127.0.0.1:8000/admin/login`にログインし、~~とてもシンプルな~~ログイン画面が表示されるはず。
また実際にログインし、(アカウント情報は存在していないため)`The provided credentials do not match our records.`と表示されれば成功。

# テストアカウントの発行

実際にログインを試みるため、テストアカウントを作成する。
今回はseederを使ってみる。

```zsh
% php artisan make:seeder AdminUserSeeder
```

実行すると、`database/seeders/AdminUserSeeder.php`ができるので、これを編集する。

```php:database/seeders/AdminUserSeeder.php
    public function run()
    {
        DB::table('admin_users')->insert([
            'name' => 'owner',
            'email' => 'owner@example.com',
            'password' => Hash::make('password'),
            'admin_level' => 1,
        ]);
        DB::table('admin_users')->insert([
            'name' => 'sub',
            'email' => 'sub@example.com',
            'password' => Hash::make('password'),
            'admin_level' => 0,
        ]);
    }
```

そしたら、データを挿入するために以下を実行する。

```zsh
% php artisan db:seed --class=AdminUserSeeder
```

# 再度動作確認

再びサーバーを立ててみて、実際にログインできるか確認する。
この記事通りに作成していた場合は、`admin_level`が0の場合はログインできないので、それをチェックするため、先にsubのログインを試みる。
(ログアウトの処理を入れていないため)
`sub@example.com`でログインした時、`You do not have permission to log in.`と表示されれば成功。
また`owner@example.com`でログインした時、`admin/dashboard`に移動すれば成功。

…いや、成功していなかった。ログインしていてもログインページにアクセスできる。

# ログイン済みのリダイレクト

ログインしている時はログイン画面ではなく、先程の`admin/dashboard`に飛ぶようにする。

## ミドルウェアの追加

まずは`app/Http/Controllers/LoginController.php`に追記する。

```php:app/Http/Controllers/LoginController.php
    public function __construct()
    {
        $this->middleware('guest:admin')->except('adminLogout');
    }
```

簡単に説明すると、コントローラーのコンストラクタは、関数を呼び出す前に呼び出されるため、そこでミドルウェア[^1]を指定する。
今回は、`guest`ミドルウェアを呼び出し、引数として`admin`を渡す。
`except`は例外。これを指定した場合は、ミドルウェアを通過しない。
後にログアウト画面を作成するため、先にそれを除外しておく。

[^1]: https://readouble.com/laravel/8.x/ja/middleware.html

次に、`routes/web.php`で先ほど書いたコードに追記する。

```php:routes/web.php
Route::get('/admin/login', function () {
    return view('adminLogin');
})->middleware('guest:admin'); // ここ
```

これは、ログイン時にpostする際は上記のコンストラクターを挟むが、普通にgetでアクセスする際はコントローラーを使わないので、
ここで別途設定する必要がある。
…というか、postも別にここで設定していいような気がした。もしくはgetの方もコントローラーに追加するか。

## リダイレクト先の追加

このままアクセスすると、`\home`に飛ばされてしまう。
そのため、`admin/dashboard`を設定する。

ミドルウェアの`guest`は`\App\Http\Middleware\RedirectIfAuthenticated::class`が定義されているため、ここを変更する

```php:app/Http/Middleware/RedirectIfAuthenticated.php
        foreach ($guards as $guard) {
            if (Auth::guard($guard)->check()) {
                if ($guard === 'admin') { // 以下3行追記する
                    return redirect(RouteServiceProvider::ADMIN_HOME);
                }
                return redirect(RouteServiceProvider::HOME);
            }
        }
```

`ADMIN_HOME`は定義されていないので、定義する。

```php:app/Providers/RouteServiceProvider.php
    public const ADMIN_HOME = '/admin/dashboard';
```

これで完了なのだが、せっかくなので`/admin/dashboard`が記述されているもう一箇所も書き換えておく。

```diff_php:app/Http/Controllers/LoginController.php
+ use App\Providers\RouteServiceProvider;
// (略)
-               return redirect()->intended('admin/dashboard');
+               return redirect()->intended(RouteServiceProvider::ADMIN_HOME);
```

ちなみにこちらが`redirect()->intended()`となっているのは、意図されたリダイレクトだからだそう。
実際にどう処理が変化するのか詳しくは調べれていない。(ので情報求む、と言えば果たして集まるのだろうか…)

これで、無事にリダイレクトされるようになる。

# ログアウト処理の追加

ログインテストでログアウトする方法が(キャッシュを消すしか)なかったので、追加する。

```php:app/Http/Controllers/LoginController.php
    public function adminLogout(Request $request)
    {
        Auth::guard('admin')->logout();

        $request->session()->invalidate();

        $request->session()->regenerateToken();

        return redirect('/admin/login');
    }
```

```php:routes/web.php
Route::get('/admin/logout', [\App\Http\Controllers\LoginController::class, 'adminLogout'])->name('admin.logout');
```

ログインと同じなので特に説明はいいかなーと。
ログアウトの際に、セッションの無効化とCSRFトークンを再生成することが推奨されているので、それをしておく。
逆にログアウトしてもセッションが必要な場合は気をつけないといけない。

これでログアウト画面に行くとログアウトされ、`/admin/login`にリダイレクトされる。
ログアウトしました、と表示されるページを別途作ってもいいかもしれない。

# ダッシュボードの作成

## blade.phpの作成

まずは`adminDashboard.blade.php`を作成し、以下を記述

```php:resources/views/adminDashboard.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>ダッシュボード</title>
    </head>
    <body class="antialiased">
        <ul>
            <li>
                ログイン中：{{ Auth::guard('admin')->user()->name ?? 'undefined' }}
            </li>
            <li>
                <a href="{{ route('admin.logout') }}">
                    ログアウト
                </a>
            </li>
        </ul>
    </body>
</html>
```

シンプルすぎてリストの使い方を間違えるほど。デザインはお任せします。

簡単に説明すると、`Auth::guard('admin')->user()`でログイン情報が取得できるので、その`name`を表示している。
また２段目はログアウトのリンク。

## ルート設定

```php:routes/web.php
Route::get('/admin/dashboard', function () {
    return view('adminDashboard');
})->middleware('auth:admin');
```

今度はログイン中の時しか表示させないので、ミドルウェアは`auth:admin`を使用する。

## リダイレクト先の指定

`auth`の定義は`app/Http/Middleware/Authenticate.php`にあるので、ここを変更する

```php:app/Http/Middleware/Authenticate.php
    protected function redirectTo($request) // ※このコードは後に使わない
    {
        if (! $request->expectsJson()) {
            return url('/');
        }
    }
```

ログインしていなければ`/`に飛びます。
普通であれば`admin/login`を指定すればいいのだが、一つ大きな問題がある。

`Authenticate.php`の`redirectTo`は、`guard`の情報を取得できない。

`RedirectIfAuthenticated.php`の時のように、引数に`$guards`を指定できればいいのだが、
関数名がhandleではない時点でお察しの通り、このクラスは継承元の`Illuminate\Auth\Middleware\Authenticate`でhandleが定義されている。
そして、ここでは定義されている`$guard`が、何故かredirectToに届いていない。
そのため、リダイレクトを分岐させることができなかった。

と、書いてて思いついたのだが、その前のコードも上書きしてしまえばいいのでは？

```php:app/Http/Middleware/Authenticate.php
    protected function unauthenticated($request, array $guards)
    {
        throw new AuthenticationException(
            'Unauthenticated.',
            $guards,
            $this->redirectToOriginal($request, $guards)
        );
    }

    protected function redirectToOriginal($request, array $guards)
    {
        foreach ($guards as $guard) {
            if ($guard === 'admin') {
                return route('admin.login');
            }
        }
    }
```

いけた。`redirectTo`は継承の都合上書き換えられないので、`redirectToOriginal`を使用する。

これで、ログインしていない時にダッシュボードを踏むと、`admin/login`に飛ばされる。

# アカウント登録画面の作成

長くないですか？そろそろ疲れてきた。
でももう少し続きます。

次はアカウントを登録する画面とその仕組みを作成する。
管理画面のアカウントなので、一般公開する必要もないかなと思い、今回はログイン済みのアカウントが新規にアカウントを発行する仕組みを作る。

## コントローラーの作成

データベースの登録とかを行うコントローラー。

```zsh
% php artisan make:controller RegisterController
```

次に中身を書いていく。

```php:app/Http/Controllers/RegisterController.php
// 前略
use Illuminate\Support\Facades\Validator;
use App\Models\AdminUser;
use Illuminate\Validation\Rules\Password;
use Illuminate\Foundation\Auth\RegistersUsers;

class RegisterController extends Controller
{
    use RegistersUsers;

    public function adminRegisterForm(Request $request)
    {
        return view('adminRegister');
    }

    protected function adminValidator(array $data)
    {
        return Validator::make($data, [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:App\Models\AdminUser'],
            'password' => ['required', 'confirmed', Password::min(8)],
            'admin_level' => ['required', 'numeric'],
        ]);
    }

    protected function adminRegisterDatabase(array $data)
    {
        return AdminUser::create([
            'name' => $data['name'],
            'email' => $data['email'],
            'password' => Hash::make($data['password']),
            'admin_level' => $data['admin_level'],
        ]);
    }

    public function adminRegister(Request $request)
    {
        $this->adminValidator($request->all())->validate();

        $user = $this->adminRegisterDatabase($request->all());

        if ($user) {
            return view('adminRegister', ['registered' => true, 'registered_email' => $user->email]);
        }
    }
// 後略
```

唐突の関数が連続。
まあ一つでも書けるが、結構分割してるサイトが多かったので見習って分割。あと１関数１作業(？)とか言うし。

`adminRegisterForm`はblade.phpを表示。ログインの時はweb.phpに直接記述したが、今回はこちらに書いてみる。
`adminValidator`はValidatorの作成。
`adminRegisterDatabase`はデータベースに挿入する。
`adminRegister`がリクエストを受け付ける場所で、上記２つの関数を使ってデータを登録作業を行なっている。
あと成功したら成功したよーと表示する。

## blade.phpの作成

登録画面。

```php:resources/views/adminRegister.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>登録</title>
</head>

<body class="antialiased">

    @if ($errors->any())
        @foreach ($errors->all() as $error)
            <div>{{ $error }}</div>
        @endforeach
    @endif

    @isset($registered)
        <div>登録に成功しました。メールアドレス：{{ $registered_email }}</div>
    @endisset

    <form method="POST" action="{{ route('admin.register') }}">
        @csrf
        <div>
            <label for="name">Name</label>
            <input type="text" id="name" name="name">
        </div>
        <div>
            <label for="email">Mail</label>
            <input type="text" id="email" name="email">
        </div>
        <div>
            <label for="password">Password</label>
            <input type="password" id="password" name="password">
        </div>
        <div>
            <label for="password_confirmation">Password(confirmed)</label>
            <input type="password" id="password_confirmation" name="password_confirmation">
        </div>
        <div>
            <label for="admin_level">AdminLevel</label>
            <input type="text" id="admin_level" name="admin_level">
        </div>
        <div>
            <button type="submit">Register</button>
        </div>
    </form>
</body>
</html>
```

項目が多かったのでdivで囲んだ。
先程の`adminValidator`でpasswordに`confirmed`を設定しているため、同名の`confirmed`を設定する必要がある。
あと`AdminLevel`は記事内に定期的に出てくるが、これはご自由にお使いください。[^3]

[^3]: 権限レベルって数値で表すものなのか？と薄々思っている。

## ルート設定

```php:routes/web.php
Route::get('/admin/register', [\App\Http\Controllers\RegisterController::class, 'adminRegisterForm'])->middleware('auth:admin');

Route::post('/admin/register', [\App\Http\Controllers\RegisterController::class, 'adminRegister'])->middleware('auth:admin')->name('admin.register');
```

今回は、ログイン済みのアカウントで新規アカウントを作成するので、`middleware`は`auth`を選択する。
誰でも登録できる時は、ログインと同じで`guest`にする。

また、`adminDashboard.blade.php`にアカウント作成ページへ行けるリンクを追加する。

```php:resources/views/adminDashboard.blade.php
            <li>
                <a href="{{route('admin.register')}}">
                    アカウント作成
                </a>
            </li>
```

# 再度動作確認

`admin/register`にアクセスし、

- アカウントを新規作成できるか
- 作成したアカウントでログインできるか

など、今回作成したものが一通り動くかを確認すればok。

# 終わりに

やりたいこと一つで、一通り周りの動き方を理解しないといけないので、
最初から全体的に把握してないと作れないなあと。
逆にいえば結構laravelの動作が分かってきたような気もする。

今回はここで一旦切るが、

- メールの確認
- パスワードの変更/リセット
- アカウントの管理

等が出来ていないので、その辺も後に書いていくかも。

もし変なところや間違えているところ、改善できる場所があれば教えてください。
逆に合っている場合も合ってると教えてくれると非常に助かります…！[^4]
では！

[^4]: これ改めて考えてみると、ないことの証明と同等に難しい問題なのでは。

# 参考サイト

[Laravel 8でマルチ認証して一般ログインと管理ログインを分ける方法(UI方式、非Jetstream、非Fortify、非Breeze) - Qiita](https://qiita.com/hitotch/items/45c70110291f725d87c4)

laravel日本語訳サイトより
[Eloquentの準備 8.x Laravel](https://readouble.com/laravel/8.x/ja/eloquent.html)
[認証 8.x Laravel](https://readouble.com/laravel/8.x/ja/authentication.html)
[ミドルウェア 8.x Laravel](https://readouble.com/laravel/8.x/ja/middleware.html)



フォームで
[[Laravel 5.7] フォーム認証処理の仕組み - Qiita](https://qiita.com/mmmmmmanta/items/d2327169b470ea926463)