589 lines
24 KiB
PHP
589 lines
24 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire;
|
|
|
|
use App\Livewire\Concerns\ResolvesCustomer;
|
|
use App\Models\Customer;
|
|
use App\Models\Seat;
|
|
use App\Provisioning\Jobs\SyncSeatToNextcloud;
|
|
use Illuminate\Database\UniqueConstraintViolationException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\On;
|
|
use Livewire\Attributes\Validate;
|
|
use Livewire\Component;
|
|
|
|
#[Layout('layouts.portal-app')]
|
|
class Users extends Component
|
|
{
|
|
use ResolvesCustomer;
|
|
|
|
#[Validate('required|email|max:255')]
|
|
public string $inviteEmail = '';
|
|
|
|
#[Validate('nullable|string|max:255')]
|
|
public string $inviteName = '';
|
|
|
|
#[Validate('required|in:admin,member,readonly')]
|
|
public string $inviteRole = 'member';
|
|
|
|
public function mount(): void
|
|
{
|
|
// Every customer starts with themselves as the owner seat. firstOrCreate
|
|
// keyed on (customer_id, email) is idempotent; the catch covers the
|
|
// concurrent-first-visit race against the unique index.
|
|
$customer = $this->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;
|
|
}
|
|
|
|
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(<fremderSitz>, '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);
|
|
}
|
|
|
|
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,
|
|
]);
|
|
}
|
|
}
|