CluPilotCloud/app/Livewire/Users.php

429 lines
15 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 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 auf `none`. Der Aufruf ist
* folgenlos, solange es kein Konto gibt, und die Bedingung davor haelt
* ihn von jedem Sitz fern, der schon verknuepft ist.
*
* 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 && $owner->nc_state === Seat::STATE_NONE) {
$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;
}
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 nie in der Nextcloud war, braucht keinen Auftrag — es
* gaebe dort nichts zu aendern, und der Fehlschlag stellte die Zeile
* danach auf „fehlgeschlagen", also auf eine Fehlermeldung fuer etwas,
* das nie ein Fehler war.
*
* Geprueft werden BEIDE Angaben: ein Sitz, der schon einen Anmeldenamen
* traegt, kann dort ein Konto haben, auch wenn `nc_state` es (noch) nicht
* sagt. Von den beiden moeglichen Irrtuemern ist ein ueberfluessiger
* Auftrag der harmlose — der andere hiesse, dass ein entzogener Zugang
* offen bleibt.
*/
private function queueSync(Seat $seat, string $action): void
{
if ($seat->nc_state === Seat::STATE_NONE && blank($seat->nc_username)) {
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;
}
$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,
]);
}
}