diff --git a/app/Livewire/Users.php b/app/Livewire/Users.php
index 50f3371..356f887 100644
--- a/app/Livewire/Users.php
+++ b/app/Livewire/Users.php
@@ -64,13 +64,20 @@ class Users extends Component
* behielte sonst fuer immer einen owner-Sitz ohne Verknuepfung. Der
* Aufruf ist folgenlos, solange es kein Konto gibt.
*
- * Die Bedingung haengt an `nc_username`, NICHT an `nc_state`: `nc_state`
- * sagt nur, wie der letzte Versuch ausging. Stuende hier
- * `=== STATE_NONE`, waere ein Sitz nach einem einzigen fehlgeschlagenen
- * Auftrag fuer immer unverknuepft — und ein spaeterer Versuch legte in
- * der Nextcloud ein ZWEITES Konto in der Gruppe `admin` an, neben dem
- * echten. `nc_username` ist die Angabe, die genau einmal gesetzt wird
- * und danach steht.
+ * Die Bedingung haengt an `nc_username`, weil das die Angabe ist, um
+ * die es geht: genau einmal gesetzt, danach unveraenderlich. Sie ist in
+ * jedem ERREICHBAREN Zustand gleichbedeutend mit
+ * `nc_state === STATE_NONE` — sie heilt also NICHTS, was
+ * `=== STATE_NONE` nicht auch heilen wuerde, und insbesondere heilt sie
+ * einen bereits beschaedigten Sitz NICHT: der traegt ja einen
+ * `nc_username`, und dann greift sie nie.
+ *
+ * Beschaedigt heisst hier: Inhaber-Sitz mit gesetztem `nc_username`, der
+ * nicht das Admin-Konto der Instanz ist. Der Weg dorthin ist seit dem
+ * Fix zu (sendInvite() und retry() weisen `owner` ab), und im Bestand
+ * duerfte es keinen geben, weil saemtliche `nc_*`-Spalten aus der
+ * Wanderung von heute stammen. Taeucht doch einer auf, hilft nur ein
+ * Eingriff an der Zeile — nicht diese Bedingung.
*
* Die Wanderung aus Aufgabe 5 tut dasselbe fuer den Bestand — sie fuehrt
* ihre eigene, eingefrorene Fassung. Hier gilt die laufende.
@@ -88,6 +95,14 @@ class Users extends Component
* Schickt ausdruecklich NICHTS los und laesst `nc_state` auf `none`: ein
* Inhaber soll sein Team vorbereiten koennen, ohne dass jemand eine Mail
* bekommt. Erst `sendInvite()` erzeugt einen Benutzer im Gast.
+ *
+ * Und es ist zugleich die Vordertuer zurueck: ein entzogener Sitz derselben
+ * Adresse wird hier WIEDER AUFGENOMMEN, statt an der Eindeutigkeit von
+ * (customer_id, email) abzuprallen. Wer geht und ein Jahr spaeter
+ * wiederkommt, muss wieder Zugang bekommen koennen; verhindert werden soll
+ * die VERSEHENTLICHE Wiederbelebung mit zwei Klicks an der Zeile — und die
+ * ginge an der Platzgrenze vorbei, die nur hier steht. Hier nicht: die
+ * Wiederaufnahme geht durch dieselbe Pruefung wie jeder neue Sitz.
*/
public function addSeat(): void
{
@@ -102,14 +117,35 @@ class Users extends Component
// the last free seat can't both pass (lock the customer row).
$result = DB::transaction(function () use ($customer, $data) {
$locked = Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
+ $vorhanden = $locked->seats()->where('email', $data['inviteEmail'])->first();
- if ($locked->seats()->where('email', $data['inviteEmail'])->exists()) {
+ if ($vorhanden !== null && $vorhanden->status !== 'revoked') {
return 'duplicate';
}
+
+ // Der entzogene Sitz zaehlt heute NICHT gegen die Grenze
+ // (usedSeats() laesst 'revoked' aus). Sobald er wieder auf
+ // 'invited' steht, zaehlt er — die Pruefung gehoert also davor,
+ // genau wie beim Anlegen.
if ($this->usedSeats($locked) >= $this->seatLimit($locked)) {
return 'limit';
}
+ 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([
+ 'name' => $data['inviteName'] ?: $vorhanden->name,
+ 'role' => $data['inviteRole'],
+ 'status' => 'invited',
+ 'invited_at' => now(),
+ ]);
+
+ return 'reinstated';
+ }
+
$locked->seats()->create([
'email' => $data['inviteEmail'],
'name' => $data['inviteName'] ?: null,
@@ -134,7 +170,7 @@ class Users extends Component
$this->reset('inviteEmail', 'inviteName', 'inviteRole');
$this->inviteRole = 'member';
- $this->dispatch('notify', message: __('users.added'));
+ $this->dispatch('notify', message: __($result === 'reinstated' ? 'users.reinstated' : 'users.added'));
}
/**
@@ -163,7 +199,7 @@ class Users extends Component
// beendet hat — und er ginge an der Platzgrenze vorbei, die nur in
// addSeat() steht. Der richtige Weg ist ein NEUER Sitz.
if ($seat->status === 'revoked') {
- $this->dispatch('notify', message: __('users.revoked_is_final'));
+ $this->dispatch('notify', message: __('users.revoked_closed'));
return;
}
@@ -232,6 +268,23 @@ class Users extends Component
return;
}
+ // Dieselbe Wache wie in sendInvite(), und aus demselben Grund: an einem
+ // beschaedigten Inhaber-Sitz (`failed`, nie gespiegelt) waehlte das
+ // match unten `invite` und legte in der Nextcloud ein ZWEITES Konto in
+ // der Gruppe `admin` an, neben dem echten. Gehaertet war bisher nur der
+ // Knopf davor, nicht der Auftragsweg dahinter.
+ if ($seat->role === 'owner') {
+ $this->dispatch('notify', message: __('users.owner_not_invitable'));
+
+ return;
+ }
+
+ // Ein ENTZOGENER Sitz darf hier ausdruecklich durch. Er ist der Fall,
+ // fuer den dieser Knopf am dringendsten gebraucht wird: scheitert das
+ // `disable`, ist das Konto im Gast weiter offen, waehrend die Zeile
+ // „entzogen" sagt. Gefaehrlich waere nur ein Weg zurueck — den gibt es
+ // nicht, das match unten bildet 'revoked' ausnahmslos auf `disable` ab.
+ //
// Welcher Auftrag der richtige ist, steht am Sitz selbst: was der
// Inhaber WILL (`status`) und ob dieser Sitz je in der Nextcloud
// ankam (`nc_synced_at`). Den letzten Auftrag mitzuschreiben waere ein
@@ -369,7 +422,7 @@ class Users extends Component
// anmelden, und er zaehlte wieder gegen die Platzgrenze, ohne dass sie
// hier jemals geprueft wird.
if ($seat->status === 'revoked') {
- $this->dispatch('notify', message: __('users.revoked_is_final'));
+ $this->dispatch('notify', message: __('users.revoked_closed'));
return;
}
diff --git a/app/Provisioning/Jobs/SyncSeatToNextcloud.php b/app/Provisioning/Jobs/SyncSeatToNextcloud.php
index c718f4f..1458447 100644
--- a/app/Provisioning/Jobs/SyncSeatToNextcloud.php
+++ b/app/Provisioning/Jobs/SyncSeatToNextcloud.php
@@ -101,12 +101,25 @@ class SyncSeatToNextcloud implements ShouldQueue
*/
private function invite(NextcloudUsers $users, Instance $instance, Seat $seat): bool
{
+ // VOR dem Vermerk gelesen: war dieser Sitz schon einmal in der
+ // Nextcloud, ist dies eine Wiederaufnahme — und sein Konto dort steht
+ // gesperrt, weil `revoke()` ein `disable` geschickt hat. `user:welcome`
+ // macht eine Sperre nicht rueckgaengig; ohne diese Zeile bekaeme der
+ // Wiederaufgenommene eine Willkommensmail zu einer Tuer, die zu bleibt.
+ // Bei einem frisch angelegten Konto entfaellt der Befehl ganz, statt
+ // sich auf ein folgenloses `user:enable` zu verlassen.
+ $wiederaufnahme = $seat->nc_synced_at !== null;
+
if (! $users->invite($instance, $seat)) {
return false;
}
$seat->forceFill(['nc_synced_at' => now()])->save();
+ if ($wiederaufnahme && ! $users->enable($instance, $seat)) {
+ return false;
+ }
+
return $users->applyRole($instance, $seat);
}
diff --git a/lang/de/users.php b/lang/de/users.php
index 284c069..6db42c1 100644
--- a/lang/de/users.php
+++ b/lang/de/users.php
@@ -78,14 +78,17 @@ return [
'error_guest_failed' => 'Die Cloud hat die Änderung nicht angenommen.',
'error_unexpected' => 'Unerwarteter Fehler. Bitte noch einmal versuchen.',
- // Entzogen ist endgültig. Vorher löschte revoke() die Zeile, und die Frage
- // stellte sich nie; heute bleibt sie stehen, und ohne diese beiden Sätze
- // sähe sie aus wie ein Zugang, den man wieder aufmachen kann.
- 'revoked_final' => 'Endgültig entzogen',
- 'revoked_is_final' => 'Dieser Zugang wurde entzogen und kann nicht wieder geöffnet werden. Legen Sie bei Bedarf einen neuen an.',
+ // An der ZEILE gibt es keinen Weg zurück — das war die versehentliche
+ // Wiederbelebung mit zwei Klicks, vorbei an der Platzgrenze. Über das
+ // Formular oben schon: dieselbe Adresse, dieselbe Prüfung wie jeder neue
+ // Platz. „Endgültig" wäre deshalb gelogen, und ein Satz, der eine Sackgasse
+ // behauptet, wo eine Tür ist, kostet mehr als eine offene Lücke.
+ 'revoked_final' => 'Zugang entzogen',
+ 'revoked_closed' => 'Dieser Zugang ist geschlossen. Über das Formular oben können Sie dieselbe Adresse wieder aufnehmen — sie zählt dann wieder gegen Ihre Plätze.',
'owner_not_invitable' => 'Der Zugang des Inhabers wird beim Aufbau der Cloud angelegt und kann hier nicht eingeladen werden.',
'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.',
'too_many_invites' => 'Zu viele Einladungen — in :minutes Minuten wieder möglich.',
'revoked' => 'Benutzer entfernt.',
diff --git a/lang/en/users.php b/lang/en/users.php
index 547c832..8180320 100644
--- a/lang/en/users.php
+++ b/lang/en/users.php
@@ -77,14 +77,17 @@ return [
'error_guest_failed' => 'The cloud did not accept the change.',
'error_unexpected' => 'Unexpected error. Please try again.',
- // Revoked is final. revoke() used to delete the row and the question never
- // came up; today it stays, and without these two sentences it would look
- // like access somebody can open again.
- 'revoked_final' => 'Revoked for good',
- 'revoked_is_final' => 'This access was revoked and cannot be reopened. Add a new seat if you need one.',
+ // There is no way back from the ROW — that was the accidental revival in
+ // two clicks, bypassing the seat limit. Through the form above there is:
+ // same address, same check as any new seat. "Final" would therefore be a
+ // lie, and a sentence claiming a dead end where there is a door costs more
+ // than an open gap.
+ 'revoked_final' => 'Access revoked',
+ 'revoked_closed' => 'This access is closed. Use the form above to take the same address back on — it then counts against your seats again.',
'owner_not_invitable' => 'The owner\'s account is created when the cloud is built and cannot be invited from here.',
'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.',
'too_many_invites' => 'Too many invitations — possible again in :minutes minutes.',
'revoked' => 'User removed.',
diff --git a/resources/views/livewire/users.blade.php b/resources/views/livewire/users.blade.php
index 2bd9efb..0b5fad6 100644
--- a/resources/views/livewire/users.blade.php
+++ b/resources/views/livewire/users.blade.php
@@ -81,45 +81,62 @@
sichtbar geschieht. --}}
@if ($seat->status === 'revoked')
- {{-- Entzogen geht allem anderen vor und ist
- endgueltig. Ein „wird eingerichtet …"
- oder ein „Nochmal versuchen" an dieser
- Zeile las sich wie ein Weg zurueck —
- den es nicht gibt und nicht geben
- soll. --}}
+ {{-- Entzogen geht dem Zustand vor: ein
+ „wird eingerichtet …" an einer
+ geschlossenen Zeile las sich wie ein
+ Weg zurueck, den es an DIESER Zeile
+ nicht gibt. --}}
{{ __('users.status_revoked') }}
-
{{ __('users.revoked_is_final') }}
+
{{ __('users.revoked_closed') }}
@elseif ($seat->nc_state === \App\Models\Seat::STATE_NONE)
{{ __('users.state_none') }}
@elseif ($seat->nc_state === \App\Models\Seat::STATE_PENDING)
{{ __('users.state_pending') }}
- @elseif ($seat->nc_state === \App\Models\Seat::STATE_FAILED)
- {{ __('users.state_failed') }}
- {{-- Der Grund im Klartext, nicht der Code:
- 'guest_failed' sagt einem Inhaber
- nichts, und ein Fehlschlag ohne Grund
- ist eine Sackgasse. --}}
-
-
- @else
- @php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp
+ @elseif ($seat->nc_state !== \App\Models\Seat::STATE_FAILED)
+ @php $sb = ['active' => 'active', 'invited' => 'provisioning'][$seat->status] ?? 'info'; @endphp
{{ __('users.status_'.$seat->status) }}
@endif
+
+ {{-- Der Fehlschlag steht NEBEN dem Entzogen,
+ nicht dahinter. Sonst verschwaende genau
+ dort die Rueckfahrkarte, wo sie am
+ noetigsten ist: scheitert das `disable`,
+ ist das Konto im Gast weiter offen,
+ waehrend 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. --}}
+ @if ($seat->nc_state === \App\Models\Seat::STATE_FAILED)
+
$seat->status === 'revoked'])>
+ {{ __('users.state_failed') }}
+ {{-- Der Grund im Klartext, nicht der
+ Code: 'guest_failed' sagt einem
+ Inhaber nichts, und ein Fehlschlag
+ ohne Grund ist eine Sackgasse. --}}
+
@if ($seat->status === 'revoked')
- {{-- Kein Knopf. Kein Wiedereinsetzen: der
- richtige Weg zurueck ist ein NEUER
- Sitz, und der geht durch die
- Platzpruefung. Gesagt statt leer
+ {{-- Kein Knopf an der Zeile. Der Weg
+ zurueck geht durch die Vordertuer: das
+ Anlegen-Formular oben, mit derselben
+ Adresse — und damit durch die
+ Platzpruefung, die es an der Zeile
+ nicht gaebe. Gesagt statt leer
gelassen, wie bei der Inhaber-Zeile —
eine leere Zelle liesse jemanden nach
einem Knopf suchen, der absichtlich
fehlt. --}}
- {{ __('users.revoked_final') }}
+ {{ __('users.revoked_final') }}
@else
{{-- Einladen ist der zweite Vorgang und
hat deshalb seinen eigenen Knopf. Er
diff --git a/tests/Feature/Seats/SyncSeatToNextcloudTest.php b/tests/Feature/Seats/SyncSeatToNextcloudTest.php
index cb7987c..b57ebae 100644
--- a/tests/Feature/Seats/SyncSeatToNextcloudTest.php
+++ b/tests/Feature/Seats/SyncSeatToNextcloudTest.php
@@ -229,6 +229,10 @@ it('vermerkt ein angelegtes Konto, auch wenn die Rolle danach scheitert', functi
// liesse ein spaeteres Entziehen die Sperre weg, weil die Seite den Sitz
// fuer nie angekommen hielte. Der Zustand bleibt trotzdem 'failed'.
$pve = new FakeProxmoxClient;
+ // user:info auf 1, sonst haelt invite() den Benutzer fuer vorhanden und
+ // schickt nur `user:welcome` — dann liefe hier gar kein `user:add`, und der
+ // Ablauf waere ein anderer als der beschriebene.
+ $pve->guestScript('user:info', 1);
$pve->guestScript('group:adduser', 1);
app()->instance(ProxmoxClient::class, $pve);
$customer = Customer::factory()->create();
@@ -237,10 +241,47 @@ it('vermerkt ein angelegtes Konto, auch wenn die Rolle danach scheitert', functi
(new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(NextcloudUsers::class));
- expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_FAILED)
+ expect($pve->guestRan('user:add'))->toBeTrue()
+ ->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_FAILED)
->and($sitz->fresh()->nc_synced_at)->not->toBeNull();
});
+it('macht die Sperre beim Wiederaufnehmen rueckgaengig', function () {
+ // Ein wiederaufgenommener Sitz war schon einmal in der Nextcloud, und
+ // revoke() hat ihm dort ein `user:disable` geschickt. `user:welcome` hebt
+ // eine Sperre nicht auf — ohne `user:enable` bekaeme er eine
+ // Willkommensmail zu einer Tuer, die zu bleibt.
+ $pve = new FakeProxmoxClient;
+ app()->instance(ProxmoxClient::class, $pve);
+ $customer = Customer::factory()->create();
+ Instance::factory()->for($customer)->create(['status' => 'active', 'vmid' => 201, 'host_id' => Host::factory()]);
+ $sitz = Seat::factory()->for($customer)->create([
+ 'nc_username' => 'anna@firma.tld', 'nc_state' => Seat::STATE_PENDING, 'nc_synced_at' => now()->subYear(),
+ ]);
+
+ (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('schickt bei einer ersten Einladung kein Entsperren mit', function () {
+ // Ein frisch angelegtes Konto ist nicht gesperrt. Ein `user:enable` waere
+ // hier folgenlos — und ein folgenloser Befehl im Gast ist einer, den
+ // niemand mehr erklaeren kann, wenn er eines Tages doch etwas tut.
+ $pve = new FakeProxmoxClient;
+ $pve->guestScript('user:info', 1);
+ app()->instance(ProxmoxClient::class, $pve);
+ $customer = Customer::factory()->create();
+ Instance::factory()->for($customer)->create(['status' => 'active', 'vmid' => 201, 'host_id' => Host::factory()]);
+ $sitz = Seat::factory()->for($customer)->create(['nc_username' => 'anna@firma.tld']);
+
+ (new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(NextcloudUsers::class));
+
+ expect($pve->guestRan('user:enable'))->toBeFalse()
+ ->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED);
+});
+
it('spiegelt eine Rollenaenderung in die Nextcloud', function () {
Queue::fake();
$customer = Customer::factory()->create();
@@ -345,7 +386,7 @@ it('sagt an einer entzogenen Zeile „entzogen", nicht „noch nicht eingeladen"
Livewire::actingAs($user)->test(Users::class)
->assertSee(__('users.status_revoked'))
- ->assertSee(__('users.revoked_is_final'))
+ ->assertSee(__('users.revoked_closed'))
->assertDontSee(__('users.state_none'));
});
@@ -368,16 +409,20 @@ it('laedt den Inhaber nicht zu seiner eigenen Cloud ein', function () {
});
it('verknuepft den Inhaber-Sitz beim Besuch, auch wenn die Instanz spaeter dazukommt', function () {
- // Wer /users oeffnet, bevor die Bereitstellung durch ist, hatte danach
- // einen owner-Sitz ohne Verknuepfung. Haenge die Bedingung an nc_state,
- // reicht EIN fehlgeschlagener Auftrag, damit sie nie wieder greift — der
- // Sitz bliebe fuer immer unverknuepft. Deshalb steht hier ein Sitz auf
- // 'failed' und wird trotzdem geheilt.
+ // /users verlangt keine fertige Instanz. Wer die Seite oeffnet, bevor die
+ // Bereitstellung das Admin-Konto angelegt hat, bekommt einen owner-Sitz
+ // ohne Verknuepfung — und ohne diese Zeilen in mount() bliebe er es fuer
+ // immer, waehrend das Panel dem Inhaber anboete, sich SELBST einzuladen.
+ // Der Aufruf muss deshalb bei JEDEM Besuch stattfinden, nicht nur beim
+ // ersten.
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
- $sitz = Seat::factory()->for($customer)->owner()->create([
- 'nc_state' => Seat::STATE_FAILED, 'nc_error' => 'no_instance',
- ]);
+
+ // Erster Besuch, noch ohne Instanz: mount() legt den owner-Sitz an, findet
+ // aber kein Konto zum Verknuepfen.
+ Livewire::actingAs($user)->test(Users::class)->assertOk();
+ $sitz = $customer->seats()->where('role', 'owner')->firstOrFail();
+ expect($sitz->nc_username)->toBeNull();
Instance::factory()->for($customer)->create(['status' => 'active', 'nc_admin_ref' => 'admin']);
@@ -387,6 +432,134 @@ it('verknuepft den Inhaber-Sitz beim Besuch, auch wenn die Instanz spaeter dazuk
->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED);
});
+it('laesst den Wiederholen-Knopf an einer entzogenen Zeile stehen, wenn das Sperren scheiterte', function () {
+ // Der gefaehrlichste Halbschritt auf der anderen Seite: der Inhaber
+ // entzieht, der `disable`-Auftrag scheitert (Gast aus, Tunnel weg), und das
+ // Konto im Gast ist WEITER OFFEN, waehrend die Zeile „entzogen" sagt. Es
+ // gibt keinen Wiederholungslauf (tries = 1), keinen Abgleich im Zeitplan,
+ // und diese Seite ist die einzige Stelle im Repo, die nc_state anzeigt —
+ // ohne diesen Knopf faellt es nie wieder jemandem auf.
+ $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' => 'guest_failed', 'nc_synced_at' => now(),
+ ]);
+
+ Livewire::actingAs($user)->test(Users::class)
+ ->assertSee(__('users.state_failed'))
+ ->assertSee(__('users.error_guest_failed'))
+ ->assertSee("retry('{$sitz->uuid}')", escape: false)
+ // Und die Zeile sagt trotzdem, dass der Zugang geschlossen ist.
+ ->assertSee(__('users.status_revoked'));
+});
+
+it('wiederholt an einem entzogenen Sitz das Sperren, nie das Einladen', function () {
+ 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' => 'guest_failed', 'nc_synced_at' => now(),
+ ]);
+
+ Livewire::actingAs($user)->test(Users::class)->call('retry', $sitz->uuid);
+
+ Queue::assertPushed(SyncSeatToNextcloud::class, fn ($auftrag) => $auftrag->action === 'disable');
+ expect($sitz->fresh()->status)->toBe('revoked');
+});
+
+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
+ // `invite` und legte damit genau das an, was die Wache in sendInvite()
+ // verhindern soll — ein ZWEITES Konto in der Gruppe `admin`.
+ Queue::fake();
+ $customer = Customer::factory()->create();
+ $user = $customer->ensureUser();
+ Instance::factory()->for($customer)->create(['status' => 'active']);
+ $sitz = Seat::factory()->for($customer)->owner()->create([
+ 'nc_state' => Seat::STATE_FAILED, 'nc_error' => 'guest_failed',
+ ]);
+
+ Livewire::actingAs($user)->test(Users::class)->call('retry', $sitz->uuid);
+
+ Queue::assertNothingPushed();
+ expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_FAILED);
+});
+
+it('nimmt einen entzogenen Sitz ueber das Anlegen-Formular wieder auf', function () {
+ // Die empfohlene Vordertuer muss offen sein. Vor dem Fix prallte dieselbe
+ // Adresse an der Eindeutigkeit von (customer_id, email) ab — die Meldung
+ // empfahl damit einen Weg, den es nicht gab.
+ Queue::fake();
+ $customer = Customer::factory()->create();
+ $user = $customer->ensureUser();
+ Instance::factory()->for($customer)->create(['status' => 'active']);
+ $sitz = Seat::factory()->for($customer)->create([
+ 'email' => 'anna@firma.tld', 'role' => 'member', 'status' => 'revoked',
+ 'nc_username' => 'anna@firma.tld', 'nc_state' => Seat::STATE_SYNCED, 'nc_synced_at' => now(),
+ ]);
+
+ Livewire::actingAs($user)->test(Users::class)
+ ->set('inviteEmail', 'anna@firma.tld')
+ ->set('inviteRole', 'readonly')
+ ->call('addSeat')
+ ->assertHasNoErrors();
+
+ // Dieselbe Zeile, kein Duplikat — und der Anmeldename bleibt, wie er ist:
+ // Nextcloud kann Benutzer nicht umbenennen, und es ist dieselbe Person.
+ expect($customer->seats()->where('email', 'anna@firma.tld')->count())->toBe(1)
+ ->and($sitz->fresh()->status)->toBe('invited')
+ ->and($sitz->fresh()->role)->toBe('readonly')
+ ->and($sitz->fresh()->nc_username)->toBe('anna@firma.tld');
+
+ // Angelegt ist nicht eingeladen — auch beim Wiederaufnehmen nicht.
+ Queue::assertNothingPushed();
+});
+
+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
+ // wiederaufgenommener schon.
+ $customer = Customer::factory()->create();
+ $user = $customer->ensureUser();
+ Instance::factory()->for($customer)->create(['status' => 'active']);
+ Seat::factory()->for($customer)->count(5)->create(['role' => 'member']);
+ $sitz = Seat::factory()->for($customer)->create([
+ 'email' => 'anna@firma.tld', 'role' => 'member', 'status' => 'revoked',
+ ]);
+
+ Livewire::actingAs($user)->test(Users::class)
+ ->set('inviteEmail', 'anna@firma.tld')
+ ->call('addSeat')
+ ->assertHasErrors(['inviteEmail']);
+
+ expect($sitz->fresh()->status)->toBe('revoked');
+});
+
+it('weist eine Adresse ab, die noch einen lebenden Platz hat', function () {
+ // Die Wiederaufnahme gilt NUR fuer entzogene Zeilen. Ein aktiver Sitz
+ // derselben Adresse bleibt ein Duplikat — sonst waere das Formular ein
+ // stiller Rollenwechsel an einer bestehenden Zeile.
+ $customer = Customer::factory()->create();
+ $user = $customer->ensureUser();
+ Instance::factory()->for($customer)->create(['status' => 'active']);
+ $sitz = Seat::factory()->for($customer)->create([
+ 'email' => 'anna@firma.tld', 'role' => 'member', 'status' => 'active',
+ ]);
+
+ Livewire::actingAs($user)->test(Users::class)
+ ->set('inviteEmail', 'anna@firma.tld')
+ ->set('inviteRole', 'admin')
+ ->call('addSeat')
+ ->assertHasErrors(['inviteEmail']);
+
+ expect($sitz->fresh()->role)->toBe('member');
+});
+
it('nennt in keiner Datei unter app/ das Loeschen eines Benutzers', function () {
// Testerzwungene Regel: kein Nextcloud-Benutzer wird je geloescht, und
// keine Datei. Wer das aendern will, muss diese Pruefung anfassen und