diff --git a/app/Livewire/Users.php b/app/Livewire/Users.php index 356f887..2ddc456 100644 --- a/app/Livewire/Users.php +++ b/app/Livewire/Users.php @@ -132,16 +132,27 @@ class Users extends Component } if ($vorhanden !== null) { - // `nc_username` bleibt, wie er ist: Nextcloud kann Benutzer - // nicht umbenennen, und es ist dieselbe Person. `nc_state` - // ebenfalls — was im Gast steht, aendert sich durch das - // Anlegen nie, das ist der ganze Sinn der Trennung. - $vorhanden->update([ + // `nc_username` und `nc_synced_at` bleiben, wie sie sind: der + // Anmeldename gehoert weiter dieser Person (Nextcloud kann + // nicht umbenennen), und dass das Konto im Gast existiert, + // bleibt wahr — daran haengt spaeter das Entsperren. + // + // `nc_state` dagegen MUSS zurueck auf `none`: der Sitz stand + // auf `synced`, und `status = 'invited'` daneben liesse die + // Zeile „Eingeladen" zeichnen — fuer jemanden, den niemand + // eingeladen hat und dessen Konto im Gast weiter gesperrt ist. + // Dieselbe Luege wie eine Zeile, die einen Fehlschlag + // verschweigt, nur in die andere Richtung. Mit `none` heisst + // sie „angelegt — noch nicht eingeladen", der Knopf heisst + // „Einladen", und beides stimmt. + $vorhanden->forceFill([ 'name' => $data['inviteName'] ?: $vorhanden->name, 'role' => $data['inviteRole'], 'status' => 'invited', 'invited_at' => now(), - ]); + 'nc_state' => Seat::STATE_NONE, + 'nc_error' => null, + ])->save(); return 'reinstated'; } @@ -295,6 +306,21 @@ class Users extends Component default => 'role', }; + // 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 + // Zeile im Kreis — anlegen, einladen, Auftrag scheitert an + // `no_instance`, entziehen (queueSync bricht korrekt ab, `failed` + // bleibt stehen), und jeder Druck schickte ein `user:disable` gegen + // einen Benutzer, den es nie gab. Genau der Dauerfehler, den + // queueSync() schon abgestellt hat, nur ueber die andere Tuer. + // + // `invite` ist ausgenommen: das ist der Fall, FUER den dieser Knopf + // gebaut ist — dort soll das Konto ja erst entstehen. + if ($action !== 'invite' && $seat->nc_synced_at === null) { + return; + } + $seat->forceFill(['nc_state' => Seat::STATE_PENDING, 'nc_error' => null])->save(); SyncSeatToNextcloud::dispatch($seat->uuid, $action); diff --git a/resources/views/livewire/users.blade.php b/resources/views/livewire/users.blade.php index 0b5fad6..dfddfc8 100644 --- a/resources/views/livewire/users.blade.php +++ b/resources/views/livewire/users.blade.php @@ -115,7 +115,17 @@ Inhaber nichts, und ein Fehlschlag ohne Grund ist eine Sackgasse. --}}

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

- @if ($seat->role !== 'owner') + @php + // Dieselbe Bedingung wie in retry(): ein Knopf, der + // nur wieder rot zurueckkommt, 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. + $wiederholbar = $seat->role !== 'owner' + && ($seat->nc_synced_at !== null + || ! in_array($seat->status, ['revoked', 'suspended'], true)); + @endphp + @if ($wiederholbar) diff --git a/tests/Feature/Seats/SyncSeatToNextcloudTest.php b/tests/Feature/Seats/SyncSeatToNextcloudTest.php index b57ebae..2b37dbf 100644 --- a/tests/Feature/Seats/SyncSeatToNextcloudTest.php +++ b/tests/Feature/Seats/SyncSeatToNextcloudTest.php @@ -471,6 +471,30 @@ it('wiederholt an einem entzogenen Sitz das Sperren, nie das Einladen', function expect($sitz->fresh()->status)->toBe('revoked'); }); +it('wiederholt nichts an einer entzogenen Zeile, die nie in der Nextcloud war', function () { + // Anlegen → einladen → Auftrag scheitert an `no_instance` → entziehen. + // queueSync() bricht korrekt ab, `failed` bleibt stehen, und der + // Fehlschlag-Block ist zu Recht da. Ohne Vorbehalt liefe ein Druck aber auf + // `user:disable` gegen einen Benutzer, den es nie gab: scheitert, dieselbe + // rote Zeile, beliebig oft. Genau der Dauerfehler, nur ueber die andere + // Tuer — und mit einem veralteten Grund aus einem anderen Vorgang. + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create([ + 'role' => 'member', 'status' => 'revoked', 'nc_username' => 'anna@firma.tld', + 'nc_state' => Seat::STATE_FAILED, 'nc_error' => 'no_instance', 'nc_synced_at' => null, + ]); + + Livewire::actingAs($user)->test(Users::class) + ->call('retry', $sitz->uuid) + // Und der Knopf steht dort gar nicht erst. + ->assertDontSee("retry('{$sitz->uuid}')", escape: false); + + Queue::assertNothingPushed(); +}); + it('wiederholt nichts an einem Inhaber-Sitz', function () { // retry() war der ungehaertete Zwilling von sendInvite(): an einem // beschaedigten Inhaber-Sitz (failed, nie gespiegelt) waehlte es die Aktion @@ -520,6 +544,48 @@ it('nimmt einen entzogenen Sitz ueber das Anlegen-Formular wieder auf', function Queue::assertNothingPushed(); }); +it('laesst einen wieder Aufgenommenen auch wirklich wieder hinein', function () { + // Der ganze Weg in einer Pruefung: entziehen, wieder aufnehmen, einladen. + // Zwei Dinge muessen dabei zugleich stimmen — die Zeile darf nicht + // „Eingeladen" behaupten, solange niemand eingeladen hat, und die Sperre, + // die revoke() in den Gast geschickt hat, muss beim Einladen wieder + // aufgehen. `user:welcome` allein tut das nicht. + Queue::fake(); + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active', 'vmid' => 201, 'host_id' => Host::factory()]); + $sitz = Seat::factory()->for($customer)->create([ + 'email' => 'anna@firma.tld', 'role' => 'member', 'status' => 'active', + 'nc_username' => 'anna@firma.tld', 'nc_state' => Seat::STATE_SYNCED, 'nc_synced_at' => now()->subYear(), + ]); + + $seite = Livewire::actingAs($user)->test(Users::class); + $seite->call('revoke', $sitz->uuid); + $seite->set('inviteEmail', 'anna@firma.tld')->set('inviteRole', 'member') + ->call('addSeat')->assertHasNoErrors(); + + expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_NONE) + ->and($sitz->fresh()->nc_error)->toBeNull() + // Anmeldename und Ankunftszeitpunkt bleiben: der Name gehoert dieser + // Person, und dass es das Konto im Gast gibt, bleibt wahr. + ->and($sitz->fresh()->nc_username)->toBe('anna@firma.tld') + ->and($sitz->fresh()->nc_synced_at)->not->toBeNull(); + + // Und die Zeile sagt es auch: „angelegt — noch nicht eingeladen", mit dem + // Knopf „Einladen" statt „Erneut senden". + $seite->assertSee(__('users.state_none')) + ->assertSee(__('users.invite')) + ->assertDontSee(__('users.status_invited')); + + $seite->call('sendInvite', $sitz->uuid); + (new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(NextcloudUsers::class)); + + expect($pve->guestRan('user:enable'))->toBeTrue() + ->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED); +}); + it('weist die Wiederaufnahme ab, wenn kein Platz mehr frei ist', function () { // Der Grund, warum es die Vordertuer sein muss und kein Knopf an der Zeile: // hier steht die Platzpruefung. Ein entzogener Sitz zaehlt nicht mit, ein