customer(); if ($customer !== null && $customer->seats()->count() === 0) { try { $customer->seats()->firstOrCreate( ['email' => $customer->email], ['name' => $customer->name, 'role' => 'owner', 'status' => 'active', 'invited_at' => now()], ); } catch (UniqueConstraintViolationException) { // Another concurrent first visit created it — fine. } } } public function invite(): void { $customer = $this->requireCustomer(); if ($customer === null) { return; } $data = $this->validate(); // Serialize the limit check with the insert so two concurrent invites for // the last free seat can't both pass (lock the customer row). $result = DB::transaction(function () use ($customer, $data) { $locked = Customer::query()->whereKey($customer->id)->lockForUpdate()->first(); if ($locked->seats()->where('email', $data['inviteEmail'])->exists()) { return 'duplicate'; } if ($this->usedSeats($locked) >= $this->seatLimit($locked)) { return 'limit'; } $locked->seats()->create([ 'email' => $data['inviteEmail'], 'name' => $data['inviteName'] ?: null, 'role' => $data['inviteRole'], 'status' => 'invited', 'invited_at' => now(), ]); return 'ok'; }); if ($result === 'limit') { $this->addError('inviteEmail', __('users.limit_reached')); return; } if ($result === 'duplicate') { $this->addError('inviteEmail', __('users.duplicate')); return; } $this->reset('inviteEmail', 'inviteName', 'inviteRole'); $this->inviteRole = 'member'; $this->dispatch('notify', message: __('users.invited')); } public function setRole(string $uuid, string $role): void { if (! in_array($role, Seat::ROLES, true)) { return; } $customer = $this->requireCustomer(); if ($customer === null) { return; } // 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')); } } /** * Pause a seat without destroying it. * * The action an owner actually needs when someone leaves: access stops now, * and the record of who held it survives — which is the half a deletion * throws away, on a product sold on being able to show who had access to * what. */ public function suspend(string $uuid): void { $customer = $this->requireCustomer(); if ($customer === null) { return; } $seat = $customer->seats()->where('uuid', $uuid)->first(); if ($seat === null || $seat->role === 'owner') { // The owner cannot lock themselves out of their own cloud. $this->dispatch('notify', message: __('users.owner_locked')); return; } $seat->update(['status' => $seat->status === 'suspended' ? 'active' : 'suspended']); $this->dispatch('notify', message: __( $seat->status === 'suspended' ? 'users.suspended' : 'users.reactivated', )); } public function revoke(string $uuid): void { $customer = $this->requireCustomer(); if ($customer === null) { return; } $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')); } elseif ($result === 'ok') { $this->dispatch('notify', message: __('users.revoked')); } } /** * The revoke button opens ConfirmRevokeSeat instead of calling revoke() * directly (R23); its confirm button dispatches back here. */ #[On('seat-revoke-confirmed')] public function onRevokeConfirmed(string $uuid): void { $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(); } private function seatLimit(Customer $customer): int { // The entitlement follows the active (or cancelling) package, not a newer // 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(); $seats = $customer ? $customer->seats()->orderByRaw("role = 'owner' desc")->orderBy('email')->get() : collect(); // The actions column is ALWAYS drawn. It used to be hidden when the // only seat was the owner, on the reasoning that there was nothing to // act on — but every seat can be renamed, and a column that disappears // does not read as "nothing applies here", it reads as "this product // cannot do that". Which is exactly how it was reported. return view('livewire.users', [ 'seats' => $seats, 'used' => $customer ? $this->usedSeats($customer) : 0, 'limit' => $customer ? $this->seatLimit($customer) : 0, 'roles' => Seat::ROLES, ]); } }