CluPilotCloud/docs/superpowers/plans/2026-07-28-operator-identit...

74 KiB
Raw Blame History

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 <cmd>. 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 <x-ui.nav-item> gehören in <x-slot:icon>.
  • R19: Jede angezeigte Zeit geht durch ->local(). Nie ->timezone(config('app.timezone')).
  • R20: Bearbeiten mit Eingabefeldern passiert im Modal, nie in der Tabellenzeile.
  • <x-ui.button> hat KEINEN Icon-Slot — er rendert nur {{ $slot }}. Icons gehören in den Standard-Slot mit class="size-4". Nur <x-ui.nav-item> hat <x-slot:icon>.
  • 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 <?php, Leerzeile, namespace.
  • Ein Livewire-#[Validate] an einer Eigenschaft gilt klassenweit — Regeln gehören an die Aktion.
  • Pest-Falle: toThrow(SomeInterface::class) degradiert still zu einem String-Vergleich auf der Fehlermeldung, weil class_exists() für ein Interface false ist. Nie gegen einen Interface-Namen prüfen; konkrete Klasse oder try/catch.
  • Commit-Konvention: kurzer Aussagesatz im Imperativ, kein feat:/chore:-Präfix.
  • Nach jeder Aufgabe: docker compose exec -T app php artisan test vollständig grün. Ausgangsstand: 815 Tests.

File Structure

Datei Verantwortung
database/migrations/..._create_operators_table.php neu — die Tabelle
app/Models/Operator.php neu — Betreiberkonto: HasRoles (Guard operator), TwoFactorAuthenticatable, HasUuid
database/factories/OperatorFactory.php neu — Testdaten, mit ->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

use App\Models\Mailbox;

it('names only the EU as the server location, on the sign-in page', function () {
    $this->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:

    'fact_location' => 'EU',

lang/en/auth.php:37:

    'fact_location' => 'EU',

resources/views/landing.blade.php:466 — replace the whole row:

        <div class="row"><dt>Serverstandort</dt><dd>EU</dd></div>
  • 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
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

use App\Models\Operator;
use Illuminate\Support\Facades\Auth;

it('authenticates on its own guard, not on web', function () {
    $operator = Operator::factory()->create(['password' => 'geheim-genug-12']);

    expect(Auth::guard('operator')->attempt([
        'email' => $operator->email, 'password' => 'geheim-genug-12',
    ]))->toBeTrue();

    // The web guard resolves against users, which has no such row.
    expect(Auth::guard('web')->attempt([
        'email' => $operator->email, 'password' => 'geheim-genug-12',
    ]))->toBeFalse();
});

it('hashes the password rather than storing it', function () {
    $operator = Operator::factory()->create(['password' => 'geheim-genug-12']);

    expect($operator->password)->not->toBe('geheim-genug-12')
        ->and(Hash::check('geheim-genug-12', $operator->password))->toBeTrue();
});

it('assigns a uuid on create and uses it as the route key', function () {
    $operator = Operator::factory()->create();

    expect($operator->uuid)->not->toBeNull()
        ->and($operator->getRouteKeyName())->toBe('uuid');
});

it('carries the two-factor columns Fortify expects', function () {
    $operator = Operator::factory()->create();

    expect($operator->two_factor_secret)->toBeNull()
        ->and($operator->two_factor_confirmed_at)->toBeNull()
        ->and(method_exists($operator, 'twoFactorQrCodeSvg'))->toBeTrue();
});

it('can be disabled without deleting the record', function () {
    $operator = Operator::factory()->create(['disabled_at' => now()]);

    expect($operator->fresh()->disabled_at)->not->toBeNull();
});
  • 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

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

/**
 * The people who run CluPilot: the owner and the staff.
 *
 * Their own table rather than a flag on `users`, because a flag lets the state
 * "operator who is also a portal account" reappear at any time — and that state
 * is why OperatorInPortalTest had to exist: an operator without a customer
 * could wander into the portal and press buttons that did nothing. A separate
 * table makes the class of fault impossible rather than caught.
 */
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('operators', function (Blueprint $table) {
            $table->id();
            $table->uuid()->unique();

            $table->string('name');
            $table->string('email')->unique();
            $table->string('password');
            $table->rememberToken();

            // Same column names Fortify's TwoFactorAuthenticatable expects.
            $table->text('two_factor_secret')->nullable();
            $table->text('two_factor_recovery_codes')->nullable();
            $table->timestamp('two_factor_confirmed_at')->nullable();

            // Who is still active — the basis for the staff view that comes later.
            $table->timestamp('last_login_at')->nullable();

            // A member of staff who has left, without deleting the audit trail
            // their id appears in.
            $table->timestamp('disabled_at')->nullable();

            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('operators');
    }
};
  • Step 4: Write the model and factory
<?php

namespace App\Models;

use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Spatie\Permission\Traits\HasRoles;

/**
 * An operator: the owner of CluPilot, or a member of staff.
 *
 * Extends Authenticatable rather than App\Models\User — sharing the class would
 * put both groups back in one table, which is the thing this exists to end.
 *
 * `$guard_name` is what makes Spatie resolve roles against the `operator` guard.
 * Without it every role assignment would be checked against `web` and fail,
 * which is the failure mode to expect if roles suddenly stop matching.
 */
class Operator extends Authenticatable
{
    use HasFactory, HasRoles, HasUuid, Notifiable, TwoFactorAuthenticatable;

    /** Tells spatie/laravel-permission which guard this model's roles live under. */
    protected string $guard_name = 'operator';

    /** The six roles that grant access to the console. */
    public const OPERATOR_ROLES = ['Owner', 'Admin', 'Support', 'Billing', 'Read-only', 'Developer'];

    protected $fillable = ['name', 'email', 'password', 'last_login_at', 'disabled_at'];

    protected $hidden = ['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'];

    protected function casts(): array
    {
        return [
            'password' => 'hashed',
            'last_login_at' => 'datetime',
            'disabled_at' => 'datetime',
            'two_factor_confirmed_at' => 'datetime',
        ];
    }

    /** True when this operator holds a role that reaches the console. */
    public function isOperator(): bool
    {
        return $this->hasAnyRole(self::OPERATOR_ROLES);
    }

    /** A disabled operator keeps their record and loses their access. */
    public function isActive(): bool
    {
        return $this->disabled_at === null;
    }
}
<?php

namespace Database\Factories;

use App\Models\Operator;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<Operator> */
class OperatorFactory extends Factory
{
    protected $model = Operator::class;

    public function definition(): array
    {
        return [
            'name' => $this->faker->name(),
            'email' => $this->faker->unique()->safeEmail(),
            'password' => 'passwort-fuer-tests',
        ];
    }

    /** Give the operator a console role. Roles are seeded by migration. */
    public function role(string $role = 'Owner'): static
    {
        return $this->afterCreating(fn (Operator $operator) => $operator->syncRoles([$role]));
    }
}
  • Step 5: Register the guard

In config/auth.php, add to guards:

        // 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:

        'operators' => [
            'driver' => 'eloquent',
            'model' => App\Models\Operator::class,
        ],

and to passwords (needed later for operator password resets; harmless now):

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

use App\Models\Customer;
use App\Models\Operator;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;

it('moves every permission and role to the operator guard, leaving none behind', function () {
    expect(Permission::where('guard_name', 'web')->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

use App\Models\Operator;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
use Spatie\Permission\PermissionRegistrar;

/**
 * RBAC moves to the operator guard. It is a move, not a copy.
 *
 * Every one of the seventeen permissions is a console permission — there is no
 * customer-facing one among them — so duplicating the set under a second guard
 * would create two role sets that drift apart. Moving them leaves `users` with
 * no roles at all, which is exactly right.
 */
return new class extends Migration
{
    public function up(): void
    {
        DB::transaction(function () {
            app(PermissionRegistrar::class)->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:

    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():

        if ($this->fromManagementNetwork($request) || Auth::guard('operator')->check()) {

app/Http/Middleware/EnsureCustomerActive.php:22 — the operator special case disappears; only the impersonation flag remains:

        if ($user !== null && ! $request->session()->has('impersonator_id')) {

app/Models/Customer.php:138 — replace $user->is_admin || $user->isOperator():

        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:

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:

{{-- 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:

// before
Livewire::actingAs(User::factory()->operator('Owner')->create())->test(...)
// after
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')->test(...)

and for HTTP tests:

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

use App\Livewire\Admin\Secrets;
use App\Models\Operator;
use App\Models\User;
use Livewire\Livewire;

beforeEach(fn () => 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:

    /**
     * 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():

        $account = auth()->guard($this->confirmationGuard())->user();
        $key = 'confirm-password:'.$this->confirmationGuard().'|'.$account?->getAuthIdentifier().'|'.request()->ip();
        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:

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

use App\Livewire\Auth\OperatorLogin;
use App\Models\Operator;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Livewire\Livewire;

it('offers no way to register — an operator account is not self-served', function () {
    $this->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

namespace App\Livewire\Auth;

use App\Models\Operator;
use App\Support\AdminArea;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;

/**
 * The console's own sign-in.
 *
 * Not Fortify: Fortify binds to exactly one guard, and hanging both the portal
 * and the console off it would be the shared login this separation exists to
 * end. The two-factor MECHANICS are still Fortify's — the same
 * TwoFactorAuthenticatable trait and the same TOTP provider — only the flow is
 * ours.
 */
#[Layout('layouts.portal')]
class OperatorLogin extends Component
{
    public string $email = '';

    public string $password = '';

    public bool $remember = false;

    public function authenticate(): void
    {
        // Rules on the action: a #[Validate] attribute on a property applies
        // class-wide and would be dragged into any other action added later.
        $this->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

namespace App\Livewire\Auth;

use App\Models\Operator;
use App\Support\AdminArea;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
use Livewire\Attributes\Layout;
use Livewire\Component;

/**
 * The operator's two-factor step.
 *
 * Fortify's TOTP provider does the arithmetic — there is no second
 * implementation of the algorithm here, only a second flow, because Fortify's
 * own flow is bound to the web guard.
 */
#[Layout('layouts.portal')]
class OperatorTwoFactorChallenge extends Component
{
    public string $code = '';

    public string $recoveryCode = '';

    public bool $usingRecovery = false;

    public function mount(): void
    {
        // Reachable only between password and code. Landing here directly is a
        // dead end, not a way in.
        abort_if(! session()->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:

    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

use App\Livewire\Auth\OperatorLogin;
use App\Livewire\Auth\OperatorTwoFactorChallenge;
use Illuminate\Support\Facades\Route;

/*
| The console's own front door.
|
| Registered before the authenticated console routes so that /login on the
| console host resolves here, and NOT to the portal's sign-in page — which is
| what put a "Registrieren" link in front of an operator and a 404 behind it.
*/
Route::get('/login', OperatorLogin::class)->name('login');
Route::get('/two-factor', OperatorTwoFactorChallenge::class)->name('two-factor');

and in routes/admin.php, inside the authenticated group, add the logout:

// 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 <x-auth.brand> 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.
<div class="flex min-h-screen items-center justify-center bg-bg px-6 py-12">
    <div class="w-full max-w-sm animate-rise">
        <div class="mb-9 flex items-center gap-2.5">
            <x-ui.logo class="size-8" />
            <span class="text-lg font-bold tracking-tight text-ink">Clu<span class="text-accent-text">Pilot</span></span>
        </div>

        <h1 class="text-2xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.console_login_title') }}</h1>
        <p class="mt-2 text-sm text-muted">{{ __('auth.console_login_subtitle') }}</p>

        <form wire:submit="authenticate" class="mt-8 space-y-5">
            <x-ui.input name="email" type="email" wire:model="email" :label="__('auth.email')" autocomplete="username" autofocus required />
            <x-ui.input name="password" type="password" wire:model="password" :label="__('auth.password_label')" autocomplete="current-password" required />
            <x-ui.checkbox name="remember" wire:model="remember" :label="__('auth.remember')" value="1" />
            <x-ui.button type="submit" variant="primary" size="md" class="w-full">{{ __('auth.sign_in') }}</x-ui.button>
        </form>
    </div>
</div>

resources/views/livewire/auth/operator-two-factor-challenge.blade.php — the same shell, with <x-ui.otp-input> 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):

    '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
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"

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:

    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:

    /**
     * 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.

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

use App\Models\Customer;
use App\Models\Operator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\URL;

it('hands over a signed link instead of relying on a shared cookie', function () {
    $operator = Operator::factory()->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

namespace App\Http\Controllers;

use App\Models\Customer;
use App\Models\Operator;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\URL;

/**
 * Looking at a customer's portal as that customer.
 *
 * Not a shared cookie. Once the console has a hostname of its own, a session
 * cookie set on the console host never reaches the portal host — SESSION_DOMAIN
 * is null and cookies are host-bound. So the handover is a signed, single-use
 * link: it works regardless of cookie scope, it expires, and it leaves a record
 * of who looked at whom.
 */
class ImpersonationController extends Controller
{
    /** Sixty seconds is a handover, not a credential. */
    private const WINDOW = 60;

    public function start(Customer $customer): RedirectResponse
    {
        $this->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:

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

use App\Livewire\Admin\Settings;
use App\Models\Operator;
use App\Support\Settings as AppSettings;
use Livewire\Livewire;

it('is voluntary by default', function () {
    expect(AppSettings::bool('console.require_2fa', false))->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

namespace App\Http\Middleware;

use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;

/**
 * Two-factor for operators, when the owner has made it compulsory.
 *
 * Off by default. The settings page itself is exempt, because it is where the
 * operator sets two-factor up — gating it would leave nobody able to satisfy
 * the requirement.
 */
class RequireOperatorTwoFactor
{
    public function handle(Request $request, Closure $next): Response
    {
        if (! Settings::bool('console.require_2fa', false)) {
            return $next($request);
        }

        $operator = Auth::guard('operator')->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:

            '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:

    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:

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

use App\Models\Operator;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;

/**
 * R21 — the console and the portal share no identity.
 *
 * Not a style rule. A shared sign-in page showed an operator a registration
 * link with a 404 behind it, and that was not a display fault: it was two
 * different groups of people in one table.
 */

it('leaves no role or permission on the web guard', function () {
    expect(Role::where('guard_name', 'web')->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 R18R20 (Verbote, Warum, Wie gebaut, Erzwungen durch):

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

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