fix(portal): allow closure after failed provisioning; serialize last-owner guard

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 15:02:37 +02:00
parent 8bcb5ec268
commit f6efa4f200
2 changed files with 40 additions and 17 deletions

View File

@ -40,8 +40,10 @@ class ConfirmCloseAccount extends ModalComponent
private function hasActivePackage(Customer $customer): bool
{
// Only genuinely-live packages block closure — a failed/cancelled/
// deprovisioned instance does not retain a service.
return $customer->instances()
->whereNotIn('status', ['cancelled', 'deprovisioned'])
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->exists();
}

View File

@ -93,36 +93,57 @@ class Users extends Component
if (! in_array($role, Seat::ROLES, true)) {
return;
}
$seat = $this->seat($uuid);
if ($seat === null) {
$customer = $this->customer();
if ($customer === null) {
return;
}
// Never leave the account without an owner.
if ($seat->role === 'owner' && $role !== 'owner' && $this->ownerCount() <= 1) {
// Lock the customer so a concurrent owner change can't race past the guard.
$ok = 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;
}
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;
});
if (! $ok) {
$this->dispatch('notify', message: __('users.last_owner'));
return;
}
$seat->update(['role' => $role]);
}
public function revoke(string $uuid): void
{
$seat = $this->seat($uuid);
if ($seat === null) {
$customer = $this->customer();
if ($customer === null) {
return;
}
if ($seat->role === 'owner' && $this->ownerCount() <= 1) {
$result = 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';
}
if ($seat->role === 'owner' && $customer->seats()->where('role', 'owner')->count() <= 1) {
return 'last_owner';
}
$seat->delete();
return 'ok';
});
if ($result === 'last_owner') {
$this->dispatch('notify', message: __('users.last_owner'));
return;
} elseif ($result === 'ok') {
$this->dispatch('notify', message: __('users.revoked'));
}
$seat->delete();
$this->dispatch('notify', message: __('users.revoked'));
}
public function resend(string $uuid): void