Give the people who run CluPilot a table of their own

feat/operator-identity
nexxo 2026-07-28 09:20:54 +02:00
parent 2a06b5dfb2
commit 8d1fa52a5a
5 changed files with 199 additions and 0 deletions

57
app/Models/Operator.php Normal file
View File

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

View File

@ -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,
],
], ],
/* /*

View File

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

View File

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

View File

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