diff --git a/docs/superpowers/plans/2026-07-28-operator-identity.md b/docs/superpowers/plans/2026-07-28-operator-identity.md new file mode 100644 index 0000000..993f9a8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-operator-identity.md @@ -0,0 +1,2007 @@ +# Betreiber-Identität — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Die Konsole bekommt eine eigene Personengruppe — Inhaber und Mitarbeiter von CluPilot — in eigener Tabelle, mit eigenem Guard und eigener Anmeldung, getrennt von den Kundenkonten in `users`. + +**Architecture:** Neue Tabelle `operators` mit dem Guard `operator`; das gesamte Spatie-RBAC zieht von `web` nach `operator` um, statt sich zu verdoppeln. Fortify bleibt beim Portal; die Konsole bekommt eigene Anmelde- und 2FA-Bauteile, die Fortifys TOTP-Mechanik wiederverwenden, aber nicht seinen Ablauf. Damit fällt die geteilte Anmeldeseite weg — und mit ihr der Registrieren-Link und die 404. + +**Tech Stack:** Laravel 13.8, Livewire 3 (klassenbasiert, kein Volt), Fortify, spatie/laravel-permission 8.3, Pest, MariaDB 11.4, Redis, wire-elements/modal, Tailwind v4. Docker-first. + +## Global Constraints + +- **Zweigbasis: `feat/mailboxes`, nicht `main`.** Die Postfach-Arbeit ist gepusht, aber noch nicht gemergt, und dieser Plan ändert `app/Livewire/Admin/Mail.php`, `app/Livewire/EditMailbox.php` und `app/Livewire/Concerns/ConfirmsPassword.php` — alles Dateien von dort. Von `main` abzweigen erzeugt Konflikte in genau den Dateien, die dieser Plan anfasst. Ist `feat/mailboxes` beim Start bereits gemergt, dann von `main`. +- **Alle Befehle laufen im Container:** `docker compose exec -T app `. Nie auf dem Host. +- **R1/R2:** Seiten sind klassenbasierte Livewire-Vollseiten-Komponenten. Kein Volt. +- **R13:** Pfade und Route-Namen englisch, Oberflächentexte deutsch, ausschließlich über `lang/de/*`. `TranslationParityTest` erzwingt Parität beider Sprachdateien. +- **R18:** Icon neben dem Text, `size-4` in Buttons und Tabellen, `size-5` in der Navigation. Icons in `` gehören in ``. +- **R19:** Jede angezeigte Zeit geht durch `->local()`. Nie `->timezone(config('app.timezone'))`. +- **R20:** Bearbeiten mit Eingabefeldern passiert im Modal, nie in der Tabellenzeile. +- **`` hat KEINEN Icon-Slot** — er rendert nur `{{ $slot }}`. Icons gehören in den Standard-Slot mit `class="size-4"`. Nur `` hat ``. +- **Komponentensatz ist fest:** `alert, badge, button, card, chart, checkbox, icon, input, logo, metric, nav-item, otp-input, progress-stepper, ring, spark, stat-tile`. Es gibt **kein** `x-ui.select` und keine Page-Header-Komponente — beides von Hand schreiben, Klassen aus `admin/host-create.blade.php` oder `admin/edit-datacenter.blade.php` übernehmen. +- **Seitenüberschriften sind `text-2xl`**, nicht `text-3xl`. +- **Die `// pfad/zur/datei.php`-Zeile in den Codeblöcken dieses Plans ist eine Annotation, kein Code.** Repo-Konvention ist `role('Owner')` | +| `database/migrations/..._move_rbac_to_operator_guard.php` | **neu** — 17 Berechtigungen und 6 Rollen von `web` nach `operator`, Kontenumzug, `users`-Zeilen entfernen | +| `config/auth.php` | Guard `operator` + Provider `operators` + Passwort-Broker | +| `app/Models/User.php` | verliert `HasRoles`, `is_admin`, `OPERATOR_ROLES`, `isOperator()` | +| `app/Livewire/Concerns/ConfirmsPassword.php` | wird guard-bewusst über `confirmationGuard()` | +| `app/Livewire/Auth/OperatorLogin.php` + View | **neu** — Konsolen-Anmeldung, ohne Registrieren | +| `app/Livewire/Auth/OperatorTwoFactorChallenge.php` + View | **neu** — 2FA am Operator-Guard | +| `app/Http/Middleware/EnsureAdmin.php` | prüft den Operator-Guard | +| `app/Http/Middleware/RestrictAdminHost.php` | `SHARED` schrumpft auf `livewire/*` und `up` | +| `app/Http/Responses/*` | **drei Klassen entfallen ersatzlos** | +| `app/Http/Controllers/ImpersonationController.php` | signierte Einmal-URL über zwei Guards | +| `app/Livewire/Admin/Settings.php` | Schalter `console.require_2fa` mit Aussperr-Schutz | +| `app/Http/Middleware/RequireOperatorTwoFactor.php` | **neu** — erzwingt die Einrichtung, wenn der Schalter an ist | +| `tests/Feature/IdentitySeparationTest.php` | **neu** — erzwingt R21 | +| `CLAUDE.md` | R21 | +| `app/Console/Commands/CreateAdmin.php` | wird `clupilot:create-operator`, alter Name als Alias | + +--- + +## Task 1: „EU" statt „EU — Österreich / Deutschland" + +Der kleinste Punkt, unabhängig von allem anderen, und der einzige aus der ursprünglichen Meldung, der ohne den Guard-Umbau auskommt. + +**Files:** +- Modify: `lang/de/auth.php:37`, `lang/en/auth.php:37` +- Modify: `resources/views/landing.blade.php:466` +- Test: `tests/Feature/Auth/BrandFactsTest.php` (neu) + +**Interfaces:** +- Produces: nichts, was spätere Aufgaben brauchen. + +- [ ] **Step 1: Write the failing test** + +```php +get('/login')->assertOk()->assertSee('EU', false) + ->assertDontSee('Österreich / Deutschland', false); +}); + +it('names only the EU in the specification plate on the public site', function () { + // The approved template (docs/design/tpl-home.html:728, :996) says only "EU" + // in both places. The row that spelled out the countries was never in it. + $this->get('/')->assertOk()->assertDontSee('EU — Österreich / Deutschland', false); +}); + +it('keeps the countries out of the translation files entirely', function () { + expect(__('auth.fact_location'))->toBe('EU'); + + app()->setLocale('en'); + expect(__('auth.fact_location'))->toBe('EU'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec -T app php artisan test --filter=BrandFactsTest` +Expected: FAIL — `fact_location` is `EU — Österreich / Deutschland`. + +Note: if `/` returns 503 rather than 200, the public site is switched off +(`site.public`); the test runs from a trusted range in the container, so this +should not happen — if it does, read `app/Http/Middleware/PublicSiteGate.php` +before changing the test. + +- [ ] **Step 3: Make the change** + +`lang/de/auth.php:37`: + +```php + 'fact_location' => 'EU', +``` + +`lang/en/auth.php:37`: + +```php + 'fact_location' => 'EU', +``` + +`resources/views/landing.blade.php:466` — replace the whole row: + +```blade +
Serverstandort
EU
+``` + +- [ ] **Step 4: Run the tests** + +Run: `docker compose exec -T app php artisan test --filter=BrandFactsTest` +Expected: PASS — three tests. +Then the full suite: `docker compose exec -T app php artisan test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lang/de/auth.php lang/en/auth.php resources/views/landing.blade.php tests/Feature/Auth/BrandFactsTest.php +git commit -m "Say EU and stop there, as the approved template does" +``` + +--- + +## Task 2: Tabelle, Modell, Guard + +Rein additiv — nichts benutzt den Guard danach, die Suite bleibt grün. + +**Files:** +- Create: `database/migrations/2026_07_29_090000_create_operators_table.php` +- Create: `app/Models/Operator.php` +- Create: `database/factories/OperatorFactory.php` +- Modify: `config/auth.php` +- Test: `tests/Feature/Admin/OperatorModelTest.php` + +**Interfaces:** +- Produces: `App\Models\Operator` mit `isOperator(): bool`, `Operator::OPERATOR_ROLES`; Guard `operator`; `OperatorFactory` mit `->role(string $role = 'Owner')`. + +- [ ] **Step 1: Write the failing test** + +```php +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(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec -T app php artisan test --filter=OperatorModelTest` +Expected: FAIL — `Class "App\Models\Operator" not found`. + +- [ ] **Step 3: Write the migration** + +```php +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'); + } +}; +``` + +- [ ] **Step 4: Write the model and factory** + +```php + '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; + } +} +``` + +```php + */ +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])); + } +} +``` + +- [ ] **Step 5: Register the guard** + +In `config/auth.php`, add to `guards`: + +```php + // 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', + ], +``` + +and to `providers`: + +```php + 'operators' => [ + 'driver' => 'eloquent', + 'model' => App\Models\Operator::class, + ], +``` + +and to `passwords` (needed later for operator password resets; harmless now): + +```php + 'operators' => [ + 'provider' => 'operators', + 'table' => 'password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], +``` + +Add `use App\Models\Operator;` is **not** needed — the file already uses the +fully-qualified `App\Models\User`; match that style. + +- [ ] **Step 6: Run the tests** + +Run: `docker compose exec -T app php artisan test --filter=OperatorModelTest` +Expected: PASS — five tests. The role-related ones do not run yet; roles still +live under `web` until Task 3. +Then: `docker compose exec -T app php artisan test` +Expected: PASS, 815 + 5. + +- [ ] **Step 7: Commit** + +```bash +git add database/migrations/2026_07_29_090000_create_operators_table.php app/Models/Operator.php database/factories/OperatorFactory.php config/auth.php tests/Feature/Admin/OperatorModelTest.php +git commit -m "Give the people who run CluPilot a table of their own" +``` + +--- + +## Task 3: Der Guard-Wechsel + +**Diese Aufgabe ist groß und lässt sich nicht sinnvoll teilen.** Ein Guard-Wechsel ist atomar: in dem Moment, in dem die Rollen unter `operator` liegen, kann kein `User` mehr `can('console.view')`, und jeder Konsolentest fällt um. Halb umgestellt ist die Suite rot. Deshalb: RBAC-Umzug, Kontenumzug, alle elf Fundstellen und alle siebzehn Testdateien in **einem** Commit. + +**Files:** +- Create: `database/migrations/2026_07_29_100000_move_rbac_to_operator_guard.php` +- Modify: `app/Models/User.php`, `app/Http/Middleware/EnsureAdmin.php`, `app/Http/Middleware/PublicSiteGate.php`, `app/Http/Middleware/EnsureCustomerActive.php`, `app/Models/Customer.php:138`, `app/Policies/VpnPeerPolicy.php`, `routes/channels.php:10`, `resources/views/layouts/portal-app.blade.php:25`, `app/Livewire/Admin/Settings.php`, `app/Livewire/Admin/Vpn.php` +- Modify: 17 Testdateien unter `tests/Feature/Admin/` und `tests/Feature/Mail/` +- Delete: `tests/Feature/OperatorInPortalTest.php` +- Test: `tests/Feature/Admin/RbacMoveTest.php` (neu) + +**Interfaces:** +- Consumes: `App\Models\Operator`, Guard `operator` (Task 2) +- Produces: alle 17 Berechtigungen und 6 Rollen unter `guard_name = 'operator'`; `User` ohne `HasRoles`/`is_admin`/`isOperator()` + +- [ ] **Step 1: Write the failing test** + +```php +count())->toBe(0) + ->and(Role::where('guard_name', 'web')->count())->toBe(0) + ->and(Permission::where('guard_name', 'operator')->count())->toBe(17) + ->and(Role::where('guard_name', 'operator')->count())->toBe(6); +}); + +it('keeps Owner holding every permission after the move', function () { + $owner = Role::findByName('Owner', 'operator'); + + expect($owner->permissions()->count())->toBe(Permission::count()); +}); + +it('lets an operator use a console capability and a user not', function () { + $operator = Operator::factory()->role('Owner')->create(); + + expect($operator->can('console.view'))->toBeTrue() + ->and(method_exists(User::factory()->create(), 'hasRole'))->toBeFalse(); +}); + +it('carries an existing operator user across with password and two-factor intact', function () { + // Simulates the pre-migration state on a live server. + $hash = bcrypt('altes-passwort'); + DB::table('users')->insert([ + 'name' => 'Alt', 'email' => 'alt@clupilot.test', 'password' => $hash, + 'two_factor_secret' => 'geheimnis', 'is_admin' => true, + 'created_at' => now(), 'updated_at' => now(), + ]); + $userId = DB::table('users')->where('email', 'alt@clupilot.test')->value('id'); + DB::table('model_has_roles')->insert([ + 'role_id' => DB::table('roles')->where('name', 'Owner')->value('id'), + 'model_type' => User::class, 'model_id' => $userId, + ]); + + // Re-run only the move migration against this hand-built state. + (require base_path('database/migrations/2026_07_29_100000_move_rbac_to_operator_guard.php'))->up(); + + $operator = Operator::where('email', 'alt@clupilot.test')->first(); + + expect($operator)->not->toBeNull() + ->and($operator->password)->toBe($hash) + ->and($operator->two_factor_secret)->toBe('geheimnis') + ->and($operator->hasRole('Owner'))->toBeTrue() + // Not mixed: the users row is gone. + ->and(DB::table('users')->where('email', 'alt@clupilot.test')->exists())->toBeFalse(); +}); + +it('refuses to delete a users row that has a customer on it', function () { + $customer = Customer::factory()->create(); + $user = User::factory()->create(['email' => 'beides@clupilot.test']); + DB::table('users')->where('id', $user->id)->update(['customer_id' => $customer->id]); + DB::table('model_has_roles')->insert([ + 'role_id' => DB::table('roles')->where('name', 'Owner')->value('id'), + 'model_type' => User::class, 'model_id' => $user->id, + ]); + + // Aborting is the point: silently deleting would take billing data with it. + try { + (require base_path('database/migrations/2026_07_29_100000_move_rbac_to_operator_guard.php'))->up(); + $this->fail('The migration should have refused.'); + } catch (RuntimeException $e) { + expect($e->getMessage())->toContain('beides@clupilot.test'); + } +}); +``` + +Note on the fourth and fifth tests: `RefreshDatabase` has already run every +migration, so calling `up()` again works on an already-migrated schema. Confirm +the migration is written to tolerate that (`firstOrNew`, guard clauses) — the +mailbox work was bitten by a non-idempotent seed migration and the repo's +`2026_07_25_133900_seed_roles_and_permissions.php` is the local precedent for +doing it re-runnably. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec -T app php artisan test --filter=RbacMoveTest` +Expected: FAIL — the permissions are still on guard `web`. + +- [ ] **Step 3: Write the migration** + +```php +forgetCachedPermissions(); + + DB::table('permissions')->where('guard_name', 'web')->update(['guard_name' => 'operator']); + DB::table('roles')->where('guard_name', 'web')->update(['guard_name' => 'operator']); + + foreach ($this->operatorUserIds() as $userId) { + $this->carryAcross($userId); + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + }); + } + + /** Every users row that currently holds a console role. */ + private function operatorUserIds(): array + { + return DB::table('model_has_roles') + ->where('model_type', User::class) + ->pluck('model_id') + ->unique() + ->all(); + } + + private function carryAcross(int $userId): void + { + $row = DB::table('users')->where('id', $userId)->first(); + + if ($row === null) { + return; + } + + // Idempotent: re-running must fix a half-finished move, not fail on the + // unique email. + $operator = Operator::firstOrNew(['email' => $row->email]); + $operator->name = $row->name; + $operator->uuid ??= (string) Str::uuid(); + $operator->save(); + + // The hash and the two-factor secret are carried, not translated — the + // operator signs in with the same password as before. + DB::table('operators')->where('id', $operator->id)->update([ + 'password' => $row->password, + 'two_factor_secret' => $row->two_factor_secret, + 'two_factor_recovery_codes' => $row->two_factor_recovery_codes, + 'two_factor_confirmed_at' => $row->two_factor_confirmed_at, + 'remember_token' => $row->remember_token, + ]); + + // Re-point the role assignment at the new model. + DB::table('model_has_roles') + ->where('model_type', User::class)->where('model_id', $userId) + ->update(['model_type' => Operator::class, 'model_id' => $operator->id]); + + DB::table('model_has_permissions') + ->where('model_type', User::class)->where('model_id', $userId) + ->update(['model_type' => Operator::class, 'model_id' => $operator->id]); + + $this->removeUserRow($row); + } + + /** + * The users row goes — whoever is in `operators` has no business in `users`. + * + * One exception, and it deletes nothing: a row with a customer, a seat or an + * order on it means the same person is operator AND paying customer. Then it + * is not the row that is wrong but the assumption, and a silent delete would + * take billing data with it. + */ + private function removeUserRow(object $row): void + { + $hasCustomer = property_exists($row, 'customer_id') && $row->customer_id !== null; + $hasSeats = DB::table('seats')->where('user_id', $row->id)->exists(); + $hasOrders = DB::table('orders')->where('user_id', $row->id)->exists(); + + if ($hasCustomer || $hasSeats || $hasOrders) { + throw new RuntimeException( + "Refusing to move [{$row->email}]: this account is both an operator and a customer. " + .'Resolve that by hand before migrating.' + ); + } + + DB::table('users')->where('id', $row->id)->delete(); + } + + public function down(): void + { + DB::transaction(function () { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + foreach (DB::table('operators')->get() as $row) { + $userId = DB::table('users')->insertGetId([ + 'name' => $row->name, 'email' => $row->email, 'password' => $row->password, + 'two_factor_secret' => $row->two_factor_secret, + 'two_factor_recovery_codes' => $row->two_factor_recovery_codes, + 'two_factor_confirmed_at' => $row->two_factor_confirmed_at, + 'remember_token' => $row->remember_token, + 'is_admin' => true, + 'created_at' => $row->created_at, 'updated_at' => $row->updated_at, + ]); + + DB::table('model_has_roles') + ->where('model_type', Operator::class)->where('model_id', $row->id) + ->update(['model_type' => User::class, 'model_id' => $userId]); + } + + DB::table('permissions')->where('guard_name', 'operator')->update(['guard_name' => 'web']); + DB::table('roles')->where('guard_name', 'operator')->update(['guard_name' => 'web']); + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + }); + } +}; +``` + +> `down()` recreates the `users` rows from `operators`. What it cannot bring +> back is anything that only ever existed on the operator — `last_login_at`. +> Say so in a comment where the migration is read, not only here. + +- [ ] **Step 4: Strip the operator concepts out of `User`** + +In `app/Models/User.php`: remove `HasRoles` from the `use` list and its import, +remove `OPERATOR_ROLES`, remove `isOperator()`, remove `'is_admin'` from the +`#[Fillable]` attribute and from `casts()`. Leave the `is_admin` **column** in +place — dropping it is a separate migration and not worth coupling to this one; +add a one-line comment saying it is dead. + +- [ ] **Step 5: Point the eleven consumers at the operator guard** + +`app/Http/Middleware/EnsureAdmin.php`: + +```php + public function handle(Request $request, Closure $next): Response + { + $operator = Auth::guard('operator')->user(); + + abort_unless($operator !== null && $operator->isActive() && $operator->can('console.view'), 403); + + return $next($request); + } +``` + +`app/Http/Middleware/PublicSiteGate.php` — replace `$request->user()?->isOperator()`: + +```php + if ($this->fromManagementNetwork($request) || Auth::guard('operator')->check()) { +``` + +`app/Http/Middleware/EnsureCustomerActive.php:22` — the operator special case +disappears; only the impersonation flag remains: + +```php + if ($user !== null && ! $request->session()->has('impersonator_id')) { +``` + +`app/Models/Customer.php:138` — replace `$user->is_admin || $user->isOperator()`: + +```php + if (Auth::guard('operator')->check()) { +``` + +Read the surrounding method first: if `$user` is a parameter used elsewhere in +it, keep that use and change only this condition. + +`routes/channels.php:10`: + +```php +Broadcast::channel('admin.runs', fn () => (bool) Auth::guard('operator')->user()?->can('console.view')); +``` + +`resources/views/layouts/portal-app.blade.php:25` — the console link goes +entirely. A customer is never an operator now, so the condition can only ever +be false: + +```blade +{{-- The console link is gone: a portal account is never an operator (R21). --}} +``` + +`app/Policies/VpnPeerPolicy.php` — this is the one genuinely mixed surface. +Split it: the customer half keeps working off the `web` user and `owns()`, and +the operator half moves to explicit guard checks. Read the file and write both +paths; do not branch on `instanceof` inside one method, because Laravel resolves +the policy from the model being authorised and either identity can arrive. + +`app/Livewire/Admin/Settings.php` and `app/Livewire/Admin/Vpn.php` — replace +`auth()->user()` with `Auth::guard('operator')->user()` wherever the operator is +meant. Grep both files for `auth()` and check each hit. + +- [ ] **Step 6: Migrate the seventeen test files** + +In every file under `tests/Feature/Admin/` and `tests/Feature/Mail/` that calls +`actingAs`, replace the operator user with an operator on its guard: + +```php +// before +Livewire::actingAs(User::factory()->operator('Owner')->create())->test(...) +// after +Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')->test(...) +``` + +and for HTTP tests: + +```php +$this->actingAs(Operator::factory()->role('Owner')->create(), 'operator')->get(...) +``` + +Update the `use` statements. **Do not weaken any assertion to make a test pass** — +if a test breaks for a reason other than the identity change, stop and report it. + +Delete `tests/Feature/OperatorInPortalTest.php`. It proved that an operator +without a customer gets a message instead of a dead button in the portal; that +state cannot occur any more. Replace it with a single test in the new +`IdentitySeparationTest` (Task 9) proving an operator cannot authenticate on the +`web` guard at all — do not leave the deletion uncompensated. + +- [ ] **Step 7: Run the tests** + +Run: `docker compose exec -T app php artisan test` +Expected: PASS. Expect this step to take several iterations — that is the nature +of an atomic guard switch. Work through the failures; do not skip or weaken. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "Move the console's identity out of the customer table" +``` + +--- + +## Task 4: Passwortbestätigung wird guard-bewusst + +`ConfirmsPassword` prüft heute `auth()->user()->password` — den Standard-Guard. +Auf einer Konsolenseite ist das ab Task 3 der falsche Benutzer, und +`auth()->id()` im Rate-Limiter-Schlüssel ebenso. Fünf Bauteile benutzen den +Trait, zwei davon im Portal. + +**Files:** +- Modify: `app/Livewire/Concerns/ConfirmsPassword.php` +- Modify: `app/Livewire/Admin/Secrets.php`, `app/Livewire/Admin/Mail.php`, `app/Livewire/EditMailbox.php` +- Test: `tests/Feature/Admin/PasswordConfirmationGuardTest.php` (neu) + +**Interfaces:** +- Produces: `ConfirmsPassword::confirmationGuard(): string`, überschreibbar; Vorgabe `'web'`. + +- [ ] **Step 1: Write the failing test** + +```php + config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)))); + +it('confirms a console password against the operator record, not a portal user', function () { + $operator = Operator::factory()->role('Owner')->create(['password' => 'operator-passwort']); + + Livewire::actingAs($operator, 'operator')->test(Secrets::class) + ->set('confirmablePassword', 'operator-passwort') + ->call('confirmPassword') + ->assertHasNoErrors(); +}); + +it('rejects a password that belongs to a portal user with the same address', function () { + // The trap: two tables, one address. Confirming against the wrong one would + // let a customer's password unlock the console. + $operator = Operator::factory()->role('Owner')->create([ + 'email' => 'doppelt@clupilot.test', 'password' => 'operator-passwort', + ]); + User::factory()->create(['email' => 'doppelt@clupilot.test', 'password' => 'kunden-passwort']); + + Livewire::actingAs($operator, 'operator')->test(Secrets::class) + ->set('confirmablePassword', 'kunden-passwort') + ->call('confirmPassword') + ->assertHasErrors('confirmablePassword'); +}); + +it('still confirms a portal password on the web guard', function () { + $user = User::factory()->create(['password' => 'kunden-passwort']); + + Livewire::actingAs($user)->test(App\Livewire\Settings::class) + ->set('confirmablePassword', 'kunden-passwort') + ->call('confirmPassword') + ->assertHasNoErrors(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec -T app php artisan test --filter=PasswordConfirmationGuardTest` +Expected: FAIL — the console tests resolve `auth()->user()` on the web guard and +get null. + +- [ ] **Step 3: Make the trait guard-aware** + +In `app/Livewire/Concerns/ConfirmsPassword.php`, add: + +```php + /** + * Which guard's record this component confirms against. + * + * Overridden to 'operator' by the console components. A default of 'web' + * keeps the portal working unchanged — and an override is explicit, where + * sniffing the request would be one more thing to get wrong on a page that + * gates access to credentials. + */ + protected function confirmationGuard(): string + { + return 'web'; + } +``` + +and replace the two `auth()` uses in `confirmPassword()`: + +```php + $account = auth()->guard($this->confirmationGuard())->user(); + $key = 'confirm-password:'.$this->confirmationGuard().'|'.$account?->getAuthIdentifier().'|'.request()->ip(); +``` + +```php + if ($account === null || ! Hash::check($this->confirmablePassword, $account->password)) { +``` + +The guard name goes into the rate-limiter key so a portal and a console +confirmation for the same id cannot share a bucket. + +- [ ] **Step 4: Override it in the three console components** + +Add to `app/Livewire/Admin/Secrets.php`, `app/Livewire/Admin/Mail.php` and +`app/Livewire/EditMailbox.php`: + +```php + protected function confirmationGuard(): string + { + return 'operator'; + } +``` + +- [ ] **Step 5: Run the tests** + +Run: `docker compose exec -T app php artisan test --filter=PasswordConfirmationGuardTest` +Expected: PASS — three tests. +Then: `docker compose exec -T app php artisan test` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add app/Livewire/Concerns/ConfirmsPassword.php app/Livewire/Admin/Secrets.php app/Livewire/Admin/Mail.php app/Livewire/EditMailbox.php tests/Feature/Admin/PasswordConfirmationGuardTest.php +git commit -m "Confirm a console password against the console's own record" +``` + +--- + +## Task 5: Die Konsolen-Anmeldung + +**Files:** +- Create: `app/Livewire/Auth/OperatorLogin.php`, `resources/views/livewire/auth/operator-login.blade.php` +- Create: `app/Livewire/Auth/OperatorTwoFactorChallenge.php`, `resources/views/livewire/auth/operator-two-factor-challenge.blade.php` +- Modify: `routes/web.php` (Gast-Gruppe für die Konsole), `routes/admin.php` +- Modify: `lang/de/auth.php`, `lang/en/auth.php` +- Test: `tests/Feature/Auth/OperatorLoginTest.php` + +**Interfaces:** +- Consumes: Guard `operator` (Task 2) +- Produces: Routen `admin.login`, `admin.login.store`, `admin.two-factor`, `admin.logout` + +- [ ] **Step 1: Write the failing test** + +```php +get(route('admin.login'))->assertOk() + ->assertDontSee(route('register')) + ->assertDontSee(__('auth.sign_up')); +}); + +it('signs an operator in on the operator guard', function () { + $operator = Operator::factory()->role('Owner')->create(['password' => 'richtig-langes-pw']); + + Livewire::test(OperatorLogin::class) + ->set('email', $operator->email) + ->set('password', 'richtig-langes-pw') + ->call('authenticate') + ->assertRedirect(App\Support\AdminArea::home()); + + expect(Auth::guard('operator')->check())->toBeTrue() + ->and(Auth::guard('web')->check())->toBeFalse(); +}); + +it('refuses a portal account at the console door', function () { + $user = User::factory()->create(['email' => 'kunde@clupilot.test', 'password' => 'kunden-passwort']); + + Livewire::test(OperatorLogin::class) + ->set('email', 'kunde@clupilot.test') + ->set('password', 'kunden-passwort') + ->call('authenticate') + ->assertHasErrors('email'); + + expect(Auth::guard('operator')->check())->toBeFalse(); +}); + +it('refuses a disabled operator', function () { + $operator = Operator::factory()->role('Owner')->create([ + 'password' => 'richtig-langes-pw', 'disabled_at' => now(), + ]); + + Livewire::test(OperatorLogin::class) + ->set('email', $operator->email) + ->set('password', 'richtig-langes-pw') + ->call('authenticate') + ->assertHasErrors('email'); + + expect(Auth::guard('operator')->check())->toBeFalse(); +}); + +it('sends an operator with confirmed two-factor to the challenge instead of the console', function () { + $operator = Operator::factory()->role('Owner')->create([ + 'password' => 'richtig-langes-pw', + 'two_factor_secret' => encrypt('JBSWY3DPEHPK3PXP'), + 'two_factor_confirmed_at' => now(), + ]); + + Livewire::test(OperatorLogin::class) + ->set('email', $operator->email) + ->set('password', 'richtig-langes-pw') + ->call('authenticate') + ->assertRedirect(route('admin.two-factor')); + + // Not signed in yet — the challenge is not decoration. + expect(Auth::guard('operator')->check())->toBeFalse(); +}); + +it('throttles repeated failures', function () { + $operator = Operator::factory()->role('Owner')->create(['password' => 'richtig-langes-pw']); + + $component = Livewire::test(OperatorLogin::class)->set('email', $operator->email)->set('password', 'falsch'); + + foreach (range(1, 5) as $ignored) { + $component->call('authenticate'); + } + + $component->call('authenticate')->assertHasErrors('email'); + expect(session()->has('login.id'))->toBeFalse(); +}); + +it('records when the operator last signed in', function () { + $operator = Operator::factory()->role('Owner')->create(['password' => 'richtig-langes-pw']); + + Livewire::test(OperatorLogin::class) + ->set('email', $operator->email)->set('password', 'richtig-langes-pw') + ->call('authenticate'); + + expect($operator->fresh()->last_login_at)->not->toBeNull(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec -T app php artisan test --filter=OperatorLoginTest` +Expected: FAIL — `Class "App\Livewire\Auth\OperatorLogin" not found`. + +- [ ] **Step 3: Write the login component** + +```php +validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]); + + $key = 'operator-login:'.mb_strtolower($this->email).'|'.request()->ip(); + + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages([ + 'email' => __('auth.throttle', ['seconds' => RateLimiter::availableIn($key)]), + ]); + } + + $operator = Operator::where('email', $this->email)->first(); + + // One message for every failure — a different message for "no such + // account" tells a stranger which addresses are operators. + if ($operator === null + || ! Hash::check($this->password, $operator->password) + || ! $operator->isActive() + || ! $operator->isOperator()) { + RateLimiter::hit($key, 60); + $this->reset('password'); + + throw ValidationException::withMessages(['email' => __('auth.failed')]); + } + + RateLimiter::clear($key); + + if ($operator->two_factor_confirmed_at !== null) { + // Not signed in yet. The id is parked in the session and the guard + // stays untouched until the code checks out. + session(['operator.2fa.id' => $operator->id, 'operator.2fa.remember' => $this->remember]); + $this->redirectRoute('admin.two-factor', navigate: false); + + return; + } + + $this->completeLogin($operator, $this->remember); + } + + /** Shared with the two-factor challenge, so both exits behave identically. */ + public static function completeLogin(Operator $operator, bool $remember): void + { + Auth::guard('operator')->login($operator, $remember); + session()->regenerate(); + $operator->forceFill(['last_login_at' => now()])->save(); + } + + private function completeLoginAndRedirect(Operator $operator, bool $remember): void + { + self::completeLogin($operator, $remember); + $this->redirect(AdminArea::home(), navigate: false); + } + + public function render() + { + return view('livewire.auth.operator-login'); + } +} +``` + +Replace the `$this->completeLogin($operator, $this->remember);` call in +`authenticate()` with `$this->completeLoginAndRedirect($operator, $this->remember);`. + +- [ ] **Step 4: Write the two-factor challenge** + +```php +has('operator.2fa.id'), 403); + } + + public function verify(): void + { + $operator = Operator::find(session('operator.2fa.id')); + abort_if($operator === null, 403); + + $key = 'operator-2fa:'.$operator->id.'|'.request()->ip(); + + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages([ + 'code' => __('auth.throttle', ['seconds' => RateLimiter::availableIn($key)]), + ]); + } + + if (! $this->passes($operator)) { + RateLimiter::hit($key, 60); + $this->reset('code', 'recoveryCode'); + + throw ValidationException::withMessages(['code' => __('auth.twofa_invalid')]); + } + + RateLimiter::clear($key); + + $remember = (bool) session('operator.2fa.remember', false); + session()->forget(['operator.2fa.id', 'operator.2fa.remember']); + + OperatorLogin::completeLogin($operator, $remember); + $this->redirect(AdminArea::home(), navigate: false); + } + + private function passes(Operator $operator): bool + { + if ($this->usingRecovery) { + $codes = json_decode(decrypt($operator->two_factor_recovery_codes), true) ?: []; + + if (! in_array($this->recoveryCode, $codes, true)) { + return false; + } + + // A recovery code is single use — leaving it valid turns a one-time + // escape hatch into a second password. + $operator->forceFill([ + 'two_factor_recovery_codes' => encrypt(json_encode(array_values( + array_diff($codes, [$this->recoveryCode]) + ))), + ])->save(); + + return true; + } + + return app(TwoFactorAuthenticationProvider::class) + ->verify(decrypt($operator->two_factor_secret), $this->code); + } + + public function render() + { + return view('livewire.auth.operator-two-factor-challenge'); + } +} +``` + +- [ ] **Step 5: Register the routes** + +In `routes/web.php`, the console block currently applies +`['admin.host', 'auth', 'admin']` to everything in `routes/admin.php`. Split it +so the sign-in pages are reachable **before** `auth`. In both the exclusive and +the fallback branch, register a guest group first: + +```php + Route::middleware(['admin.host', 'guest:operator']) + ->prefix(AdminArea::prefix()) + ->name('admin.') + ->group(base_path('routes/admin-guest.php')); +``` + +and create `routes/admin-guest.php`: + +```php +name('login'); +Route::get('/two-factor', OperatorTwoFactorChallenge::class)->name('two-factor'); +``` + +and in `routes/admin.php`, inside the authenticated group, add the logout: + +```php +// POST so Laravel's CSRF middleware protects it, like every other state change. +Route::post('/logout', function () { + Auth::guard('operator')->logout(); + session()->invalidate(); + session()->regenerateToken(); + + return redirect()->route('admin.login'); +})->name('logout'); +``` + +with `use Illuminate\Support\Facades\Auth;` at the top of that file. + +Then point `resources/views/layouts/admin.blade.php:34` at `route('admin.logout')` +instead of `route('logout')`. + +- [ ] **Step 6: Write the views** + +`resources/views/livewire/auth/operator-login.blade.php` — copy the structure of +`resources/views/livewire/auth/login.blade.php`, with three differences: + +1. **No `` fact plate and no marketing copy.** Whoever stands here + knows what CluPilot is. A plain centred card. +2. **No registration link.** That block is the whole reason this page exists. +3. `wire:submit="authenticate"` on the form, `wire:model` on the fields, rather + than a POST to Fortify. + +```blade +
+
+
+ + CluPilot +
+ +

{{ __('auth.console_login_title') }}

+

{{ __('auth.console_login_subtitle') }}

+ +
+ + + + {{ __('auth.sign_in') }} + +
+
+``` + +`resources/views/livewire/auth/operator-two-factor-challenge.blade.php` — the +same shell, with `` for the code and a link that flips +`usingRecovery`. Read `resources/views/livewire/auth/two-factor-challenge.blade.php` +for how that component is used in this repo, and copy its markup. + +Add to `lang/de/auth.php` (and the English equivalents to `lang/en/auth.php`): + +```php + 'console_login_title' => 'Konsole', + 'console_login_subtitle' => 'Anmeldung für Betreiber.', + 'twofa_invalid' => 'Der Code stimmt nicht.', +``` + +**On the layout:** there is no `layouts.guest`. The layout set is `admin`, +`portal-app` and `portal`, and all three existing auth pages +(`Login`, `Register`, `TwoFactorChallenge`) use `#[Layout('layouts.portal')]` — +that is the guest shell, despite the name. Use it. Do not add a fourth layout +for this: read `resources/views/layouts/portal.blade.php` first and confirm it +carries nothing portal-specific that would look wrong above a console sign-in. +If it does, say so in your report rather than inventing a layout. + +- [ ] **Step 7: Run the tests** + +Run: `docker compose exec -T app php artisan test --filter=OperatorLoginTest` +Expected: PASS — seven tests. +Then: `docker compose exec -T app php artisan test` +Expected: PASS. +Then: `docker compose exec -T app npm run build` + +- [ ] **Step 8: Commit** + +```bash +git add app/Livewire/Auth/Operator*.php resources/views/livewire/auth/operator-*.blade.php routes/ resources/views/layouts/admin.blade.php lang/ tests/Feature/Auth/OperatorLoginTest.php +git commit -m "Give the console a front door of its own" +``` + +--- + +## Task 6: Die Trennung wird scharf — hier sterben Registrieren-Link und 404 + +**Files:** +- Modify: `app/Http/Middleware/RestrictAdminHost.php` +- Delete: `app/Http/Responses/LandsWhereSignedIn.php`, `ConsoleAwareLoginResponse.php`, `ConsoleAwareTwoFactorLoginResponse.php` +- Modify: `app/Providers/FortifyServiceProvider.php` +- Test: `tests/Feature/Admin/ConsoleHostSeparationTest.php` (erweitern), `tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php` (ersetzen) + +**Interfaces:** +- Consumes: Routen `admin.login`, `admin.two-factor` (Task 5) + +- [ ] **Step 1: Write the failing test** + +Add to `tests/Feature/Admin/ConsoleHostSeparationTest.php`, inside the +`describe('exclusive host', …)` block: + +```php + it('does not serve the portal sign-in or registration on the console host', function () { + // The reported fault, at its root: the console used to answer /login + // with the portal's page, which offers "Registrieren" — and /register + // was never in SHARED, so the link 404'd. + expect(guard('admin.example.test', '/register', 'register'))->toBe('404') + ->and(guard('admin.example.test', '/login', 'login'))->toBe('404'); + }); + + it('still serves the console's own sign-in on its host', function () { + expect(guard('admin.example.test', '/login', 'admin.login'))->toBe('through') + ->and(guard('admin.example.test', '/two-factor', 'admin.two-factor'))->toBe('through'); + }); + + it('keeps only the endpoints both sides genuinely share', function () { + expect(guard('admin.example.test', '/livewire/update', null))->toBe('through') + ->and(guard('admin.example.test', '/up', null))->toBe('through'); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec -T app php artisan test --filter=ConsoleHostSeparationTest` +Expected: FAIL — `/login` currently passes through because `login` is in `SHARED`. + +- [ ] **Step 3: Shrink SHARED** + +In `app/Http/Middleware/RestrictAdminHost.php`: + +```php + /** + * Endpoints both sides genuinely share. + * + * `login`, `logout` and `two-factor-challenge` used to be here, from when + * one sign-in page served both. They are gone: the console has its own at + * `admin.login`, and leaving the portal's here is what put a registration + * link in front of an operator — with a 404 behind it, because `register` + * was never in this list and could not be, since the console has no + * registration by design. + */ + private const SHARED = [ + 'livewire/*', + 'up', + ]; +``` + +- [ ] **Step 4: Delete the three response classes** + +They existed only to route a shared sign-in to the right destination. Each side +now lands where it belongs by construction. + +```bash +git rm app/Http/Responses/LandsWhereSignedIn.php app/Http/Responses/ConsoleAwareLoginResponse.php app/Http/Responses/ConsoleAwareTwoFactorLoginResponse.php +``` + +In `app/Providers/FortifyServiceProvider.php`, remove both `$this->app->singleton(...)` +bindings (around lines 39-44) and their imports. Fortify falls back to its own +responses, which redirect to `Fortify::redirects('login')` — the portal +dashboard. That is now correct, because Fortify only ever serves the portal. + +Delete `tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php` — it tested +the deleted classes. Its intent is covered by Task 5's "signs an operator in on +the operator guard" and by the separation tests above; confirm both exist before +deleting, and say so in the commit message. + +- [ ] **Step 5: Run the tests** + +Run: `docker compose exec -T app php artisan test` +Expected: PASS. Watch for tests that reached the console through `/login` — they +now need `route('admin.login')`. + +- [ ] **Step 6: Verify the reported fault is actually gone** + +```bash +docker compose exec -T app php artisan route:list | grep -E "login|register" +``` + +On a console host in exclusive mode, `/register` must not resolve at all, and +`/login` must resolve to `admin.login`. Note in the report what you saw. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "Stop the console serving the portal's sign-in page" +``` + +--- + +## Task 7: Impersonation über eine signierte Einmal-URL + +**Files:** +- Modify: `app/Http/Controllers/ImpersonationController.php` +- Modify: `routes/web.php` (Einlöse-Route im Portal) +- Test: `tests/Feature/ImpersonationTest.php` (umschreiben) + +**Interfaces:** +- Produces: Route `impersonate.enter` (signiert, im Portal) + +> **The URL addresses the CUSTOMER by uuid, not the user.** `users` has no +> `uuid` column — checked — so a link naming the user would have to expose the +> integer primary key, against R11. `Customer` and `Operator` both carry one +> through `HasUuid`, and the customer is what the operator actually selected; +> the portal end resolves the user with `ensureUser()`, exactly as the console +> end does. + +- [ ] **Step 1: Write the failing test** + +```php +role('Owner')->create(); + $customer = Customer::factory()->create(); + + $response = $this->actingAs($operator, 'operator') + ->post(route('admin.impersonate', $customer)); + + // The console and the portal are different hosts in exclusive mode, and a + // session cookie is host-bound — so the handover cannot be a cookie. + $response->assertRedirectContains('signature='); +}); + +it('signs the customer in on the web guard when the link is followed', function () { + $operator = Operator::factory()->role('Owner')->create(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + + $url = URL::temporarySignedRoute('impersonate.enter', now()->addSeconds(60), [ + 'customer' => $customer->uuid, 'operator' => $operator->uuid, + ]); + + $this->get($url)->assertRedirect(route('dashboard')); + + expect(Auth::guard('web')->id())->toBe($user->id); +}); + +it('refuses the same link twice', function () { + $operator = Operator::factory()->role('Owner')->create(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + + $url = URL::temporarySignedRoute('impersonate.enter', now()->addSeconds(60), [ + 'customer' => $customer->uuid, 'operator' => $operator->uuid, + ]); + + $this->get($url)->assertRedirect(route('dashboard')); + Auth::guard('web')->logout(); + + // A link that still works after use is a password with an expiry date. + $this->get($url)->assertForbidden(); +}); + +it('refuses an expired link', function () { + $operator = Operator::factory()->role('Owner')->create(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + + $url = URL::temporarySignedRoute('impersonate.enter', now()->subSecond(), [ + 'customer' => $customer->uuid, 'operator' => $operator->uuid, + ]); + + $this->get($url)->assertForbidden(); +}); + +it('leaves the operator session untouched, so leaving returns to the console', function () { + $operator = Operator::factory()->role('Owner')->create(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + + $url = URL::temporarySignedRoute('impersonate.enter', now()->addSeconds(60), [ + 'customer' => $customer->uuid, 'operator' => $operator->uuid, + ]); + + $this->actingAs($operator, 'operator')->get($url); + + expect(Auth::guard('operator')->check())->toBeTrue(); + + $this->post(route('impersonate.leave')); + + expect(Auth::guard('web')->check())->toBeFalse() + ->and(Auth::guard('operator')->check())->toBeTrue(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec -T app php artisan test --filter=ImpersonationTest` +Expected: FAIL — the controller still calls `Auth::login()` directly. + +- [ ] **Step 3: Rewrite the controller** + +```php +authorize('customers.impersonate'); + + $operator = Auth::guard('operator')->user(); + + // Addressed by CUSTOMER uuid, not user: `users` has no uuid column, and + // naming the integer key in a URL is what R11 forbids. The portal end + // resolves the account the same way this end would have. + return redirect()->away(URL::temporarySignedRoute( + 'impersonate.enter', + now()->addSeconds(self::WINDOW), + ['customer' => $customer->uuid, 'operator' => $operator->uuid], + )); + } + + /** The portal end of the handover. Signed, and good exactly once. */ + public function enter(Request $request, string $customer, string $operator): RedirectResponse + { + abort_unless($request->hasValidSignature(), 403); + + // Single use: the signature's own hash is the key, and it lives exactly + // as long as the link could. Redis, not the database — the marker is as + // short-lived as the link and should not need tidying up. + $marker = 'impersonate:'.hash('sha256', (string) $request->query('signature')); + + abort_unless(Cache::add($marker, true, self::WINDOW), 403); + + $target = Customer::where('uuid', $customer)->firstOrFail()->ensureUser(); + $who = Operator::where('uuid', $operator)->firstOrFail(); + + Auth::guard('web')->login($target); + session(['impersonator_id' => $who->id]); + + return redirect()->route('dashboard'); + } + + /** Back out of the customer's portal. The operator session was never touched. */ + public function leave(): RedirectResponse + { + session()->forget('impersonator_id'); + Auth::guard('web')->logout(); + + return redirect()->to(\App\Support\AdminArea::home()); + } +} +``` + +- [ ] **Step 4: Register the redemption route** + +In `routes/web.php`, outside the `auth` group — the visitor is not signed in yet +when they follow the link: + +```php +// Signed and single-use; see ImpersonationController. Deliberately not behind +// `auth`: following the link is what creates the session. +Route::get('/impersonate/enter/{customer}/{operator}', [ImpersonationController::class, 'enter']) + ->name('impersonate.enter'); +``` + +- [ ] **Step 5: Run the tests** + +Run: `docker compose exec -T app php artisan test --filter=ImpersonationTest` +Expected: PASS — five tests. +Then: `docker compose exec -T app php artisan test` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add app/Http/Controllers/ImpersonationController.php routes/web.php tests/Feature/ImpersonationTest.php +git commit -m "Hand impersonation over by signed link, not by a cookie that cannot cross hosts" +``` + +--- + +## Task 8: Der 2FA-Pflichtschalter + +Freiwillig bleibt die Vorgabe. Der Inhaber kann sie für alle verbindlich machen — +und darf sich dabei nicht selbst aussperren. + +**Files:** +- Create: `app/Http/Middleware/RequireOperatorTwoFactor.php` +- Modify: `bootstrap/app.php`, `routes/web.php`, `app/Livewire/Admin/Settings.php`, `resources/views/livewire/admin/settings.blade.php` +- Modify: `lang/de/admin_settings.php`, `lang/en/admin_settings.php` +- Test: `tests/Feature/Admin/OperatorTwoFactorPolicyTest.php` + +**Interfaces:** +- Produces: Einstellung `console.require_2fa`; Middleware-Alias `operator.2fa` + +- [ ] **Step 1: Write the failing test** + +```php +toBeFalse(); + + $operator = Operator::factory()->role('Owner')->create(); + + $this->actingAs($operator, 'operator')->get(route('admin.overview'))->assertOk(); +}); + +it('sends an operator without two-factor to the setup page once the switch is on', function () { + AppSettings::set('console.require_2fa', true); + + $operator = Operator::factory()->role('Owner')->create(); + + $this->actingAs($operator, 'operator') + ->get(route('admin.overview')) + ->assertRedirect(route('admin.settings')); +}); + +it('lets an operator with confirmed two-factor through', function () { + AppSettings::set('console.require_2fa', true); + + $operator = Operator::factory()->role('Owner')->create([ + 'two_factor_secret' => encrypt('JBSWY3DPEHPK3PXP'), + 'two_factor_confirmed_at' => now(), + ]); + + $this->actingAs($operator, 'operator')->get(route('admin.overview'))->assertOk(); +}); + +it('refuses to switch it on while your own account has no two-factor', function () { + // The lock-out this exists to prevent: the page that would turn it back off + // sits behind the switch. Same shape as console.allowed_ips. + $operator = Operator::factory()->role('Owner')->create(); + + Livewire::actingAs($operator, 'operator')->test(Settings::class) + ->set('requireTwoFactor', true) + ->call('saveTwoFactorPolicy') + ->assertHasErrors('requireTwoFactor'); + + expect(AppSettings::bool('console.require_2fa', false))->toBeFalse(); +}); + +it('allows it once your own two-factor is confirmed', function () { + $operator = Operator::factory()->role('Owner')->create([ + 'two_factor_secret' => encrypt('JBSWY3DPEHPK3PXP'), + 'two_factor_confirmed_at' => now(), + ]); + + Livewire::actingAs($operator, 'operator')->test(Settings::class) + ->set('requireTwoFactor', true) + ->call('saveTwoFactorPolicy') + ->assertHasNoErrors(); + + expect(AppSettings::bool('console.require_2fa', false))->toBeTrue(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec -T app php artisan test --filter=OperatorTwoFactorPolicyTest` +Expected: FAIL — no such middleware, no such action. + +- [ ] **Step 3: Write the middleware** + +```php +user(); + + if ($operator === null || $operator->two_factor_confirmed_at !== null) { + return $next($request); + } + + // The one page that must stay reachable: where two-factor is enrolled. + if (\App\Support\AdminArea::routeIs('admin.settings') || $request->routeIs('admin.logout')) { + return $next($request); + } + + return redirect()->route('admin.settings') + ->with('status', __('admin_settings.two_factor_required')); + } +} +``` + +- [ ] **Step 4: Register and apply it** + +In `bootstrap/app.php`, add to the alias list: + +```php + 'operator.2fa' => \App\Http\Middleware\RequireOperatorTwoFactor::class, +``` + +In `routes/web.php`, add `'operator.2fa'` to the authenticated console group's +middleware array, after `'admin'`, in **both** the exclusive and the fallback +branch. + +- [ ] **Step 5: Add the switch to the settings page** + +In `app/Livewire/Admin/Settings.php`: + +```php + public bool $requireTwoFactor = false; + + public function saveTwoFactorPolicy(): void + { + $this->authorize('site.manage'); + + // The lock-out guard, same shape as console.allowed_ips: the page that + // could switch this back off sits behind the switch. + if ($this->requireTwoFactor && Auth::guard('operator')->user()?->two_factor_confirmed_at === null) { + $this->addError('requireTwoFactor', __('admin_settings.two_factor_self_first')); + + return; + } + + Settings::set('console.require_2fa', $this->requireTwoFactor); + $this->dispatch('notify', message: __('admin_settings.saved')); + } +``` + +Load `$this->requireTwoFactor = Settings::bool('console.require_2fa', false);` +in `mount()`. Add a checkbox and a save button to the blade, following the +card markup already in that file. + +Add to both language files: + +```php + 'two_factor_required' => 'Zwei-Faktor ist für die Konsole verbindlich. Bitte richten Sie sie hier ein.', + 'two_factor_self_first' => 'Richten Sie zuerst Ihre eigene Zwei-Faktor-Bestätigung ein — sonst sperren Sie sich mit diesem Schalter selbst aus.', +``` + +- [ ] **Step 6: Run the tests** + +Run: `docker compose exec -T app php artisan test --filter=OperatorTwoFactorPolicyTest` +Expected: PASS — five tests. +Then: `docker compose exec -T app php artisan test` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add app/Http/Middleware/RequireOperatorTwoFactor.php bootstrap/app.php routes/web.php app/Livewire/Admin/Settings.php resources/views/livewire/admin/settings.blade.php lang/ tests/Feature/Admin/OperatorTwoFactorPolicyTest.php +git commit -m "Let the owner make two-factor compulsory, without locking themselves out" +``` + +--- + +## Task 9: R21, der erzwingende Test, und das Kommando + +**Files:** +- Create: `tests/Feature/IdentitySeparationTest.php` +- Modify: `CLAUDE.md` +- Modify: `app/Console/Commands/CreateAdmin.php` +- Test: siehe unten + +**Interfaces:** +- Consumes: alles Vorherige + +- [ ] **Step 1: Write the enforcing test** + +```php +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 () { + expect(class_exists(App\Http\Responses\ConsoleAwareLoginResponse::class))->toBeFalse() + ->and(class_exists(App\Http\Responses\LandsWhereSignedIn::class))->toBeFalse(); +}); +``` + +- [ ] **Step 2: Run it** + +Run: `docker compose exec -T app php artisan test --filter=IdentitySeparationTest` +Expected: PASS — six tests. If any fails, an earlier task is incomplete; fix +there, not here. + +- [ ] **Step 3: Write R21 into `CLAUDE.md`** + +Append, in the same shape as R18–R20 (Verbote, Warum, Wie gebaut, Erzwungen durch): + +```markdown +## 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` +``` + +- [ ] **Step 4: Rename the command** + +In `app/Console/Commands/CreateAdmin.php`: change the signature's first line to +`clupilot:create-operator`, keep the old name reachable by adding +`protected $aliases = ['clupilot:create-admin'];`, and change the body to create +an `Operator` with the `Owner` role instead of a `User`. Verify the alias works: + +```bash +docker compose exec -T app php artisan clupilot:create-admin --email=probe@clupilot.test --name=Probe --password=ein-langes-testpasswort +docker compose exec -T app php artisan tinker --execute="echo App\Models\Operator::where('email','probe@clupilot.test')->exists() ? 'angelegt' : 'FEHLT';" +``` + +Then remove the probe account. + +- [ ] **Step 5: Run the full suite and build** + +Run: `docker compose exec -T app php artisan test` +Expected: PASS. +Run: `docker compose exec -T app npm run build` + +- [ ] **Step 6: Commit** + +```bash +git add tests/Feature/IdentitySeparationTest.php CLAUDE.md app/Console/Commands/CreateAdmin.php +git commit -m "Write down that the console and the portal share no identity" +``` + +--- + +## Nach dem letzten Task + +- [ ] **Im Browser ansehen, über das VPN.** `/admin` ohne Anmeldung muss auf die + Konsolen-Anmeldung führen, nicht auf die des Portals. Kein Registrieren-Link. + Null Konsolenfehler. Aus dem LAN ohne VPN ist `/admin` weiterhin 404 — das ist + gewollt (`console.network_restricted`), kein Fehler. +- [ ] **Codex-Review (R15)**, Ablauf in der Nutzer-Memory `clupilot-r15-codex-review`. + Im Hintergrund laufen lassen; der Aufruf überschreitet zwei Minuten. Schleifen, + bis keine Befunde mehr kommen — bei der Postfach-Arbeit waren das zehn Runden + mit sechs P1. +- [ ] **Vor dem Live-Update prüfen, dass niemand ausgesperrt wird.** Die Migration + überträgt Passwort-Hash und 2FA, es gilt also dasselbe Passwort wie vorher. + Trotzdem auf dev durchspielen, inklusive `migrate:rollback`. +- [ ] Handoff fortschreiben. + +## Risiken + +| Risiko | Gegenmaßnahme | +|---|---| +| **Aussperren beim Live-Update** | Hash und 2FA werden 1:1 übernommen; zusätzlich `clupilot:create-operator` über die Kommandozeile. Vorher auf dev durchspielen. | +| Spatie-Rollencache hält alte `guard_name` | `forgetCachedPermissions()` in beiden Richtungen der Migration, danach `config:clear` | +| Task 3 ist groß und lässt sich nicht teilen | Bewusst so: ein Guard-Wechsel ist atomar. Dafür ist die Aufgabe davor und danach klein. | +| `VpnPeerPolicy` bekommt je nach Guard ein anderes Modell | Getrennt, nicht verzweigt; beide Wege einzeln getestet | +| Eine Person ist Betreiber **und** zahlender Kunde | Die Migration bricht ab und nennt die Adresse, statt Abrechnungsdaten zu löschen |