# 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 |