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 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() return $customer->instances()
->whereNotIn('status', ['cancelled', 'deprovisioned']) ->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->exists(); ->exists();
} }

View File

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