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
parent
e1052f6c4e
commit
0e3a76a7cb
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue