diff --git a/CLAUDE.md b/CLAUDE.md
index 57d1e35..9ebd373 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -150,3 +150,31 @@ Bearbeitung inline gebaut, obwohl das Muster danebenlag.
`tests/Feature/EditInModalTest.php` — kein Seiten-Blade darf ein Eingabefeld in
einem `
` wachsen lassen, und die Benutzertabelle muss `edit-seat` per
`openModal` öffnen.
+
+---
+
+## R21 — Konsole und Portal teilen keine Identität
+
+**Betreiber und Kunden sind zwei Personengruppen, nicht zwei Zustände einer.**
+
+Verboten:
+
+1. **Eine Rolle oder Berechtigung am `User`.** Alle siebzehn Berechtigungen sind
+ Konsolen-Berechtigungen; sie liegen am `operator`-Guard. Ein `users`-Datensatz
+ mit Konsolenzugang ist die Vermischung in klein.
+2. **Eine Auth-Route, die beide Seiten bedient.** `RestrictAdminHost::SHARED`
+ führt nur noch `livewire/*` und `up`.
+3. **Eine Anmeldeansicht für beide.** Das Portal hat Fortify, die Konsole hat
+ `App\Livewire\Auth\OperatorLogin`.
+
+### Warum das aufgeschrieben wurde
+
+Die Konsole lieferte die Anmeldeseite des Portals aus — mit „Kein Konto?
+Registrieren" darauf. `register` stand nicht in `SHARED` und konnte dort auch
+nicht stehen, weil eine Konsole keine Selbstregistrierung hat. Der Link führte
+also zwangsläufig in eine 404. Das war kein Anzeigefehler, sondern die Identität
+zweier Personengruppen in einer Tabelle.
+
+### Erzwungen durch
+
+`tests/Feature/IdentitySeparationTest.php`
diff --git a/app/Console/Commands/CreateAdmin.php b/app/Console/Commands/CreateAdmin.php
index 7ace7d1..772a2e9 100644
--- a/app/Console/Commands/CreateAdmin.php
+++ b/app/Console/Commands/CreateAdmin.php
@@ -3,7 +3,7 @@
namespace App\Console\Commands;
use App\Models\Customer;
-use App\Models\User;
+use App\Models\Operator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
@@ -16,14 +16,20 @@ use Spatie\Permission\PermissionRegistrar;
*
* Promotion is deliberate: running this twice with the same address should fix
* a lost role, not fail with "email already taken".
+ *
+ * Renamed from clupilot:create-admin now that the account this creates is an
+ * Operator, not a User with is_admin set — the old name stays reachable as an
+ * alias so existing install scripts and muscle memory keep working.
*/
class CreateAdmin extends Command
{
- protected $signature = 'clupilot:create-admin
+ protected $signature = 'clupilot:create-operator
{--email= : Login address}
{--name= : Display name}
{--password= : Leave empty to be prompted}';
+ protected $aliases = ['clupilot:create-admin'];
+
protected $description = 'Create or promote an operator account with the Owner role';
public function handle(): int
@@ -57,17 +63,15 @@ class CreateAdmin extends Command
return self::FAILURE;
}
- $user = User::query()->firstOrNew(['email' => $email]);
- $existed = $user->exists;
+ $operator = Operator::query()->firstOrNew(['email' => $email]);
+ $existed = $operator->exists;
- $user->fill([
+ $operator->fill([
'name' => $name,
'password' => Hash::make($password),
- 'is_admin' => true,
- 'email_verified_at' => $user->email_verified_at ?? now(),
])->save();
- $user->syncRoles(['Owner']);
+ $operator->syncRoles(['Owner']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->info($existed
diff --git a/tests/Feature/IdentitySeparationTest.php b/tests/Feature/IdentitySeparationTest.php
new file mode 100644
index 0000000..af43cb5
--- /dev/null
+++ b/tests/Feature/IdentitySeparationTest.php
@@ -0,0 +1,61 @@
+count())->toBe(0)
+ ->and(Permission::where('guard_name', 'web')->count())->toBe(0);
+});
+
+it('gives the User model no way to hold a role', function () {
+ expect(method_exists(User::class, 'hasRole'))->toBeFalse()
+ ->and(method_exists(User::class, 'isOperator'))->toBeFalse();
+});
+
+it('cannot authenticate an operator on the web guard', function () {
+ $operator = Operator::factory()->role('Owner')->create(['password' => 'operator-passwort']);
+
+ expect(Auth::guard('web')->attempt([
+ 'email' => $operator->email, 'password' => 'operator-passwort',
+ ]))->toBeFalse();
+});
+
+it('cannot authenticate a portal user on the operator guard', function () {
+ $user = User::factory()->create(['password' => 'kunden-passwort']);
+
+ expect(Auth::guard('operator')->attempt([
+ 'email' => $user->email, 'password' => 'kunden-passwort',
+ ]))->toBeFalse();
+});
+
+it('shares no authentication route between the two sides', function () {
+ $shared = (new ReflectionClass(App\Http\Middleware\RestrictAdminHost::class))
+ ->getConstant('SHARED');
+
+ foreach (['login', 'logout', 'register', 'two-factor-challenge'] as $path) {
+ expect($shared)->not->toContain($path);
+ }
+});
+
+it('has no console-aware login response left, because there is nothing to disambiguate', function () {
+ // A third class lived here too: ConsoleAwareTwoFactorLoginResponse, Fortify's
+ // TwoFactorLoginResponse-contract sibling of ConsoleAwareLoginResponse, for
+ // the two-factor completion path (see the deletion in 8f8a0ad — every one of
+ // the three landing-disambiguation classes went together, not just these two).
+ // Checking two of the three would let the third quietly come back.
+ expect(class_exists(App\Http\Responses\ConsoleAwareLoginResponse::class))->toBeFalse()
+ ->and(class_exists(App\Http\Responses\ConsoleAwareTwoFactorLoginResponse::class))->toBeFalse()
+ ->and(class_exists(App\Http\Responses\LandsWhereSignedIn::class))->toBeFalse();
+});
|