CluPilotCloud/app/Livewire/Users.php

183 lines
5.1 KiB
PHP

<?php
namespace App\Livewire;
use App\Models\Customer;
use App\Models\Seat;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Users extends Component
{
#[Validate('required|email|max:255')]
public string $inviteEmail = '';
#[Validate('nullable|string|max:255')]
public string $inviteName = '';
#[Validate('required|in:admin,member,readonly')]
public string $inviteRole = 'member';
public function mount(): void
{
// Every customer starts with themselves as the owner seat.
$customer = $this->customer();
if ($customer !== null && $customer->seats()->count() === 0) {
$customer->seats()->create([
'email' => $customer->email,
'name' => $customer->name,
'role' => 'owner',
'status' => 'active',
'invited_at' => now(),
]);
}
}
public function invite(): void
{
$customer = $this->customer();
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;
}
$seat = $this->seat($uuid);
if ($seat === null) {
return;
}
// Never leave the account without an owner.
if ($seat->role === 'owner' && $role !== 'owner' && $this->ownerCount() <= 1) {
$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) {
return;
}
if ($seat->role === 'owner' && $this->ownerCount() <= 1) {
$this->dispatch('notify', message: __('users.last_owner'));
return;
}
$seat->delete();
$this->dispatch('notify', message: __('users.revoked'));
}
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
{
$instance = $customer->instances()->latest('id')->first();
$plan = $instance->plan ?? 'start';
return (int) config("provisioning.plans.$plan.seats", 5);
}
private function seat(string $uuid): ?Seat
{
$customer = $this->customer();
return $customer?->seats()->where('uuid', $uuid)->first();
}
private function customer(): ?Customer
{
$user = auth()->user();
if (! $user) {
return null;
}
return Customer::query()->where('user_id', $user->id)->first()
?? Customer::query()->where('email', $user->email)->first();
}
public function render()
{
$customer = $this->customer();
$seats = $customer ? $customer->seats()->orderByRaw("role = 'owner' desc")->orderBy('email')->get() : collect();
return view('livewire.users', [
'seats' => $seats,
'used' => $customer ? $this->usedSeats($customer) : 0,
'limit' => $customer ? $this->seatLimit($customer) : 0,
'roles' => Seat::ROLES,
]);
}
}