diff --git a/app/Livewire/EditSeat.php b/app/Livewire/EditSeat.php index 9637b06..eb91c7f 100644 --- a/app/Livewire/EditSeat.php +++ b/app/Livewire/EditSeat.php @@ -51,9 +51,32 @@ class EditSeat extends ModalComponent * * `nc_username` ist die Angabe, die der Kommentar ohnehin meint — genau * einmal gesetzt, danach unveränderlich. + * + * Der Inhaber-Sitz ist davon AUSGENOMMEN, und diese Ausnahme kam mit dem + * Umbau dazu: an `status === 'invited'` war seine Adresse nie änderbar, + * denn er steht immer auf 'active'. An `nc_username` allein wäre sie es — + * genau so lange, bis die Bereitstellung das Admin-Konto angelegt hat und + * `linkToInstanceAdmin()` greift. + * + * Was dabei entstünde, ist kein Umbenennen: läuft die Adresse des + * Inhaber-Sitzes von der Kundenadresse weg, legt „Anlegen" mit der echten + * Adresse eine ZWEITE Zeile für dieselbe Person an — die gegen die + * Platzgrenze zählt. Der Inhaber-Sitz ist der eine Sitz, dessen Adresse + * nicht ihm gehört, sondern dem Kundenkonto. */ public bool $addressEditable = false; + /** + * Die eine Bedingung, an drei Stellen gebraucht: beim Zeichnen, beim + * Aufstellen der Regeln und beim Übernehmen. Als Methode statt dreimal + * abgeschrieben — auseinanderlaufen muss sie nur einmal, um eine Lücke zu + * sein. + */ + private function adresseAenderbar(Seat $seat): bool + { + return blank($seat->nc_username) && $seat->role !== 'owner'; + } + public function mount(string $uuid): void { $seat = $this->seat($uuid); @@ -64,7 +87,7 @@ class EditSeat extends ModalComponent $this->name = (string) $seat->name; $this->email = (string) $seat->email; $this->isOwner = $seat->role === 'owner'; - $this->addressEditable = blank($seat->nc_username); + $this->addressEditable = $this->adresseAenderbar($seat); } public function save() @@ -80,8 +103,8 @@ class EditSeat extends ModalComponent // Re-read from the record, never from the hydrated property: a forged // addressEditable would otherwise open the address of a seat that - // already has an account in the cloud. - if (blank($seat->nc_username)) { + // already has an account in the cloud — or the owner's own. + if ($this->adresseAenderbar($seat)) { $rules['email'] = 'required|email|max:255'; } @@ -89,7 +112,7 @@ class EditSeat extends ModalComponent $changes = ['name' => trim($data['name'] ?? '') ?: null]; - if (blank($seat->nc_username)) { + if ($this->adresseAenderbar($seat)) { $address = trim($data['email']); if ($address !== $seat->email) { diff --git a/app/Livewire/Users.php b/app/Livewire/Users.php index 50e1198..a45c7f0 100644 --- a/app/Livewire/Users.php +++ b/app/Livewire/Users.php @@ -346,6 +346,34 @@ class Users extends Component default => 'restore', }; + // Dieselbe Wache wie in sendInvite(), und aus demselben Grund: dieser + // `invite`-Zweig schickt genau dasselbe + // `occ user:add --generate-password --email`. + // + // Der Weg hierher fuehrt an der Wache dort vorbei, und er ist am ersten + // Tag rein durch Klicken erreichbar: /users steht offen, solange die + // Instanz noch in Bereitstellung ist (`customer.active` prueft den + // Kunden, nicht die Instanz). Ohne AKTIVE Instanz sagt + // mailversandBereit() `true` — also kein Hinweis, keine Wache, die + // Einladung geht hinaus und scheitert an `no_instance`. Die Zeile wird + // rot, der Wiederholen-Knopf erscheint. Wird die Bereitstellung fertig, + // ohne dass der Versand steht, legte ein Klick das Konto an, occ + // beendete mit 0, und die Plakette sagte „Eingeladen". + // + // Zweiter Weg zum selben Ende, ganz ohne Bereitstellung: ein einmal + // eingerichtetes Versandkonto wird abgeschaltet (Anbieterwechsel, + // Passwortwechsel), und jede rote Zeile ohne `nc_synced_at` fuehrt beim + // Wiederholen dorthin. + // + // Nur fuer `invite`. `disable` und `restore` verschicken nichts — sie + // duerfen auch ohne Mailversand jederzeit laufen, und sie sind die + // Rueckfahrkarte aus einem Fehlschlag. + if ($action === 'invite' && ! $this->mailversandBereit($customer)) { + $this->dispatch('notify', message: __('users.invite_no_mail')); + + return; + } + // Derselbe Vorbehalt wie in queueSync(), und er fehlte hier: war dieser // Sitz nie in der Nextcloud, gibt es dort nichts zu sperren. Ohne die // Zeile drehte sich der Knopf an einer entzogenen, nie gespiegelten diff --git a/tests/Feature/Seats/SyncSeatToNextcloudTest.php b/tests/Feature/Seats/SyncSeatToNextcloudTest.php index 295e315..a9de0a0 100644 --- a/tests/Feature/Seats/SyncSeatToNextcloudTest.php +++ b/tests/Feature/Seats/SyncSeatToNextcloudTest.php @@ -326,6 +326,10 @@ it('spiegelt eine Rollenänderung in die Nextcloud', function () { it('gibt dem Inhaber nach einem Fehlschlag einen zweiten Versuch', function () { Queue::fake(); + // Die Gegenrichtung zur Mailwache an retry(): steht der Versand, wird + // wieder eingeladen. Ohne diese Zeile bestünde der Test aus dem falschen + // Grund nicht mehr — er wäre an der Wache gescheitert statt am Auftrag. + mailversandEingerichtet(); $customer = Customer::factory()->create(); $user = $customer->ensureUser(); Instance::factory()->for($customer)->create(['status' => 'active']); @@ -910,6 +914,59 @@ it('verschickt keine Einladung, solange der Mailversand nicht eingerichtet ist', ->and($sitz->fresh()->nc_username)->toBeNull(); }); +it('wiederholt keine Einladung, solange der Mailversand nicht eingerichtet ist', function () { + // Dieselbe Attrappe über die andere Tür. Der Zweig + // `nc_synced_at === null => 'invite'` in retry() schickt genau dasselbe + // `occ user:add --generate-password --email` wie sendInvite() — und war + // ungewacht. + // + // Der Weg dorthin ist am ersten Tag rein durch Klicken erreichbar: + // + // 1. Die Instanz ist noch in Bereitstellung. /users ist trotzdem offen — + // `customer.active` prüft den Kunden, nicht die Instanz. + // 2. Ohne AKTIVE Instanz liefert mailversandBereit() `true`. Also kein + // Hinweis und keine Wache: „Einladen" meldet „Einladung verschickt …". + // 3. Der Auftrag scheitert an `no_instance`, die Zeile wird rot, der + // Wiederholen-Knopf erscheint. + // 4. Die Bereitstellung wird fertig. Der Mailversand ist weiter nicht + // eingerichtet — der Hinweis erscheint jetzt. + // 5. Ein Klick legte das Konto an, occ beendete mit 0, die Plakette sagte + // „Eingeladen". Niemand bekam etwas. + // + // Derselbe Ausgang ohne Bereitstellung: ein einmal eingerichtetes + // Versandkonto wird abgeschaltet (Anbieterwechsel, Passwortwechsel), und + // jede rote Zeile ohne `nc_synced_at` führt beim Wiederholen dorthin. + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + $instance = Instance::factory()->for($customer)->create(['status' => 'provisioning']); + $sitz = Seat::factory()->for($customer)->create(['role' => 'member']); + + // Schritt 1 und 2: die Einladung geht hinaus, weil es noch nichts gibt, + // worüber sich etwas sagen liesse. + Livewire::actingAs($user)->test(Users::class)->call('sendInvite', $sitz->uuid); + + // Schritt 3: der Auftrag findet keine Instanz. + (new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(NextcloudUsers::class)); + + expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_FAILED) + ->and($sitz->fresh()->nc_error)->toBe('no_instance') + ->and($sitz->fresh()->nc_synced_at)->toBeNull(); + + // Schritt 4: die Bereitstellung wird fertig, der Versand steht weiter nicht. + $instance->update(['status' => 'active']); + + // Schritt 5: und hier muss Schluss sein. + Livewire::actingAs($user)->test(Users::class) + ->call('retry', $sitz->uuid) + ->assertDispatched('notify', fn ($ereignis, $daten) => $daten['message'] !== __('users.retrying')); + + // Nur die erste Einladung aus Schritt 1, keine zweite — und die Zeile + // bleibt rot, statt auf „wird eingerichtet …" zu springen. + Queue::assertPushed(SyncSeatToNextcloud::class, 1); + expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_FAILED); +}); + it('sagt es auf der Seite, statt den Knopf verschwinden zu lassen', function () { // Der Hinweis steht dort, wo der Inhaber ihn VOR dem Klicken liest. Der // Knopf bleibt: die Einladen-Spalte an jeder Zeile verschwinden zu lassen diff --git a/tests/Feature/SeatsTest.php b/tests/Feature/SeatsTest.php index 03c838d..2765a66 100644 --- a/tests/Feature/SeatsTest.php +++ b/tests/Feature/SeatsTest.php @@ -182,6 +182,37 @@ it('will not move a seat that already has an account in the cloud', function () ->and($seat->fresh()->name)->toBe('B'); }); +it('will not move the owner seat to a different address', function () { + // Der Inhaber-Sitz steht immer auf 'active' — an `status === 'invited'` war + // seine Adresse deshalb NIE änderbar. An `nc_username` allein wäre sie es, + // solange `linkToInstanceAdmin()` noch nicht gegriffen hat, also genau vor + // der Bereitstellung. + // + // Was dabei entstünde, ist kein Umbenennen: läuft die Adresse des + // Inhaber-Sitzes von der Kundenadresse weg, legt „Anlegen" mit der echten + // Adresse eine ZWEITE Zeile für dieselbe Person an, die gegen die + // Platzgrenze zählt. + $user = User::factory()->create(); + $customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]); + // Ohne nc_username: die Bereitstellung hat das Admin-Konto noch nicht + // angelegt, der Sitz ist also noch mit nichts im Gast verknüpft. + $owner = $customer->seats()->create([ + 'email' => $user->email, 'name' => 'Inhaberin', 'role' => 'owner', 'status' => 'active', + ]); + + Livewire::actingAs($user) + ->test(EditSeat::class, ['uuid' => $owner->uuid]) + ->assertSet('addressEditable', false) + ->set('addressEditable', true) + ->set('email', 'privat@woanders.test') + ->set('name', 'Dr. Inhaberin') + ->call('save'); + + expect($owner->fresh()->email)->toBe($user->email) + // Umbenennen bleibt erlaubt, auch am Inhaber-Sitz. + ->and($owner->fresh()->name)->toBe('Dr. Inhaberin'); +}); + it('refuses to open a seat belonging to somebody else', function () { // The uuid comes from the browser, and a modal is reachable without the // page's route middleware.