fix(portal): allow closure after failed provisioning; serialize last-owner guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
8bcb5ec268
commit
f6efa4f200
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue