Give the people who run CluPilot a table of their own
parent
2a06b5dfb2
commit
8d1fa52a5a
|
|
@ -0,0 +1,57 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\HasUuid;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An operator: the owner of CluPilot, or a member of staff.
|
||||||
|
*
|
||||||
|
* Extends Authenticatable rather than App\Models\User — sharing the class would
|
||||||
|
* put both groups back in one table, which is the thing this exists to end.
|
||||||
|
*
|
||||||
|
* `$guard_name` is what makes Spatie resolve roles against the `operator` guard.
|
||||||
|
* Without it every role assignment would be checked against `web` and fail,
|
||||||
|
* which is the failure mode to expect if roles suddenly stop matching.
|
||||||
|
*/
|
||||||
|
class Operator extends Authenticatable
|
||||||
|
{
|
||||||
|
use HasFactory, HasRoles, HasUuid, Notifiable, TwoFactorAuthenticatable;
|
||||||
|
|
||||||
|
/** Tells spatie/laravel-permission which guard this model's roles live under. */
|
||||||
|
protected string $guard_name = 'operator';
|
||||||
|
|
||||||
|
/** The six roles that grant access to the console. */
|
||||||
|
public const OPERATOR_ROLES = ['Owner', 'Admin', 'Support', 'Billing', 'Read-only', 'Developer'];
|
||||||
|
|
||||||
|
protected $fillable = ['name', 'email', 'password', 'last_login_at', 'disabled_at'];
|
||||||
|
|
||||||
|
protected $hidden = ['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'password' => 'hashed',
|
||||||
|
'last_login_at' => 'datetime',
|
||||||
|
'disabled_at' => 'datetime',
|
||||||
|
'two_factor_confirmed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when this operator holds a role that reaches the console. */
|
||||||
|
public function isOperator(): bool
|
||||||
|
{
|
||||||
|
return $this->hasAnyRole(self::OPERATOR_ROLES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A disabled operator keeps their record and loses their access. */
|
||||||
|
public function isActive(): bool
|
||||||
|
{
|
||||||
|
return $this->disabled_at === null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,15 @@ return [
|
||||||
'driver' => 'session',
|
'driver' => 'session',
|
||||||
'provider' => 'users',
|
'provider' => 'users',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// The console. Its own session cookie name is set in config/session.php
|
||||||
|
// is NOT enough — Laravel keeps one session per request, and both guards
|
||||||
|
// read from it under different keys, so a portal login and a console
|
||||||
|
// login coexist in one browser without overwriting each other.
|
||||||
|
'operator' => [
|
||||||
|
'driver' => 'session',
|
||||||
|
'provider' => 'operators',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -71,6 +80,11 @@ return [
|
||||||
// 'driver' => 'database',
|
// 'driver' => 'database',
|
||||||
// 'table' => 'users',
|
// 'table' => 'users',
|
||||||
// ],
|
// ],
|
||||||
|
|
||||||
|
'operators' => [
|
||||||
|
'driver' => 'eloquent',
|
||||||
|
'model' => App\Models\Operator::class,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -99,6 +113,13 @@ return [
|
||||||
'expire' => 60,
|
'expire' => 60,
|
||||||
'throttle' => 60,
|
'throttle' => 60,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'operators' => [
|
||||||
|
'provider' => 'operators',
|
||||||
|
'table' => 'password_reset_tokens',
|
||||||
|
'expire' => 60,
|
||||||
|
'throttle' => 60,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Operator;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/** @extends Factory<Operator> */
|
||||||
|
class OperatorFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Operator::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $this->faker->name(),
|
||||||
|
'email' => $this->faker->unique()->safeEmail(),
|
||||||
|
'password' => 'passwort-fuer-tests',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Give the operator a console role. Roles are seeded by migration. */
|
||||||
|
public function role(string $role = 'Owner'): static
|
||||||
|
{
|
||||||
|
return $this->afterCreating(fn (Operator $operator) => $operator->syncRoles([$role]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The people who run CluPilot: the owner and the staff.
|
||||||
|
*
|
||||||
|
* Their own table rather than a flag on `users`, because a flag lets the state
|
||||||
|
* "operator who is also a portal account" reappear at any time — and that state
|
||||||
|
* is why OperatorInPortalTest had to exist: an operator without a customer
|
||||||
|
* could wander into the portal and press buttons that did nothing. A separate
|
||||||
|
* table makes the class of fault impossible rather than caught.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('operators', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->uuid()->unique();
|
||||||
|
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('email')->unique();
|
||||||
|
$table->string('password');
|
||||||
|
$table->rememberToken();
|
||||||
|
|
||||||
|
// Same column names Fortify's TwoFactorAuthenticatable expects.
|
||||||
|
$table->text('two_factor_secret')->nullable();
|
||||||
|
$table->text('two_factor_recovery_codes')->nullable();
|
||||||
|
$table->timestamp('two_factor_confirmed_at')->nullable();
|
||||||
|
|
||||||
|
// Who is still active — the basis for the staff view that comes later.
|
||||||
|
$table->timestamp('last_login_at')->nullable();
|
||||||
|
|
||||||
|
// A member of staff who has left, without deleting the audit trail
|
||||||
|
// their id appears in.
|
||||||
|
$table->timestamp('disabled_at')->nullable();
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('operators');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Operator;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
it('authenticates on its own guard, not on web', function () {
|
||||||
|
$operator = Operator::factory()->create(['password' => 'geheim-genug-12']);
|
||||||
|
|
||||||
|
expect(Auth::guard('operator')->attempt([
|
||||||
|
'email' => $operator->email, 'password' => 'geheim-genug-12',
|
||||||
|
]))->toBeTrue();
|
||||||
|
|
||||||
|
// The web guard resolves against users, which has no such row.
|
||||||
|
expect(Auth::guard('web')->attempt([
|
||||||
|
'email' => $operator->email, 'password' => 'geheim-genug-12',
|
||||||
|
]))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hashes the password rather than storing it', function () {
|
||||||
|
$operator = Operator::factory()->create(['password' => 'geheim-genug-12']);
|
||||||
|
|
||||||
|
expect($operator->password)->not->toBe('geheim-genug-12')
|
||||||
|
->and(Hash::check('geheim-genug-12', $operator->password))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns a uuid on create and uses it as the route key', function () {
|
||||||
|
$operator = Operator::factory()->create();
|
||||||
|
|
||||||
|
expect($operator->uuid)->not->toBeNull()
|
||||||
|
->and($operator->getRouteKeyName())->toBe('uuid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the two-factor columns Fortify expects', function () {
|
||||||
|
$operator = Operator::factory()->create();
|
||||||
|
|
||||||
|
expect($operator->two_factor_secret)->toBeNull()
|
||||||
|
->and($operator->two_factor_confirmed_at)->toBeNull()
|
||||||
|
->and(method_exists($operator, 'twoFactorQrCodeSvg'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be disabled without deleting the record', function () {
|
||||||
|
$operator = Operator::factory()->create(['disabled_at' => now()]);
|
||||||
|
|
||||||
|
expect($operator->fresh()->disabled_at)->not->toBeNull();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue