CluPilotCloud/app/Livewire/Users.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,
]);
}
}