customer(); if ($customer === null) { return; } if ($customer->seats()->count() === 0) { try { $customer->seats()->firstOrCreate( ['email' => $customer->email], ['name' => $customer->name, 'role' => 'owner', 'status' => 'active', 'invited_at' => now()], ); } catch (UniqueConstraintViolationException) { // Another concurrent first visit created it — fine. } } /* * Der Inhaber-Sitz wird mit dem bestehenden Admin-Konto verknuepft. * * Dieses Konto gibt es in der Nextcloud laengst — CreateCustomerAdmin * hat es beim Aufbau angelegt. Ohne diese Verknuepfung stuende der Sitz * auf `none`, und das Panel boete dem Inhaber an, sich SELBST * einzuladen; der Auftrag traefe dann auf einen Benutzer, den es schon * gibt. * * Bei JEDEM Besuch versucht, nicht nur beim allerersten: wer das Panel * oeffnet, bevor die Bereitstellung das Admin-Konto angelegt hat, * 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 Wanderung aus Aufgabe 5 tut dasselbe fuer den Bestand — sie fuehrt * ihre eigene, eingefrorene Fassung. Hier gilt die laufende. */ $owner = $customer->seats()->where('role', 'owner')->first(); if ($owner !== null && blank($owner->nc_username)) { $owner->linkToInstanceAdmin(); } } /** * Anlegen — der erste der beiden Vorgaenge. * * 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. */ public function addSeat(): void { $customer = $this->requireCustomer(); if ($customer === null) { return; } $data = $this->validate(); // Serialize the limit check with the insert so two concurrent invites for // 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(); if ($locked->seats()->where('email', $data['inviteEmail'])->exists()) { return 'duplicate'; } if ($this->usedSeats($locked) >= $this->seatLimit($locked)) { return 'limit'; } $locked->seats()->create([ 'email' => $data['inviteEmail'], 'name' => $data['inviteName'] ?: null, 'role' => $data['inviteRole'], 'status' => 'invited', 'invited_at' => now(), ]); return 'ok'; }); if ($result === 'limit') { $this->addError('inviteEmail', __('users.limit_reached')); return; } if ($result === 'duplicate') { $this->addError('inviteEmail', __('users.duplicate')); return; } $this->reset('inviteEmail', 'inviteName', 'inviteRole'); $this->inviteRole = 'member'; $this->dispatch('notify', message: __('users.added')); } /** * Einladen — der zweite, getrennte Schritt. * * Anlegen und Einladen sind ausdruecklich zwei Vorgaenge: ein Inhaber * soll sein Team vorbereiten und die Einladungen spaeter verschicken * koennen, etwa alle am ersten Arbeitstag. */ public function sendInvite(string $uuid): void { $customer = $this->requireCustomer(); if ($customer === null) { return; } $seat = $customer->seats()->where('uuid', $uuid)->first(); if ($seat === null) { return; } // Entzogen bleibt entzogen. Ohne diese Zeile waere „Einladen" an einer // entzogenen Zeile der Weg zurueck in einen Zugang, den der Inhaber // 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')); return; } // Den Inhaber laedt niemand ein. Sein Konto legt die Bereitstellung an // (CreateCustomerAdmin); ein Auftrag von hier aus wuerde entweder auf // einen bestehenden Benutzer treffen oder — wenn die Bereitstellung // noch nicht so weit ist — spaeter ein ZWEITES Konto in der Gruppe // `admin` anlegen, neben dem echten. if ($seat->role === 'owner') { $this->dispatch('notify', message: __('users.owner_not_invitable')); return; } if (($warten = $this->rateLimited($customer, $seat)) !== null) { $this->dispatch('notify', message: __('users.too_many_invites', ['minutes' => $warten])); return; } // Einmal gesetzt, nie wieder geaendert: Nextcloud kann Benutzer nicht // umbenennen. Ein Sitz, dessen Adresse sich spaeter aendert, behaelt // seinen Anmeldenamen. if (blank($seat->nc_username)) { $seat->nc_username = $seat->email; } $seat->forceFill([ 'nc_username' => $seat->nc_username, 'status' => 'invited', 'invited_at' => now(), 'nc_state' => Seat::STATE_PENDING, 'nc_error' => null, ])->save(); SyncSeatToNextcloud::dispatch($seat->uuid, 'invite'); $this->dispatch('notify', message: __('users.invite_sent')); } /** * Der zweite Versuch nach einem Fehlschlag. * * Ohne ihn bliebe dem Inhaber bei einem Gast, der einmal nicht erreichbar * war, nur die Zeile zu loeschen und neu anzulegen — also genau der * Datenverlust, den `revoke()` gerade abgeschafft hat. * * Kein Ratelimit: der Knopf erscheint nur an einem fehlgeschlagenen Sitz * und setzt ihn sofort auf `pending`, wo er keinen Knopf mehr hat. Er kann * also gar nicht schneller gedrueckt werden, als die Warteschlange * antwortet — und ihn zu drosseln hiesse, die Rueckfahrkarte aus einem * Fehlschlag zu drosseln. */ public function retry(string $uuid): void { $customer = $this->requireCustomer(); if ($customer === null) { return; } $seat = $customer->seats()->where('uuid', $uuid)->first(); if ($seat === null || $seat->nc_state !== Seat::STATE_FAILED) { return; } // 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 // Feld, das nach dem ersten Erfolg nie wieder stimmt. $action = match (true) { in_array($seat->status, ['revoked', 'suspended'], true) => 'disable', $seat->nc_synced_at === null => 'invite', default => 'role', }; $seat->forceFill(['nc_state' => Seat::STATE_PENDING, 'nc_error' => null])->save(); SyncSeatToNextcloud::dispatch($seat->uuid, $action); $this->dispatch('notify', message: __('users.retrying')); } /** * Zwei Grenzen, beide aus dem Betrieb heraus gefordert: eine je Kunde * gegen den Rundumschlag, eine je Sitz gegen das wiederholte Draufdruecken * an derselben Zeile. * * Gibt die Restzeit in Minuten zurueck, oder null wenn frei. Eine stumme * Verweigerung waere schlimmer als die Grenze selbst. */ private function rateLimited(Customer $customer, Seat $seat): ?int { foreach ([ ['seat-invite:customer:'.$customer->id, 10], ['seat-invite:seat:'.$seat->id, 3], ] as [$schluessel, $grenze]) { if (RateLimiter::tooManyAttempts($schluessel, $grenze)) { return (int) ceil(RateLimiter::availableIn($schluessel) / 60); } } RateLimiter::increment('seat-invite:customer:'.$customer->id, 3600); RateLimiter::increment('seat-invite:seat:'.$seat->id, 3600); return null; } /** * Ein Sitz, der noch nie erfolgreich in der Nextcloud angekommen ist, * bekommt keinen Auftrag — dort gaebe es nichts zu aendern. * * Massgeblich ist `nc_synced_at`, nicht `nc_state`: `nc_state` sagt nur, * wie der LETZTE Versuch ausging. Eine Einladung, die an einem nicht * erreichbaren Gast gescheitert ist, hinterlaesst einen Sitz mit * Anmeldenamen und `failed` — in der Nextcloud aber nichts. Ein * `user:disable` darauf muss scheitern und liesse die Zeile dauerhaft rot * stehen, mit einem Knopf, der nur weiter scheitert: eine Fehlermeldung * fuer etwas, das nie ein Fehler war. * * Das traegt nur, weil der Auftrag ein angelegtes Konto SOFORT vermerkt, * auch wenn die Gruppe danach scheitert — siehe SyncSeatToNextcloud. Sonst * bliebe genau der gefaehrliche Fall offen: Konto da, Sperre nie geschickt. */ private function queueSync(Seat $seat, string $action): void { if ($seat->nc_synced_at === null) { return; } $seat->forceFill(['nc_state' => Seat::STATE_PENDING, 'nc_error' => null])->save(); SyncSeatToNextcloud::dispatch($seat->uuid, $action); } public function setRole(string $uuid, string $role): void { if (! in_array($role, Seat::ROLES, true)) { return; } $customer = $this->requireCustomer(); if ($customer === null) { return; } // Lock the customer so a concurrent owner change can't race past the guard. $seat = DB::transaction(function () use ($customer, $uuid, $role) { Customer::query()->whereKey($customer->id)->lockForUpdate()->first(); $seat = $customer->seats()->where('uuid', $uuid)->first(); if ($seat === null) { return null; } if ($seat->role === 'owner' && $role !== 'owner' && $customer->seats()->where('role', 'owner')->count() <= 1) { return false; // would remove the last owner } $seat->update(['role' => $role]); return $seat; }); if ($seat === false) { $this->dispatch('notify', message: __('users.last_owner')); return; } // Erst nach dem Commit: ein Auftrag, den die Warteschlange schneller // aufnimmt als die Transaktion schliesst, liest die alte Rolle. if ($seat instanceof Seat) { $this->queueSync($seat, 'role'); } } /** * Pause a seat without destroying it. * * The action an owner actually needs when someone leaves: access stops now, * and the record of who held it survives — which is the half a deletion * throws away, on a product sold on being able to show who had access to * what. */ public function suspend(string $uuid): void { $customer = $this->requireCustomer(); if ($customer === null) { return; } $seat = $customer->seats()->where('uuid', $uuid)->first(); if ($seat === null || $seat->role === 'owner') { // The owner cannot lock themselves out of their own cloud. $this->dispatch('notify', message: __('users.owner_locked')); return; } // Entzogen bleibt entzogen. Der Umschalter unten kennt nur zwei // Zustaende: an einem entzogenen Sitz machte er beim ersten Klick // `suspended` und beim zweiten `active` — samt `user:enable`. Der // Mensch, dessen Zugang der Inhaber beendet hat, koennte sich wieder // 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')); return; } $seat->update(['status' => $seat->status === 'suspended' ? 'active' : 'suspended']); // Der Klick allein sperrt niemanden aus: bis der Auftrag durch ist, // arbeitet der Gesperrte weiter. Deshalb faehrt die Sperre in den Gast // und nicht nur in die Statusspalte. $this->queueSync($seat, $seat->status === 'suspended' ? 'disable' : 'enable'); $this->dispatch('notify', message: __( $seat->status === 'suspended' ? 'users.suspended' : 'users.reactivated', )); } public function revoke(string $uuid): void { $customer = $this->requireCustomer(); if ($customer === null) { return; } $seat = DB::transaction(function () use ($customer, $uuid) { Customer::query()->whereKey($customer->id)->lockForUpdate()->first(); $seat = $customer->seats()->where('uuid', $uuid)->first(); if ($seat === null) { return null; } if ($seat->role === 'owner' && $customer->seats()->where('role', 'owner')->count() <= 1) { return false; } // Nicht loeschen. Der Zugang ist zu, die Arbeit bleibt dort, wo // sein Team sie braucht. Wer wirklich loeschen will, tut das in // der Nextcloud, wo Nextcloud danach fragt, was mit den Dateien // geschehen soll. $seat->update(['status' => 'revoked']); return $seat; }); if ($seat === false) { $this->dispatch('notify', message: __('users.last_owner')); return; } if ($seat instanceof Seat) { $this->queueSync($seat, 'disable'); $this->dispatch('notify', message: __('users.revoked')); } } /** * The revoke button opens ConfirmRevokeSeat instead of calling revoke() * directly (R23); its confirm button dispatches back here. */ #[On('seat-revoke-confirmed')] public function onRevokeConfirmed(string $uuid): void { $this->revoke($uuid); } private function usedSeats(Customer $customer): int { return $customer->seats()->where('status', '!=', 'revoked')->count(); } private function seatLimit(Customer $customer): int { // The entitlement follows the active (or cancelling) package, not a newer // failed/deprovisioned record. $instance = $customer->instances()->whereIn('status', ['active', 'cancellation_scheduled'])->latest('id')->first() ?? $customer->instances()->latest('id')->first(); // From the contract: how many people a customer may invite is part of // what they bought. Cutting a plan's seats in the catalogue must not // lock users out of an existing customer's cloud. return (int) ($instance?->subscription?->seats ?? 5); } public function render() { $customer = $this->customer(); $seats = $customer ? $customer->seats()->orderByRaw("role = 'owner' desc")->orderBy('email')->get() : collect(); // The actions column is ALWAYS drawn. It used to be hidden when the // only seat was the owner, on the reasoning that there was nothing to // act on — but every seat can be renamed, and a column that disappears // does not read as "nothing applies here", it reads as "this product // cannot do that". Which is exactly how it was reported. return view('livewire.users', [ 'seats' => $seats, 'used' => $customer ? $this->usedSeats($customer) : 0, 'limit' => $customer ? $this->seatLimit($customer) : 0, 'roles' => Seat::ROLES, ]); } }