diff --git a/app/Livewire/Users.php b/app/Livewire/Users.php index aeb5c8f..50e1198 100644 --- a/app/Livewire/Users.php +++ b/app/Livewire/Users.php @@ -6,6 +6,7 @@ use App\Livewire\Concerns\ResolvesCustomer; use App\Models\Customer; use App\Models\Seat; use App\Provisioning\Jobs\SyncSeatToNextcloud; +use App\Services\Mail\GuestMailConfig; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\RateLimiter; @@ -226,6 +227,28 @@ class Users extends Component return; } + // Die Einladung verschickt NEXTCLOUD, nicht CluPilot — nur so entsteht + // das Passwort dort, wo niemand sonst es zu sehen bekommt. Ohne + // eingerichteten Mailversand kommt dort aber nichts heraus, und + // `occ user:add --generate-password --email` GELINGT trotzdem: + // Nextcloud legt das Konto an, versucht die Willkommensmail, + // protokolliert intern einen Fehler und beendet mit 0. Der Auftrag + // meldete Erfolg, die Zeile sprang auf „Eingeladen", die Meldung + // versprach einen Link — und niemand bekam etwas. + // + // Am Tag der Auslieferung ist das kein Randfall, sondern der + // Normalzustand: der Mailserver dieses Produkts entsteht gerade erst, + // und das Postfach steht bis dahin leer. + // + // VOR dem Ratelimit, damit ein abgewiesener Versuch kein Kontingent + // verbraucht: sonst hätte der Inhaber seine Versuche aufgebraucht, + // bevor überhaupt einer hinausgehen konnte. + if (! $this->mailversandBereit($customer)) { + $this->dispatch('notify', message: __('users.invite_no_mail')); + + return; + } + if (($warten = $this->rateLimited($customer, $seat)) !== null) { $this->dispatch('notify', message: __('users.too_many_invites', ['minutes' => $warten])); @@ -550,6 +573,36 @@ class Users extends Component $this->revoke($uuid); } + /** + * Ob eine Einladung dieses Kunden überhaupt ankommen könnte. + * + * Ein reiner Blick in die Datenbank — Servereinstellung und + * Versandkonto — kein Tunnel, keine Warteschlange. Deshalb darf er auf der + * Seite stehen und nicht erst im Auftrag. + * + * OHNE Instanz gibt es hier nichts zu entscheiden: dann scheitert der + * Auftrag ohnehin an `no_instance`, und die Zeile sagt das im Klartext. + * Diese Wache gilt dem anderen Fall — die Cloud LÄUFT, nur der Versand + * fehlt. Genau dort meldet occ Erfolg, und niemand bekommt eine Mail. + * + * Dieselbe Auswahl wie im Auftrag (SyncSeatToNextcloud): die Instanz, in + * der die Einladung tatsächlich landen würde. + * + * Der Bereitstellungsschritt bleibt davon unberührt. Dort gilt weiter + * „nicht eingerichtet ist etwas anderes als kaputt", und eine bezahlte + * Bestellung läuft durch — eine Cloud ohne Mailversand funktioniert + * vollständig. Was sie nicht kann, ist eine Einladung verschicken, und nur + * darum geht es hier. + */ + private function mailversandBereit(Customer $customer): bool + { + $instance = $customer->instances() + ->whereIn('status', ['active', 'cancellation_scheduled']) + ->latest('id')->first(); + + return $instance === null || GuestMailConfig::for($instance)->available(); + } + private function usedSeats(Customer $customer): int { return $customer->seats()->where('status', '!=', 'revoked')->count(); @@ -583,6 +636,9 @@ class Users extends Component 'used' => $customer ? $this->usedSeats($customer) : 0, 'limit' => $customer ? $this->seatLimit($customer) : 0, 'roles' => Seat::ROLES, + // Der Hinweis steht dort, wo der Inhaber ihn liest, bevor er + // drückt. Der Knopf bleibt trotzdem stehen — siehe das Blade. + 'versandBereit' => $customer === null || $this->mailversandBereit($customer), ]); } } diff --git a/lang/de/users.php b/lang/de/users.php index 047369d..473aa0f 100644 --- a/lang/de/users.php +++ b/lang/de/users.php @@ -90,6 +90,12 @@ return [ 'added' => 'Benutzer angelegt. Die Einladung verschicken Sie mit „Einladen".', 'reinstated' => 'Zugang wieder aufgenommen. Die Einladung verschicken Sie mit „Einladen".', 'invite_sent' => 'Einladung verschickt. Der Mitarbeiter bekommt einen Link, an dem er sein Passwort selbst setzt.', + // Die Einladung verschickt die Nextcloud des Kunden selbst — ohne + // eingerichteten Mailversand kommt dort nichts heraus, und `occ user:add` + // meldet trotzdem Erfolg. Der Satz sagt deshalb beides: dass nichts + // verschickt wurde, und woran es liegt. + 'mail_not_ready' => 'Einladungen sind noch nicht möglich: Der Mailversand für Ihre Cloud ist noch nicht eingerichtet. Die Einladung verschickt Ihre Cloud selbst — ohne Versand bekäme niemand eine Mail. Bitte wenden Sie sich an den Support. Anlegen können Sie Ihre Leute schon jetzt.', + 'invite_no_mail' => 'Es wurde nichts verschickt: Der Mailversand für Ihre Cloud ist noch nicht eingerichtet. Bitte wenden Sie sich an den Support.', 'too_many_invites' => 'Zu viele Einladungen — in :minutes Minuten wieder möglich.', 'revoked' => 'Benutzer entfernt.', 'resent' => 'Einladung erneut gesendet.', diff --git a/lang/en/users.php b/lang/en/users.php index 32afafb..8dc5ee1 100644 --- a/lang/en/users.php +++ b/lang/en/users.php @@ -89,6 +89,12 @@ return [ 'added' => 'User added. Send the invitation with "Invite".', 'reinstated' => 'Access taken back on. Send the invitation with "Invite".', 'invite_sent' => 'Invitation sent. They get a link to set their own password.', + // The invitation is sent by the customer's own Nextcloud — with no mail + // delivery configured, nothing leaves it, and `occ user:add` reports + // success all the same. The sentence therefore says both: that nothing + // was sent, and why. + 'mail_not_ready' => 'Invitations are not possible yet: mail delivery for your cloud is not set up. The invitation is sent by your own cloud — without delivery, nobody would receive anything. Please contact support. You can already add your people.', + 'invite_no_mail' => 'Nothing was sent: mail delivery for your cloud is not set up yet. Please contact support.', 'too_many_invites' => 'Too many invitations — possible again in :minutes minutes.', 'revoked' => 'User removed.', 'resent' => 'Invitation resent.', diff --git a/resources/views/livewire/users.blade.php b/resources/views/livewire/users.blade.php index dfddfc8..ce56378 100644 --- a/resources/views/livewire/users.blade.php +++ b/resources/views/livewire/users.blade.php @@ -11,9 +11,9 @@ - {{-- Anlegen. Ausdruecklich NICHT einladen: hier entsteht nur die Zeile, + {{-- Anlegen. Ausdrücklich NICHT einladen: hier entsteht nur die Zeile, die Einladung verschickt der Knopf in der Tabelle. Ein Inhaber soll - sein Team vorbereiten koennen, ohne dass jemand eine Mail bekommt. --}} + sein Team vorbereiten können, ohne dass jemand eine Mail bekommt. --}}
@@ -40,6 +40,25 @@ + {{-- Der Mailversand fehlt: gesagt, bevor jemand drückt. + + Der Einladen-Knopf bleibt trotzdem stehen. Ihn an jeder Zeile + verschwinden zu lassen liest sich nicht als „geht hier gerade nicht", + sondern als „das kann dieses Produkt nicht" — genau die Beschwerde, + die schon einmal dazu geführt hat, dass die Aktionsspalte IMMER + gezeichnet wird (siehe Users::render()). Und der Zustand ist + vorübergehend: er endet, sobald der Betrieb das Versandkonto + ausfüllt, ohne dass sich an dieser Seite etwas ändert. + + Einmal über der Tabelle statt einmal je Zeile: der Grund ist für + alle derselbe, und an jeder Zeile stünde derselbe Satz noch einmal. + Der Server weist die Einladung mit derselben Begründung ab — der + Knopf verspricht also nichts, was der Hinweis nicht schon + zurücknimmt. --}} + @unless ($versandBereit) + {{ __('users.mail_not_ready') }} + @endunless + {{-- Seats --}}
@@ -73,10 +92,10 @@ {{-- Was der Inhaber WILL (`status`) und was in der Nextcloud WIRKLICH ist (`nc_state`) stehen in einer Zelle, und der wahre Zustand geht vor: - solange der Auftrag laeuft oder gescheitert - ist, waere „Eingeladen" eine Behauptung ueber + solange der Auftrag läuft oder gescheitert + ist, wäre „Eingeladen" eine Behauptung über etwas, das die Warteschlange noch gar nicht - erledigt hat. Ohne diese Spalte drueckt der + erledigt hat. Ohne diese Spalte drückt der Inhaber wieder und wieder, weil nichts sichtbar geschieht. --}} @@ -84,7 +103,7 @@ {{-- Entzogen geht dem Zustand vor: ein „wird eingerichtet …" an einer geschlossenen Zeile las sich wie ein - Weg zurueck, den es an DIESER Zeile + Weg zurück, den es an DIESER Zeile nicht gibt. --}} {{ __('users.status_revoked') }}

{{ __('users.revoked_closed') }}

@@ -98,15 +117,15 @@ @endif {{-- Der Fehlschlag steht NEBEN dem Entzogen, - nicht dahinter. Sonst verschwaende genau - dort die Rueckfahrkarte, wo sie am - noetigsten ist: scheitert das `disable`, + nicht dahinter. Sonst verschwände genau + dort die Rückfahrkarte, wo sie am + nötigsten ist: scheitert das `disable`, ist das Konto im Gast weiter offen, - waehrend die Zeile „entzogen" sagt — kein + während die Zeile „entzogen" sagt — kein Wiederholungslauf (tries = 1), kein Abgleich im Zeitplan, und diese Seite ist die einzige Stelle im Repo, die nc_state - ueberhaupt anzeigt. --}} + überhaupt anzeigt. --}} @if ($seat->nc_state === \App\Models\Seat::STATE_FAILED)
$seat->status === 'revoked'])> {{ __('users.state_failed') }} @@ -117,10 +136,10 @@

{{ __('users.error_'.($seat->nc_error ?: 'unexpected')) }}

@php // Dieselbe Bedingung wie in retry(): ein Knopf, der - // nur wieder rot zurueckkommt, ist schlimmer als + // nur wieder rot zurückkommt, ist schlimmer als // keiner. Eine entzogene oder gesperrte Zeile, die // nie in der Nextcloud ankam, hat dort nichts zu - // sperren — da gaebe es nichts zu wiederholen. + // sperren — da gäbe es nichts zu wiederholen. $wiederholbar = $seat->role !== 'owner' && ($seat->nc_synced_at !== null || ! in_array($seat->status, ['revoked', 'suspended'], true)); @@ -137,11 +156,11 @@
@if ($seat->status === 'revoked') {{-- Kein Knopf an der Zeile. Der Weg - zurueck geht durch die Vordertuer: das + zurück geht durch die Vordertür: das Anlegen-Formular oben, mit derselben Adresse — und damit durch die - Platzpruefung, die es an der Zeile - nicht gaebe. Gesagt statt leer + Platzprüfung, die es an der Zeile + nicht gäbe. Gesagt statt leer gelassen, wie bei der Inhaber-Zeile — eine leere Zelle liesse jemanden nach einem Knopf suchen, der absichtlich @@ -153,7 +172,7 @@ steht nur an einer Zeile, die noch nichts in der Nextcloud hat; ist der Auftrag unterwegs, gibt es nichts zu - druecken, und bei einem Fehlschlag + drücken, und bei einem Fehlschlag steht „Nochmal versuchen" schon in der Zustandsspalte. Nie an der Inhaber-Zeile: dieses Konto legt die diff --git a/tests/Feature/Seats/SyncSeatToNextcloudTest.php b/tests/Feature/Seats/SyncSeatToNextcloudTest.php index b8f4e23..295e315 100644 --- a/tests/Feature/Seats/SyncSeatToNextcloudTest.php +++ b/tests/Feature/Seats/SyncSeatToNextcloudTest.php @@ -6,15 +6,37 @@ use App\Livewire\Users; use App\Models\Customer; use App\Models\Host; use App\Models\Instance; +use App\Models\Mailbox; use App\Models\Seat; use App\Provisioning\Jobs\SyncSeatToNextcloud; +use App\Services\Mail\GuestMailConfig; use App\Services\Nextcloud\NextcloudUsers; use App\Services\Proxmox\FakeProxmoxClient; use App\Services\Proxmox\ProxmoxClient; +use App\Support\Settings; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; use Symfony\Component\Finder\Finder; +/** + * Der Mailversand, eingerichtet — Server und ausgefülltes Versandkonto. + * + * Steht jetzt in jedem Test, der wirklich einladen will: die Einladung + * verschickt die Nextcloud des Kunden selbst, und ohne Versand käme dort nie + * eine Mail heraus. Angelegt ist das Postfach ab der eigenen Wanderung — aber + * inaktiv und ohne Zugangsdaten, also genau der Zustand, den „Einladen" jetzt + * abweist. + */ +function mailversandEingerichtet(): void +{ + Settings::set('mail.host', 'mail.clupilot.cloud'); + Settings::set('mail.port', 587); + + Mailbox::findByKey(GuestMailConfig::RELAY_KEY)->update([ + 'address' => 'noreply@clupilot.cloud', 'password' => 'geheim', 'active' => true, + ]); +} + it('schreibt den Erfolg an den Sitz', function () { app()->instance(ProxmoxClient::class, new FakeProxmoxClient); $customer = Customer::factory()->create(); @@ -92,6 +114,7 @@ it('legt beim Anlegen KEINEN Nextcloud-Benutzer an', function () { it('schickt erst beim Einladen einen Auftrag los', function () { Queue::fake(); + mailversandEingerichtet(); $customer = Customer::factory()->create(); $user = $customer->ensureUser(); Instance::factory()->for($customer)->create(['status' => 'active']); @@ -108,6 +131,7 @@ it('hält den Benutzernamen fest, auch wenn die Mailadresse sich ändert', funct // Nextcloud kann Benutzer nicht umbenennen. Ein Sitz, dessen Adresse sich // später ändert, behält seinen Anmeldenamen. Queue::fake(); + mailversandEingerichtet(); $customer = Customer::factory()->create(); $user = $customer->ensureUser(); Instance::factory()->for($customer)->create(['status' => 'active']); @@ -121,6 +145,7 @@ it('hält den Benutzernamen fest, auch wenn die Mailadresse sich ändert', funct it('weist die elfte Einladung derselben Stunde ab und sagt die Restzeit', function () { Queue::fake(); + mailversandEingerichtet(); $customer = Customer::factory()->create(); $user = $customer->ensureUser(); Instance::factory()->for($customer)->create(['status' => 'active']); @@ -150,6 +175,7 @@ it('weist das vierte Draufdrücken auf denselben Sitz ab', function () { // Die zweite Grenze: gegen das wiederholte Klicken an EINER Zeile, das // demselben Menschen sonst vier Willkommensmails schickt. Queue::fake(); + mailversandEingerichtet(); $customer = Customer::factory()->create(); $user = $customer->ensureUser(); Instance::factory()->for($customer)->create(['status' => 'active']); @@ -344,6 +370,7 @@ it('lädt einen entzogenen Sitz nicht erneut ein', function () { // Derselbe Weg zurück, nur über die andere Tür — und ebenfalls an der // Platzgrenze vorbei. Der richtige Weg ist ein NEUER Sitz. Queue::fake(); + mailversandEingerichtet(); $customer = Customer::factory()->create(); $user = $customer->ensureUser(); Instance::factory()->for($customer)->create(['status' => 'active']); @@ -396,6 +423,7 @@ it('lädt den Inhaber nicht zu seiner eigenen Cloud ein', function () { // oder legte, solange die Bereitstellung noch nicht so weit ist, ein // ZWEITES Konto in der Gruppe `admin` an, neben dem echten. Queue::fake(); + mailversandEingerichtet(); $customer = Customer::factory()->create(); $user = $customer->ensureUser(); Instance::factory()->for($customer)->create(['status' => 'active']); @@ -551,6 +579,7 @@ it('lässt einen wieder Aufgenommenen auch wirklich wieder hinein', function () // die revoke() in den Gast geschickt hat, muss beim Einladen wieder // aufgehen. `user:welcome` allein tut das nicht. Queue::fake(); + mailversandEingerichtet(); $pve = new FakeProxmoxClient; app()->instance(ProxmoxClient::class, $pve); $customer = Customer::factory()->create(); @@ -657,6 +686,7 @@ it('sperrt ein Konto, das erst nach dem Entziehen entsteht', function () { // Ausführen hat sich die Absicht geändert — der Auftrag darf dann nicht // blind ausführen, was beim Absenden galt. Queue::fake(); + mailversandEingerichtet(); $pve = new FakeProxmoxClient; // Der Benutzer existiert im Gast noch nicht: es läuft ein `user:add`, // nicht nur ein `user:welcome` — der beschriebene Ablauf. @@ -842,6 +872,116 @@ it('nimmt dem Inhaber-Sitz seine Rolle auch dann nicht, wenn es einen zweiten gi Queue::assertNothingPushed(); }); +// --- Der Knopf, der eine Mail verspricht, die niemand verschicken kann. + +it('verschickt keine Einladung, solange der Mailversand nicht eingerichtet ist', function () { + // Die Einladung verschickt NEXTCLOUD, nicht CluPilot — nur so entsteht das + // Passwort dort, wo niemand sonst es zu sehen bekommt. Ohne eingerichteten + // Versand kommt aber keine Mail heraus, und `occ user:add + // --generate-password --email` GELINGT trotzdem: Nextcloud legt das Konto + // an, versucht die Willkommensmail, protokolliert intern einen Fehler und + // beendet mit 0. + // + // Die Zeile stünde also auf „Eingeladen", die Meldung verspräche einen + // Link, an dem der Mitarbeiter sein Passwort selbst setzt — und niemand + // bekäme etwas. Dieselbe Attrappe, gegen die dieses ganze Feature gebaut + // wurde, eine Schicht tiefer. + // + // Kein ausgefülltes Postfach in dieser Fixture: das ist der Zustand, in + // dem JEDE Instanz am ersten Tag steht, solange der Mailserver dieses + // Produkts noch nicht existiert. + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create(['role' => 'member']); + + Livewire::actingAs($user)->test(Users::class) + ->call('sendInvite', $sitz->uuid) + // Kein Erfolg gemeldet. Auf den Satz selbst geprüft, nicht auf + // irgendeine Meldung: „Einladung verschickt" ist genau das + // Versprechen, das hier nicht zu halten ist. + ->assertDispatched('notify', fn ($ereignis, $daten) => $daten['message'] !== __('users.invite_sent')); + + // Kein Auftrag, und der Sitz bleibt, wie er war — insbesondere ohne + // Anmeldenamen, denn den vergibt erst das Einladen. + Queue::assertNothingPushed(); + expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_NONE) + ->and($sitz->fresh()->nc_username)->toBeNull(); +}); + +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 + // liest sich nicht als „geht hier gerade nicht", sondern als „das kann + // dieses Produkt nicht" — genau die Beschwerde, die schon einmal zur + // immer sichtbaren Aktionsspalte geführt hat. + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create(['role' => 'member']); + + Livewire::actingAs($user)->test(Users::class) + ->assertSee(__('users.mail_not_ready')) + ->assertSee("sendInvite('{$sitz->uuid}')", escape: false); +}); + +it('laedt wieder ein, sobald der Versand steht', function () { + // Die Gegenrichtung: die Wache darf nicht mehr abweisen, als sie soll. + Queue::fake(); + mailversandEingerichtet(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create(['role' => 'member']); + + Livewire::actingAs($user)->test(Users::class) + ->call('sendInvite', $sitz->uuid) + ->assertDontSee(__('users.mail_not_ready')); + + Queue::assertPushed(SyncSeatToNextcloud::class, fn ($auftrag) => $auftrag->action === 'invite'); +}); + +it('verbraucht fuer eine abgewiesene Einladung kein Kontingent', function () { + // Die Wache steht VOR dem Ratelimit. Sonst hätte ein Inhaber, dessen + // Versand noch nicht steht, seine drei Versuche an dieser Zeile + // verbraucht, bevor überhaupt einer hinausgehen konnte — und müsste + // warten, sobald der Betrieb den Versand einrichtet. + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create(['role' => 'member']); + + $seite = Livewire::actingAs($user)->test(Users::class); + + foreach (range(1, 3) as $ignoriert) { + $seite->call('sendInvite', $sitz->uuid); + } + + mailversandEingerichtet(); + $seite->call('sendInvite', $sitz->uuid); + + Queue::assertPushed(SyncSeatToNextcloud::class, 1); +}); + +it('legt weiterhin Sitze an, auch ohne Versand', function () { + // Anlegen verspricht ausdrücklich KEINE Mail — ein Inhaber soll sein Team + // vorbereiten können, ohne dass jemand eine bekommt. Die Wache gehört + // deshalb an „Einladen" und nirgendwo sonst. + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + + Livewire::actingAs($user)->test(Users::class) + ->set('inviteEmail', 'anna@firma.tld') + ->call('addSeat') + ->assertHasNoErrors(); + + expect($customer->seats()->where('email', 'anna@firma.tld')->exists())->toBeTrue(); +}); + it('nennt in keiner Datei unter app/ das Löschen eines Benutzers', function () { // Testerzwungene Regel: kein Nextcloud-Benutzer wird je gelöscht, und // keine Datei. Wer das ändern will, muss diese Prüfung anfassen und