429 lines
15 KiB
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,
|
|
]);
|
|
}
|
|
}
|