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