diff --git a/app/Models/Operator.php b/app/Models/Operator.php new file mode 100644 index 0000000..d21bed7 --- /dev/null +++ b/app/Models/Operator.php @@ -0,0 +1,57 @@ + '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; + } +} diff --git a/config/auth.php b/config/auth.php index d7568ff..594aa33 100644 --- a/config/auth.php +++ b/config/auth.php @@ -42,6 +42,15 @@ return [ 'driver' => 'session', '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', // 'table' => 'users', // ], + + 'operators' => [ + 'driver' => 'eloquent', + 'model' => App\Models\Operator::class, + ], ], /* @@ -99,6 +113,13 @@ return [ 'expire' => 60, 'throttle' => 60, ], + + 'operators' => [ + 'provider' => 'operators', + 'table' => 'password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], ], /* diff --git a/database/factories/OperatorFactory.php b/database/factories/OperatorFactory.php new file mode 100644 index 0000000..f9846ae --- /dev/null +++ b/database/factories/OperatorFactory.php @@ -0,0 +1,27 @@ + */ +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])); + } +} diff --git a/database/migrations/2026_07_29_090000_create_operators_table.php b/database/migrations/2026_07_29_090000_create_operators_table.php new file mode 100644 index 0000000..654b191 --- /dev/null +++ b/database/migrations/2026_07_29_090000_create_operators_table.php @@ -0,0 +1,49 @@ +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'); + } +}; diff --git a/tests/Feature/Admin/OperatorModelTest.php b/tests/Feature/Admin/OperatorModelTest.php new file mode 100644 index 0000000..8a3b7a9 --- /dev/null +++ b/tests/Feature/Admin/OperatorModelTest.php @@ -0,0 +1,45 @@ +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(); +});