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 verknüpft. * * Dieses Konto gibt es in der Nextcloud längst — CreateCustomerAdmin * hat es beim Aufbau angelegt. Ohne diese Verknüpfung stünde der Sitz * auf `none`, und das Panel böte dem Inhaber an, sich SELBST * einzuladen; der Auftrag träfe dann auf einen Benutzer, den es schon * gibt. * * Bei JEDEM Besuch versucht, nicht nur beim allerersten: wer das Panel * öffnet, bevor die Bereitstellung das Admin-Konto angelegt hat, * behielte sonst für immer einen owner-Sitz ohne Verknüpfung. Der * Aufruf ist folgenlos, solange es kein Konto gibt. * * Die Bedingung hängt an `nc_username`, weil das die Angabe ist, um * die es geht: genau einmal gesetzt, danach unveränderlich. Sie ist in * jedem ERREICHBAREN Zustand gleichbedeutend mit * `nc_state === STATE_NONE` — sie heilt also NICHTS, was * `=== STATE_NONE` nicht auch heilen würde, und insbesondere heilt sie * einen bereits beschädigten Sitz NICHT: der trägt ja einen * `nc_username`, und dann greift sie nie. * * Beschädigt 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 * dürfte es keinen geben, weil sämtliche `nc_*`-Spalten aus der * Wanderung von heute stammen. Taucht doch einer auf, hilft nur ein * Eingriff an der Zeile — nicht diese Bedingung. * * Die Wanderung aus Aufgabe 5 tut dasselbe für den Bestand — sie führt * 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 Vorgänge. * * Schickt ausdrücklich NICHTS los und lässt `nc_state` auf `none`: ein * Inhaber soll sein Team vorbereiten können, ohne dass jemand eine Mail * bekommt. Erst `sendInvite()` erzeugt einen Benutzer im Gast. * * Und es ist zugleich die Vordertür zurück: ein entzogener Sitz derselben * Adresse wird hier WIEDER AUFGENOMMEN, statt an der Eindeutigkeit von * (customer_id, email) abzuprallen. Wer geht und ein Jahr später * wiederkommt, muss wieder Zugang bekommen können; 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 Prüfung wie jeder neue Sitz. */ 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(); $vorhanden = $locked->seats()->where('email', $data['inviteEmail'])->first(); if ($vorhanden !== null && $vorhanden->status !== 'revoked') { return 'duplicate'; } // Der entzogene Sitz zählt heute NICHT gegen die Grenze // (usedSeats() lässt 'revoked' aus). Sobald er wieder auf // 'invited' steht, zählt er — die Prüfung gehört also davor, // genau wie beim Anlegen. if ($this->usedSeats($locked) >= $this->seatLimit($locked)) { return 'limit'; } if ($vorhanden !== null) { // `nc_username` und `nc_synced_at` bleiben, wie sie sind: der // Anmeldename gehört weiter dieser Person (Nextcloud kann // nicht umbenennen), und dass das Konto im Gast existiert, // bleibt wahr — daran hängt später das Entsperren. // // `nc_state` dagegen MUSS zurück auf `none`: der Sitz stand // auf `synced`, und `status = 'invited'` daneben liesse die // Zeile „Eingeladen" zeichnen — für jemanden, den niemand // eingeladen hat und dessen Konto im Gast weiter gesperrt ist. // Dieselbe Lüge 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'; } $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: __($result === 'reinstated' ? 'users.reinstated' : 'users.added')); } /** * Einladen — der zweite, getrennte Schritt. * * Anlegen und Einladen sind ausdrücklich zwei Vorgänge: ein Inhaber * soll sein Team vorbereiten und die Einladungen später verschicken * können, 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 wäre „Einladen" an einer // entzogenen Zeile der Weg zurück 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_closed')); return; } // Den Inhaber lädt niemand ein. Sein Konto legt die Bereitstellung an // (CreateCustomerAdmin); ein Auftrag von hier aus würde entweder auf // einen bestehenden Benutzer treffen oder — wenn die Bereitstellung // noch nicht so weit ist — später ein ZWEITES Konto in der Gruppe // `admin` anlegen, neben dem echten. if ($seat->role === 'owner') { $this->dispatch('notify', message: __('users.owner_not_invitable')); 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])); return; } // Einmal gesetzt, nie wieder geändert: Nextcloud kann Benutzer nicht // umbenennen. Ein Sitz, dessen Adresse sich später ändert, behält // 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 löschen 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 gedrückt werden, als die Warteschlange * antwortet — und ihn zu drosseln hiesse, die Rückfahrkarte 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; } // Dieselbe Wache wie in sendInvite(), und aus demselben Grund: an einem // beschädigten Inhaber-Sitz (`failed`, nie gespiegelt) wählte das // match unten `invite` und legte in der Nextcloud ein ZWEITES Konto in // der Gruppe `admin` an, neben dem echten. Gehärtet 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 ausdrücklich durch. Er ist der Fall, // für den dieser Knopf am dringendsten gebraucht wird: scheitert das // `disable`, ist das Konto im Gast weiter offen, während die Zeile // „entzogen" sagt. Gefährlich wäre nur ein Weg zurück — 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 wäre ein // Feld, das nach dem ersten Erfolg nie wieder stimmt. // // Der letzte Zweig hiess `role`, und darin steckte ein Loch: `enable` // stand in keinem. Ein gescheitertes Entsperren wurde damit als // Rollenänderung wiederholt — die fuhr Gruppen und Quota, gelang, die // Zeile sprang auf „Aktiv", und `user:enable` war nie geschickt. Der // Mitarbeiter kam weiter nicht hinein, der Knopf war weg. // // Woran erkennt dieser Zweig, dass zuletzt ein `enable` fällig war? // Gar nicht — der Statuswechsel trägt es nicht ('active' ist auch // der Normalzustand), und ein Feld dafür wäre wieder eine // Behauptung über die Cloud des Kunden, die irgendwann nicht mehr // stimmt. Deshalb rät er nicht, sondern schickt an einer OFFENEN // Zeile beides: aufsperren UND die Rolle setzen (`restore`). Welcher // der beiden Befehle beim letzten Mal scheiterte, spielt dann keine // Rolle mehr. // // `suspend()` bleibt bei `enable`: dort IST bekannt, was fehlt. $action = match (true) { in_array($seat->status, ['revoked', 'suspended'], true) => 'disable', $seat->nc_synced_at === null => 'invite', default => 'restore', }; // 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 über die andere Tür. // // `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); $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 Draufdrücken * an derselben Zeile. * * Gibt die Restzeit in Minuten zurück, oder null wenn frei. Eine stumme * Verweigerung wäre 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 gäbe es nichts zu ändern. * * 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, hinterlässt 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 * für etwas, das nie ein Fehler war. * * Das trägt nur, weil der Auftrag ein angelegtes Konto SOFORT vermerkt, * auch wenn die Gruppe danach scheitert — siehe SyncSeatToNextcloud. Sonst * bliebe genau der gefährliche Fall offen: Konto da, Sperre nie geschickt. * * EINE Ausnahme, und sie war der dritte, fehlende Wächter-Fall: steht * `pending`, ist gerade ein Auftrag unterwegs. Dann entsteht dort in * diesem Moment etwas, das gesperrt werden muss — und weil der Arbeiter * sich die Warteschlange mit der bezahlten Bereitstellung teilt, sind das * Minuten, nicht Millisekunden. Der nachgeschickte Auftrag läuft hinter * dem ersten (dieselbe serielle Warteschlange, dieselbe Reihenfolge). * * Der Auftrag selbst prüft die Absicht am Ende noch einmal nach; diese * Zeile ist die Rückfahrkarte für den Fall, dass er dabei schon vorbei * war. */ private function queueSync(Seat $seat, string $action): void { if ($seat->nc_synced_at === null && $seat->nc_state !== Seat::STATE_PENDING) { return; } $seat->forceFill(['nc_state' => Seat::STATE_PENDING, 'nc_error' => null])->save(); SyncSeatToNextcloud::dispatch($seat->uuid, $action); } /** * Die Rolle umstellen — an jedem Sitz ausser dem des Inhabers. * * `owner` ist hier weder Ziel noch Ausgangspunkt, und beides ist nötig. * Die früheren Wachen zählten nur owner-Sitze, und diese Zählung liess * sich vorbereiten: `setRole(, 'owner')` — das Auswahlfeld * bietet die Rolle nicht an, die Livewire-Methode ist trotzdem öffentlich * erreichbar — machte zwei owner, und danach griff keine Wache mehr. Der * Inhaber liess sich entziehen (`user:disable admin` samt * `user:auth-tokens:delete admin` in seiner EIGENEN Cloud) oder auf * `member` stellen, womit das Admin-Konto im eigenen Gast seine * Adminrechte verlor. Vor dem Anschluss an die Nextcloud war das folgenlos * — seit ihm wirkt es. * * Damit ist die Zählung selbst weg: wer nicht zum Inhaber gemacht werden * kann und dem Inhaber nichts wegnehmen kann, braucht kein „ist noch * einer übrig?". Eine Sperre, die man sich erst erarbeiten muss, ist * keine. */ public function setRole(string $uuid, string $role): void { // `Seat::ROLES` enthält `owner` — die Menge, die HIER zulässig ist, // ist eine andere. Dieselbe Liste wie im Auswahlfeld der Zeile. if (! in_array($role, ['admin', 'member', 'readonly'], true)) { return; } $customer = $this->requireCustomer(); if ($customer === null) { return; } $seat = $customer->seats()->where('uuid', $uuid)->first(); if ($seat === null) { return; } if ($seat->role === 'owner') { $this->dispatch('notify', message: __('users.owner_locked')); return; } $seat->update(['role' => $role]); // Erst nach dem Schreiben: ein Auftrag, den die Warteschlange // schneller aufnimmt als die Zeile geschrieben ist, liest die alte // Rolle. $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 // Zustände: 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, könnte sich wieder // anmelden, und er zählte wieder gegen die Platzgrenze, ohne dass sie // hier jemals geprüft wird. if ($seat->status === 'revoked') { $this->dispatch('notify', message: __('users.revoked_closed')); 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 fährt 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 = $customer->seats()->where('uuid', $uuid)->first(); if ($seat === null) { return; } // Wortgleich zu suspend() und setRole(), und aus demselben Grund: der // Inhaber sperrt sich nicht selbst aus seiner eigenen Cloud aus. Die // frühere Wache zählte owner-Sitze — und diese Zählung liess sich // über setRole() vorbereiten. Siehe den Kopf von setRole(). if ($seat->role === 'owner') { $this->dispatch('notify', message: __('users.owner_locked')); return; } // Nicht löschen. Der Zugang ist zu, die Arbeit bleibt dort, wo sein // Team sie braucht. Wer wirklich löschen will, tut das in der // Nextcloud, wo Nextcloud danach fragt, was mit den Dateien geschehen // soll. $seat->update(['status' => 'revoked']); $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); } /** * 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(); } 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, // 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), ]); } }