diff --git a/app/Livewire/EditSeat.php b/app/Livewire/EditSeat.php index 6064f35..9637b06 100644 --- a/app/Livewire/EditSeat.php +++ b/app/Livewire/EditSeat.php @@ -34,10 +34,23 @@ class EditSeat extends ModalComponent /** * Whether the address may still be corrected. * - * Only while the invitation is in flight. Once someone has accepted, the - * address IS the person: editing it would hand one employee's access to - * another with nobody told — a transfer of access wearing the clothes of a - * rename. + * Only until the seat has an account in the customer's cloud. From that + * point the address IS the person: editing it would hand one employee's + * access to another with nobody told — a transfer of access wearing the + * clothes of a rename. + * + * Die Bedingung hing an `status === 'invited'`, und das trug nicht: NICHTS + * im Bestand setzt `seats.status` je von 'invited' auf 'active'. Ein + * eingeladener Sitz blieb damit für immer 'invited' und seine Adresse + * dauerhaft änderbar — entgegen genau diesem Kommentar. `seats.email` + * wurde `b@y`, `nc_username` blieb `a@x` (richtig: Nextcloud kann Benutzer + * nicht umbenennen), und kein Befehl trug die neue Adresse in den Gast. + * „Erneut senden" schickte danach `user:welcome --reset-password 'a@x'`: + * das Portal führte `b@y` als Inhaberin des Sitzes, tatsächlich hielt + * `a@x` den Zugang und bekam jederzeit einen neuen Zurücksetzen-Link. + * + * `nc_username` ist die Angabe, die der Kommentar ohnehin meint — genau + * einmal gesetzt, danach unveränderlich. */ public bool $addressEditable = false; @@ -51,7 +64,7 @@ class EditSeat extends ModalComponent $this->name = (string) $seat->name; $this->email = (string) $seat->email; $this->isOwner = $seat->role === 'owner'; - $this->addressEditable = $seat->status === 'invited'; + $this->addressEditable = blank($seat->nc_username); } public function save() @@ -66,8 +79,9 @@ class EditSeat extends ModalComponent $rules = ['name' => 'nullable|string|max:255']; // Re-read from the record, never from the hydrated property: a forged - // addressEditable would otherwise open the address of an accepted seat. - if ($seat->status === 'invited') { + // addressEditable would otherwise open the address of a seat that + // already has an account in the cloud. + if (blank($seat->nc_username)) { $rules['email'] = 'required|email|max:255'; } @@ -75,7 +89,7 @@ class EditSeat extends ModalComponent $changes = ['name' => trim($data['name'] ?? '') ?: null]; - if ($seat->status === 'invited') { + if (blank($seat->nc_username)) { $address = trim($data['email']); if ($address !== $seat->email) { diff --git a/tests/Feature/SeatsTest.php b/tests/Feature/SeatsTest.php index d93ae0a..03c838d 100644 --- a/tests/Feature/SeatsTest.php +++ b/tests/Feature/SeatsTest.php @@ -32,7 +32,7 @@ it('creates an owner seat on first visit', function () { // invite() heisst seit dem Nextcloud-Anschluss addSeat(), weil sie genau das // tut und nichts weiter: eine Zeile anlegen. Verschickt wird erst in // sendInvite() — siehe SyncSeatToNextcloudTest. Die Sitzplatzgrenze und die -// Sperre gegen doppelte Adressen sind dabei unveraendert geblieben. +// Sperre gegen doppelte Adressen sind dabei unverändert geblieben. it('adds a seat and blocks duplicates', function () { ['user' => $user, 'customer' => $customer] = seatSetup(); @@ -65,8 +65,8 @@ it('will not remove or demote the last owner', function () { $owner = $customer->seats()->where('role', 'owner')->first(); Livewire::actingAs($user)->test(Users::class)->call('revoke', $owner->uuid); - // Auf den STATUS geprueft, nicht mehr nur auf die Zeile: seit revoke() - // grundsaetzlich nicht mehr loescht, bewiese eine noch vorhandene Zeile + // Auf den STATUS geprüft, nicht mehr nur auf die Zeile: seit revoke() + // grundsätzlich nicht mehr löscht, bewiese eine noch vorhandene Zeile // gar nichts — sie bliebe auch dann stehen, wenn die Inhaber-Sperre // versagte. Der Status ist die Zusicherung, die hier gemeint war. expect($customer->seats()->whereKey($owner->id)->exists())->toBeTrue() @@ -77,11 +77,11 @@ it('will not remove or demote the last owner', function () { }); it('revokes a non-owner seat without deleting the row', function () { - // Frueher wurde die Zeile geloescht. Das faellt weg: ein Fehlgriff im + // Früher wurde die Zeile gelöscht. Das fällt weg: ein Fehlgriff im // Userpanel darf die Arbeit eines Menschen nicht vernichten, und in einem - // Produkt, das mit Nachvollziehbarkeit verkauft wird, gaebe es danach - // nichts mehr zu zeigen, wer welchen Zugang hatte. Geloescht wird, wenn - // ueberhaupt, in der Nextcloud selbst — dort fragt Nextcloud nach, was mit + // Produkt, das mit Nachvollziehbarkeit verkauft wird, gäbe es danach + // nichts mehr zu zeigen, wer welchen Zugang hatte. Gelöscht wird, wenn + // überhaupt, in der Nextcloud selbst — dort fragt Nextcloud nach, was mit // den Dateien geschehen soll. ['user' => $user, 'customer' => $customer] = seatSetup(); $seat = Seat::factory()->create(['customer_id' => $customer->id, 'role' => 'member']); @@ -129,7 +129,9 @@ it('renames any seat, the owner included', function () { expect($owner->fresh()->name)->toBe('Dr. M. Muster'); }); -it('corrects the address of an invitation still in flight', function () { +it('corrects the address of an invitation nobody has sent yet', function () { + // Angelegt, noch nicht eingeladen: in der Cloud gibt es dazu nichts, und + // ein Tippfehler ist an dieser Stelle folgenlos zu berichtigen. $user = User::factory()->create(); $customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]); $seat = $customer->seats()->create([ @@ -146,15 +148,25 @@ it('corrects the address of an invitation still in flight', function () { expect($seat->fresh()->email)->toBe('typo@example.test'); }); -it('will not move an accepted seat to a different address', function () { - // The address IS the person once they are in. Editing it would hand one - // employee's access to another with nobody told — a transfer of access - // wearing the clothes of a rename. Driven past the form on purpose: the - // property is hydrated from the browser and must not be trusted. +it('will not move a seat that already has an account in the cloud', function () { + // Die Bedingung hing an `status === 'invited'` — und NICHTS im Bestand + // setzt `seats.status` je von 'invited' auf 'active'. Ein eingeladener + // Sitz blieb damit für immer 'invited', und die Adresse dauerhaft + // änderbar: `seats.email` wurde `b@y`, `nc_username` blieb `a@x` + // (richtig — Nextcloud kann nicht umbenennen), und kein Befehl trug die + // neue Adresse in den Gast. „Erneut senden" schickte danach + // `user:welcome --reset-password 'a@x'`: das Portal führte `b@y` als + // Inhaberin des Sitzes, tatsächlich hielt `a@x` den Zugang und bekam + // jederzeit einen neuen Zurücksetzen-Link. + // + // Massgeblich ist deshalb `nc_username`: ab da IST die Adresse die Person. + // Am Formular vorbei gefahren, mit Absicht — die Eigenschaft kommt aus + // dem Browser und darf nicht geglaubt werden. $user = User::factory()->create(); $customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]); $seat = $customer->seats()->create([ - 'email' => 'in@example.test', 'name' => 'A', 'role' => 'member', 'status' => 'active', + 'email' => 'in@example.test', 'name' => 'A', 'role' => 'member', 'status' => 'invited', + 'nc_username' => 'in@example.test', ]); Livewire::actingAs($user) @@ -166,6 +178,7 @@ it('will not move an accepted seat to a different address', function () { ->call('save'); expect($seat->fresh()->email)->toBe('in@example.test') + // Umbenennen bleibt erlaubt — es ist dieselbe Person. ->and($seat->fresh()->name)->toBe('B'); }); @@ -262,8 +275,8 @@ it('revokes the seat once the page receives the confirmed event', function () { Livewire::actingAs($user)->test(Users::class)->call('onRevokeConfirmed', $seat->uuid); - // Wieder der Status statt der Existenz: revoke() loescht nicht mehr (siehe - // die Notiz weiter oben), also waere „die Zeile ist weg" jetzt eine + // Wieder der Status statt der Existenz: revoke() löscht nicht mehr (siehe + // die Notiz weiter oben), also wäre „die Zeile ist weg" jetzt eine // Zusicherung, die das Produkt bewusst nicht mehr gibt. expect($seat->fresh()->status)->toBe('revoked'); });