Aus der Attrappe wird Verwaltung

Anlegen und Einladen sind zwei Vorgaenge. Einladen schickt einen Auftrag auf
die Bereitstellungs-Warteschlange — die einzige, die einen Gast erreicht — und
der Sitz zeigt danach, was WIRKLICH passiert ist, samt Grund und
Wiederholen-Knopf. Ohne das drueckt der Inhaber wieder und wieder.

Entziehen loescht nichts mehr. Ratelimit 10 je Kunde und 3 je Sitz pro Stunde,
mit echter Restzeit in der Meldung statt stummer Verweigerung.

Eine Pruefung verbietet user:delete im ganzen app/-Verzeichnis.
feat/versandtakt
nexxo 2026-08-03 21:52:59 +02:00
parent e1052f6c4e
commit 0e3a76a7cb
7 changed files with 686 additions and 58 deletions

View File

@ -5,8 +5,10 @@ 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;
@ -26,14 +28,18 @@ class Users extends Component
#[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 && $customer->seats()->count() === 0) {
if ($customer === null) {
return;
}
if ($customer->seats()->count() === 0) {
try {
$customer->seats()->firstOrCreate(
['email' => $customer->email],
@ -43,9 +49,40 @@ class Users extends Component
// 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();
}
}
public function invite(): void
/**
* 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) {
@ -90,7 +127,144 @@ class Users extends Component
$this->reset('inviteEmail', 'inviteName', 'inviteRole');
$this->inviteRole = 'member';
$this->dispatch('notify', message: __('users.invited'));
$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
@ -104,22 +278,30 @@ class Users extends Component
}
// Lock the customer so a concurrent owner change can't race past the guard.
$ok = DB::transaction(function () use ($customer, $uuid, $role) {
$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 true;
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 true;
return $seat;
});
if (! $ok) {
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');
}
}
@ -150,6 +332,11 @@ class Users extends Component
$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',
));
@ -162,23 +349,33 @@ class Users extends Component
return;
}
$result = DB::transaction(function () use ($customer, $uuid) {
$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 'gone';
return null;
}
if ($seat->role === 'owner' && $customer->seats()->where('role', 'owner')->count() <= 1) {
return 'last_owner';
return false;
}
$seat->delete();
return 'ok';
// 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 ($result === 'last_owner') {
if ($seat === false) {
$this->dispatch('notify', message: __('users.last_owner'));
} elseif ($result === 'ok') {
return;
}
if ($seat instanceof Seat) {
$this->queueSync($seat, 'disable');
$this->dispatch('notify', message: __('users.revoked'));
}
}
@ -193,22 +390,6 @@ class Users extends Component
$this->revoke($uuid);
}
public function resend(string $uuid): void
{
// Invite delivery is mocked for now.
if ($this->seat($uuid) !== null) {
$this->dispatch('notify', message: __('users.resent'));
}
}
private function ownerCount(): int
{
$customer = $this->customer();
return $customer ? $customer->seats()->where('role', 'owner')->count() : 0;
}
private function usedSeats(Customer $customer): int
{
return $customer->seats()->where('status', '!=', 'revoked')->count();
@ -220,19 +401,13 @@ class Users extends Component
// 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);
}
private function seat(string $uuid): ?Seat
{
$customer = $this->customer();
return $customer?->seats()->where('uuid', $uuid)->first();
}
public function render()
{
$customer = $this->customer();

View File

@ -0,0 +1,101 @@
<?php
namespace App\Provisioning\Jobs;
use App\Models\Seat;
use App\Services\Nextcloud\NextcloudUsers;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
/**
* Spiegelt einen Sitz in die Nextcloud des Kunden.
*
* Laeuft auf der `provisioning`-Warteschlange, weil nur dieser Arbeiter den
* Tunnel und die Proxmox-Zugangsdaten hat das Portal hat weder das eine noch
* das andere und darf es auch gar nicht versuchen.
*
* Daraus folgt: der Knopf wirkt nicht sofort. Deshalb schreibt dieser Auftrag
* zurueck, was WIRKLICH passiert ist (`nc_state`, `nc_error`) sonst drueckt
* der Inhaber wieder und wieder, weil nichts sichtbar geschieht. Genau die
* Beschwerde, die dieses Projekt schon zweimal hatte.
*
* `tries = 1`: ein blinder Wiederholungslauf gegen einen nicht erreichbaren
* Gast bringt nichts, und der Inhaber hat einen Knopf dafuer.
*/
class SyncSeatToNextcloud implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public function __construct(
public string $seatUuid,
/** invite | role | disable | enable */
public string $action,
) {
$this->onConnection('provisioning');
$this->onQueue('provisioning');
}
/** Auch was an handle() vorbeifliegt, muss am Sitz sichtbar werden. */
public function failed(?Throwable $e): void
{
Seat::query()->where('uuid', $this->seatUuid)->update([
'nc_state' => Seat::STATE_FAILED,
'nc_error' => 'unexpected',
]);
}
public function handle(NextcloudUsers $users): void
{
$seat = Seat::query()->with('customer')->where('uuid', $this->seatUuid)->first();
if ($seat === null) {
return;
}
// Erneut geprueft, nicht der Seite geglaubt: eine geschlossene Instanz
// kann ihre VMID auf demselben Host weiterverliehen haben — dieselbe
// Falle, die in IssueInstanceAdminAccess schon beschrieben steht.
$instance = $seat->customer?->instances()
->whereIn('status', ['active', 'cancellation_scheduled'])
->latest('id')->first();
if ($instance === null || $instance->host === null || blank($instance->vmid)) {
$this->record($seat, false, 'no_instance');
return;
}
$ok = match ($this->action) {
// Einladen setzt in einem Zug auch die Gruppe: ein Benutzer, der
// angelegt ist und in keiner Rolle steckt, sieht in seiner neuen
// Cloud nichts und meldet sich am ersten Tag beim Inhaber.
'invite' => $users->invite($instance, $seat) && $users->applyRole($instance, $seat),
'role' => $users->applyRole($instance, $seat),
'disable' => $users->disable($instance, $seat),
'enable' => $users->enable($instance, $seat),
default => false,
};
$this->record($seat, $ok, $ok ? null : 'guest_failed');
}
/**
* `nc_synced_at` bleibt beim Fehlschlag stehen, statt geleert zu werden:
* wann dieser Sitz zuletzt WIRKLICH stimmte, ist genau die Angabe, die
* einen Fehlschlag einordnet heute Morgen oder vor drei Wochen.
*/
private function record(Seat $seat, bool $ok, ?string $grund): void
{
$seat->forceFill([
'nc_state' => $ok ? Seat::STATE_SYNCED : Seat::STATE_FAILED,
'nc_error' => $grund,
'nc_synced_at' => $ok ? now() : $seat->nc_synced_at,
])->save();
}
}

View File

@ -8,8 +8,14 @@ return [
'invite_email' => 'E-Mail',
'invite_name' => 'Name (optional)',
// Zwei Knöpfe, zwei Wörter: 'add' legt die Zeile an und verschickt nichts,
// 'invite' ist der Knopf in der Tabelle, der es wirklich tut. Ein
// Anlegen-Knopf namens „Einladen" wäre genau die Attrappe, die hier
// abgeschafft wurde.
'add' => 'Hinzufügen',
'invite' => 'Einladen',
'role' => 'Rolle',
'role_readonly_means' => 'Kann nichts hochladen oder anlegen. Was ihm freigegeben wird, kann er im Rahmen der Freigabe bearbeiten.',
'role_owner' => 'Inhaber',
'role_admin' => 'Administrator',
@ -60,10 +66,24 @@ return [
'resend' => 'Erneut senden',
'revoke' => 'Entfernen',
'invited' => 'Einladung gesendet.',
// Was in der Nextcloud WIRKLICH ist — neben dem, was der Inhaber will.
// Ohne diese Zeilen wirkt jeder Knopf folgenlos, weil alles über die
// Warteschlange läuft.
'state_none' => 'angelegt — noch nicht eingeladen',
'state_pending' => 'wird eingerichtet …',
'state_failed' => 'fehlgeschlagen',
'retry' => 'Nochmal versuchen',
'retrying' => 'Wird noch einmal versucht.',
'error_no_instance' => 'Die Cloud dieses Kunden ist gerade nicht erreichbar.',
'error_guest_failed' => 'Die Cloud hat die Änderung nicht angenommen.',
'error_unexpected' => 'Unerwarteter Fehler. Bitte noch einmal versuchen.',
'added' => 'Benutzer angelegt. Die Einladung verschicken Sie mit „Einladen".',
'invite_sent' => 'Einladung verschickt. Der Mitarbeiter bekommt einen Link, an dem er sein Passwort selbst setzt.',
'too_many_invites' => 'Zu viele Einladungen — in :minutes Minuten wieder möglich.',
'revoked' => 'Benutzer entfernt.',
'resent' => 'Einladung erneut gesendet.',
'last_owner' => 'Der letzte Inhaber kann nicht entfernt oder geändert werden.',
'limit_reached' => 'Platz-Limit Ihres Pakets erreicht. Bitte upgraden.',
'duplicate' => 'Diese E-Mail ist bereits eingeladen.',
'duplicate' => 'Für diese E-Mail gibt es bereits einen Platz.',
];

View File

@ -8,8 +8,13 @@ return [
'invite_email' => 'Email',
'invite_name' => 'Name (optional)',
// Two buttons, two words: 'add' creates the row and sends nothing, 'invite'
// is the button in the table that really does it. An add button labelled
// "Invite" would be exactly the pretence this change removed.
'add' => 'Add',
'invite' => 'Invite',
'role' => 'Role',
'role_readonly_means' => 'Cannot upload or create anything. What is shared with them, they can edit within that share.',
'role_owner' => 'Owner',
'role_admin' => 'Administrator',
@ -60,10 +65,24 @@ return [
'resend' => 'Resend',
'revoke' => 'Remove',
'invited' => 'Invitation sent.',
// What is REALLY true in the Nextcloud — next to what the owner wants.
// Without these lines every button looks like it did nothing, because all
// of it runs through the queue.
'state_none' => 'added — not invited yet',
'state_pending' => 'being set up …',
'state_failed' => 'failed',
'retry' => 'Try again',
'retrying' => 'Trying again.',
'error_no_instance' => 'This customer\'s cloud cannot be reached right now.',
'error_guest_failed' => 'The cloud did not accept the change.',
'error_unexpected' => 'Unexpected error. Please try again.',
'added' => 'User added. Send the invitation with "Invite".',
'invite_sent' => 'Invitation sent. They get a link to set their own password.',
'too_many_invites' => 'Too many invitations — possible again in :minutes minutes.',
'revoked' => 'User removed.',
'resent' => 'Invitation resent.',
'last_owner' => 'The last owner cannot be removed or changed.',
'limit_reached' => 'Your plan seat limit is reached. Please upgrade.',
'duplicate' => 'This email is already invited.',
'duplicate' => 'This email already has a seat.',
];

View File

@ -11,8 +11,10 @@
</div>
</div>
{{-- Invite --}}
<form wire:submit="invite" class="grid grid-cols-1 gap-3 rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
{{-- Anlegen. Ausdruecklich NICHT einladen: hier entsteht nur die Zeile,
die Einladung verschickt der Knopf in der Tabelle. Ein Inhaber soll
sein Team vorbereiten koennen, ohne dass jemand eine Mail bekommt. --}}
<form wire:submit="addSeat" class="grid grid-cols-1 gap-3 rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
<div>
<label class="text-sm font-medium text-body" for="inviteEmail">{{ __('users.invite_email') }}</label>
<input id="inviteEmail" type="email" wire:model="inviteEmail" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@ -29,9 +31,12 @@
<option value="member">{{ __('users.role_member') }}</option>
<option value="readonly">{{ __('users.role_readonly') }}</option>
</select>
{{-- Der ehrliche Satz zu „Nur Lesen": die Rolle nimmt den eigenen
Speicherplatz weg, hebt aber eine Freigabe nicht auf. --}}
<p class="mt-1 text-xs text-muted">{{ __('users.role_readonly_means') }}</p>
</div>
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="invite">
<x-ui.icon name="plus" class="size-4" />{{ __('users.invite') }}
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="addSeat">
<x-ui.icon name="plus" class="size-4" />{{ __('users.add') }}
</x-ui.button>
</form>
@ -65,14 +70,53 @@
</select>
@endif
</td>
{{-- Was der Inhaber WILL (`status`) und was in der
Nextcloud WIRKLICH ist (`nc_state`) stehen in
einer Zelle, und der wahre Zustand geht vor:
solange der Auftrag laeuft oder gescheitert
ist, waere „Eingeladen" eine Behauptung ueber
etwas, das die Warteschlange noch gar nicht
erledigt hat. Ohne diese Spalte drueckt der
Inhaber wieder und wieder, weil nichts
sichtbar geschieht. --}}
<td class="px-4 py-3">
@php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp
<x-ui.badge :status="$sb">{{ __('users.status_'.$seat->status) }}</x-ui.badge>
@if ($seat->nc_state === \App\Models\Seat::STATE_NONE)
<x-ui.badge status="info">{{ __('users.state_none') }}</x-ui.badge>
@elseif ($seat->nc_state === \App\Models\Seat::STATE_PENDING)
<x-ui.badge status="info">{{ __('users.state_pending') }}</x-ui.badge>
@elseif ($seat->nc_state === \App\Models\Seat::STATE_FAILED)
<x-ui.badge status="failed">{{ __('users.state_failed') }}</x-ui.badge>
{{-- Der Grund im Klartext, nicht der Code:
'guest_failed' sagt einem Inhaber
nichts, und ein Fehlschlag ohne Grund
ist eine Sackgasse. --}}
<p class="mt-1 text-xs text-muted">{{ __('users.error_'.($seat->nc_error ?: 'unexpected')) }}</p>
<button type="button" wire:click="retry('{{ $seat->uuid }}')" class="mt-1 text-xs underline">
{{ __('users.retry') }}
</button>
@else
@php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp
<x-ui.badge :status="$sb">{{ __('users.status_'.$seat->status) }}</x-ui.badge>
@endif
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-1.5">
@if ($seat->status === 'invited')
<button type="button" wire:click="resend('{{ $seat->uuid }}')" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:bg-surface-hover">{{ __('users.resend') }}</button>
{{-- Einladen ist der zweite Vorgang und
hat deshalb seinen eigenen Knopf. Er
steht nur an einer Zeile, die noch
nichts in der Nextcloud hat; ist der
Auftrag unterwegs, gibt es nichts zu
druecken, und bei einem Fehlschlag
steht „Nochmal versuchen" schon in der
Zustandsspalte. --}}
@if ($seat->nc_state === \App\Models\Seat::STATE_NONE)
<button type="button" wire:click="sendInvite('{{ $seat->uuid }}')"
wire:loading.attr="disabled" wire:target="sendInvite"
class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:bg-surface-hover">{{ __('users.invite') }}</button>
@elseif ($seat->nc_state === \App\Models\Seat::STATE_SYNCED && $seat->status === 'invited')
<button type="button" wire:click="sendInvite('{{ $seat->uuid }}')"
wire:loading.attr="disabled" wire:target="sendInvite"
class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:bg-surface-hover">{{ __('users.resend') }}</button>
@endif
{{-- Rename is offered on every row, the owner

View File

@ -0,0 +1,248 @@
<?php
// tests/Feature/Seats/SyncSeatToNextcloudTest.php
use App\Livewire\Users;
use App\Models\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Seat;
use App\Provisioning\Jobs\SyncSeatToNextcloud;
use App\Services\Nextcloud\NextcloudUsers;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Symfony\Component\Finder\Finder;
it('schreibt den Erfolg an den Sitz', function () {
app()->instance(ProxmoxClient::class, new FakeProxmoxClient);
$customer = Customer::factory()->create();
// host_id, weil die host-Beziehung sonst null bliebe: der Auftrag bricht
// dann mit 'no_instance' ab und käme nie bis zum Gast — dieselbe Falle wie
// in NextcloudUsersTest::gastBereit().
Instance::factory()->for($customer)->create(['status' => 'active', 'vmid' => 201, 'host_id' => Host::factory()]);
$sitz = Seat::factory()->for($customer)->create([
'email' => 'anna@firma.tld', 'nc_username' => 'anna@firma.tld',
'nc_state' => Seat::STATE_PENDING,
]);
(new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(NextcloudUsers::class));
expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED)
->and($sitz->fresh()->nc_error)->toBeNull()
->and($sitz->fresh()->nc_synced_at)->not->toBeNull();
});
it('schreibt den Fehlschlag mit Grund an den Sitz', function () {
$pve = new FakeProxmoxClient;
$pve->guestThrows[201] = new RuntimeException('guest agent unreachable');
app()->instance(ProxmoxClient::class, $pve);
$customer = Customer::factory()->create();
Instance::factory()->for($customer)->create(['status' => 'active', 'vmid' => 201, 'host_id' => Host::factory()]);
$sitz = Seat::factory()->for($customer)->create(['nc_username' => 'anna@firma.tld']);
(new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(NextcloudUsers::class));
// Nicht nur „irgendein Fehler": der Grund muss der des unerreichbaren
// Gastes sein, nicht der der fehlenden Instanz — sonst liefe der Test auch
// dann grün, wenn die Instanz gar nicht gefunden wurde.
expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_FAILED)
->and($sitz->fresh()->nc_error)->toBe('guest_failed');
});
it('sagt es am Sitz, wenn es gar keine erreichbare Instanz gibt', function () {
// Der Kunde hat keine laufende Instanz — es gibt nichts zu spiegeln, und
// der Inhaber muss den Unterschied zu „der Gast hat abgelehnt" lesen
// können, weil nur das eine von beiden er selbst beheben kann.
$customer = Customer::factory()->create();
$sitz = Seat::factory()->for($customer)->create(['nc_username' => 'anna@firma.tld']);
(new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(NextcloudUsers::class));
expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_FAILED)
->and($sitz->fresh()->nc_error)->toBe('no_instance');
});
it('laeuft auf der Bereitstellungs-Warteschlange, nirgends sonst', function () {
// Nur dieser Arbeiter hat Tunnel und Proxmox-Zugangsdaten. Auf der
// Standard-Warteschlange erreicht der Auftrag keinen einzigen Gast.
$auftrag = new SyncSeatToNextcloud('egal', 'invite');
expect($auftrag->connection)->toBe('provisioning')
->and($auftrag->queue)->toBe('provisioning');
});
it('legt beim Anlegen KEINEN Nextcloud-Benutzer an', function () {
// Anlegen ist nicht Einladen. Der Inhaber soll sein Team vorbereiten
// koennen, ohne dass jemand eine Mail bekommt.
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
Livewire::actingAs($user)->test(Users::class)
->set('inviteEmail', 'anna@firma.tld')
->set('inviteName', 'Anna')
->call('addSeat');
Queue::assertNothingPushed();
expect(Seat::where('email', 'anna@firma.tld')->first()->nc_state)->toBe(Seat::STATE_NONE);
});
it('schickt erst beim Einladen einen Auftrag los', function () {
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$sitz = Seat::factory()->for($customer)->create(['role' => 'member']);
Livewire::actingAs($user)->test(Users::class)->call('sendInvite', $sitz->uuid);
Queue::assertPushed(SyncSeatToNextcloud::class);
expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_PENDING)
->and($sitz->fresh()->nc_username)->toBe($sitz->email);
});
it('haelt den Benutzernamen fest, auch wenn die Mailadresse sich aendert', function () {
// Nextcloud kann Benutzer nicht umbenennen. Ein Sitz, dessen Adresse sich
// spaeter aendert, behaelt seinen Anmeldenamen.
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$sitz = Seat::factory()->for($customer)->create(['email' => 'alt@firma.tld', 'nc_username' => 'alt@firma.tld']);
$sitz->update(['email' => 'neu@firma.tld']);
Livewire::actingAs($user)->test(Users::class)->call('sendInvite', $sitz->uuid);
expect($sitz->fresh()->nc_username)->toBe('alt@firma.tld');
});
it('weist die elfte Einladung derselben Stunde ab und sagt die Restzeit', function () {
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$sitze = Seat::factory()->for($customer)->count(11)->create(['role' => 'member']);
$seite = Livewire::actingAs($user)->test(Users::class);
foreach ($sitze->take(10) as $sitz) {
$seite->call('sendInvite', $sitz->uuid);
}
$seite->call('sendInvite', $sitze->last()->uuid);
// Keine stumme Verweigerung: die Meldung nennt die echte Restzeit. Auf die
// Zahl geprüft statt auf den Satz — ein Test, der denselben __()-Aufruf
// nachrechnet wie die Implementierung, würde auch eine fehlende Restzeit
// durchwinken (siehe R19).
$seite->assertDispatched(
'notify',
fn ($ereignis, $daten) => str_contains($daten['message'], '60'),
);
expect($sitze->last()->fresh()->nc_state)->toBe(Seat::STATE_NONE);
Queue::assertPushed(SyncSeatToNextcloud::class, 10);
});
it('weist das vierte Draufdruecken auf denselben Sitz ab', function () {
// Die zweite Grenze: gegen das wiederholte Klicken an EINER Zeile, das
// demselben Menschen sonst vier Willkommensmails schickt.
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$sitz = Seat::factory()->for($customer)->create(['role' => 'member']);
$seite = Livewire::actingAs($user)->test(Users::class);
foreach (range(1, 4) as $ignoriert) {
$seite->call('sendInvite', $sitz->uuid);
}
Queue::assertPushed(SyncSeatToNextcloud::class, 3);
});
it('entzieht, ohne die Zeile zu loeschen', function () {
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$sitz = Seat::factory()->for($customer)->create(['role' => 'member', 'nc_username' => 'anna@firma.tld']);
Livewire::actingAs($user)->test(Users::class)->call('revoke', $sitz->uuid);
// Der Datensatz bleibt. Ein Fehlgriff im Userpanel darf die Arbeit eines
// Menschen nicht vernichten — und in einem Produkt, das mit
// Nachvollziehbarkeit verkauft wird, gaebe es danach nichts mehr zu zeigen.
expect(Seat::find($sitz->id))->not->toBeNull()
->and($sitz->fresh()->status)->toBe('revoked');
Queue::assertPushed(SyncSeatToNextcloud::class);
});
it('schickt fuer einen Sitz, der nie in der Nextcloud war, keinen Auftrag', function () {
// Angelegt, nie eingeladen: es gäbe dort nichts zu sperren. Ein Auftrag
// liefe ins Leere und stellte die Zeile danach auf „fehlgeschlagen" —
// eine Fehlermeldung für etwas, das nie ein Fehler war.
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$sitz = Seat::factory()->for($customer)->create(['role' => 'member']);
Livewire::actingAs($user)->test(Users::class)->call('revoke', $sitz->uuid);
Queue::assertNothingPushed();
expect($sitz->fresh()->status)->toBe('revoked')
->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_NONE);
});
it('spiegelt eine Rollenaenderung in die Nextcloud', function () {
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$sitz = Seat::factory()->for($customer)->create([
'role' => 'member', 'nc_username' => 'anna@firma.tld', 'nc_state' => Seat::STATE_SYNCED,
]);
Livewire::actingAs($user)->test(Users::class)->call('setRole', $sitz->uuid, 'readonly');
Queue::assertPushed(SyncSeatToNextcloud::class, fn ($auftrag) => $auftrag->action === 'role');
expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_PENDING);
});
it('gibt dem Inhaber nach einem Fehlschlag einen zweiten Versuch', function () {
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$sitz = Seat::factory()->for($customer)->create([
'role' => 'member', 'nc_username' => 'anna@firma.tld',
'nc_state' => Seat::STATE_FAILED, 'nc_error' => 'guest_failed',
]);
Livewire::actingAs($user)->test(Users::class)->call('retry', $sitz->uuid);
// Der Grund verschwindet mit dem neuen Versuch — ein stehengebliebener
// alter Fehlertext neben „wird eingerichtet …" wäre die Attrappe zurück.
Queue::assertPushed(SyncSeatToNextcloud::class, fn ($auftrag) => $auftrag->action === 'invite');
expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_PENDING)
->and($sitz->fresh()->nc_error)->toBeNull();
});
it('nennt in keiner Datei unter app/ das Loeschen eines Benutzers', function () {
// Testerzwungene Regel: kein Nextcloud-Benutzer wird je geloescht, und
// keine Datei. Wer das aendern will, muss diese Pruefung anfassen und
// dabei ueber die Folgen stolpern.
$treffer = [];
foreach (Finder::create()->files()->in(app_path())->name('*.php') as $datei) {
if (str_contains($datei->getContents(), 'user:delete')) {
$treffer[] = $datei->getRelativePathname();
}
}
expect($treffer)->toBe([]);
});

View File

@ -29,16 +29,21 @@ it('creates an owner seat on first visit', function () {
expect($owner)->not->toBeNull()->and($owner->email)->toBe('o@seat.test');
});
it('invites a seat and blocks duplicates', function () {
// invite() heisst seit dem Nextcloud-Anschluss addSeat(), weil sie genau das
// tut und nichts weiter: eine Zeile anlegen. Verschickt wird erst in
// sendInvite() — siehe SyncSeatToNextcloudTest. Die Sitzplatzgrenze und die
// Sperre gegen doppelte Adressen sind dabei unveraendert geblieben.
it('adds a seat and blocks duplicates', function () {
['user' => $user, 'customer' => $customer] = seatSetup();
Livewire::actingAs($user)->test(Users::class)
->set('inviteEmail', 'new@seat.test')->set('inviteRole', 'member')->call('invite')
->set('inviteEmail', 'new@seat.test')->set('inviteRole', 'member')->call('addSeat')
->assertHasNoErrors();
expect($customer->seats()->where('email', 'new@seat.test')->exists())->toBeTrue();
Livewire::actingAs($user)->test(Users::class)
->set('inviteEmail', 'new@seat.test')->call('invite')
->set('inviteEmail', 'new@seat.test')->call('addSeat')
->assertHasErrors(['inviteEmail']);
expect($customer->seats()->where('email', 'new@seat.test')->count())->toBe(1);
});
@ -49,7 +54,7 @@ it('enforces the plan seat limit', function () {
Seat::factory()->count(5)->create(['customer_id' => $customer->id]);
Livewire::actingAs($user)->test(Users::class)
->set('inviteEmail', 'over@seat.test')->call('invite')
->set('inviteEmail', 'over@seat.test')->call('addSeat')
->assertHasErrors(['inviteEmail']);
expect($customer->seats()->where('email', 'over@seat.test')->exists())->toBeFalse();
});
@ -60,18 +65,31 @@ it('will not remove or demote the last owner', function () {
$owner = $customer->seats()->where('role', 'owner')->first();
Livewire::actingAs($user)->test(Users::class)->call('revoke', $owner->uuid);
expect($customer->seats()->whereKey($owner->id)->exists())->toBeTrue();
// Auf den STATUS geprueft, nicht mehr nur auf die Zeile: seit revoke()
// grundsaetzlich nicht mehr loescht, bewiese eine noch vorhandene Zeile
// gar nichts — sie bliebe auch dann stehen, wenn die Inhaber-Sperre
// versagte. Der Status ist die Zusicherung, die hier gemeint war.
expect($customer->seats()->whereKey($owner->id)->exists())->toBeTrue()
->and($owner->fresh()->status)->toBe('active');
Livewire::actingAs($user)->test(Users::class)->call('setRole', $owner->uuid, 'member');
expect($owner->fresh()->role)->toBe('owner');
});
it('revokes a non-owner seat', function () {
it('revokes a non-owner seat without deleting the row', function () {
// Frueher wurde die Zeile geloescht. Das faellt weg: ein Fehlgriff im
// Userpanel darf die Arbeit eines Menschen nicht vernichten, und in einem
// Produkt, das mit Nachvollziehbarkeit verkauft wird, gaebe es danach
// nichts mehr zu zeigen, wer welchen Zugang hatte. Geloescht wird, wenn
// ueberhaupt, in der Nextcloud selbst — dort fragt Nextcloud nach, was mit
// den Dateien geschehen soll.
['user' => $user, 'customer' => $customer] = seatSetup();
$seat = Seat::factory()->create(['customer_id' => $customer->id, 'role' => 'member']);
Livewire::actingAs($user)->test(Users::class)->call('revoke', $seat->uuid);
expect($customer->seats()->whereKey($seat->id)->exists())->toBeFalse();
expect($customer->seats()->whereKey($seat->id)->exists())->toBeTrue()
->and($seat->fresh()->status)->toBe('revoked');
});
it('shows the actions column even when the owner is the only seat', function () {
@ -244,5 +262,8 @@ it('revokes the seat once the page receives the confirmed event', function () {
Livewire::actingAs($user)->test(Users::class)->call('onRevokeConfirmed', $seat->uuid);
expect($customer->seats()->whereKey($seat->id)->exists())->toBeFalse();
// Wieder der Status statt der Existenz: revoke() loescht nicht mehr (siehe
// die Notiz weiter oben), also waere „die Zeile ist weg" jetzt eine
// Zusicherung, die das Produkt bewusst nicht mehr gibt.
expect($seat->fresh()->status)->toBe('revoked');
});