233 lines
7.9 KiB
PHP
233 lines
7.9 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Admin;
|
|
|
|
use App\Models\Customer;
|
|
use App\Models\User;
|
|
use App\Models\VpnPeer;
|
|
use App\Provisioning\Jobs\ApplyVpnPeer;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Str;
|
|
use Spatie\Permission\Models\Role;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\Validate;
|
|
use Livewire\Component;
|
|
|
|
/**
|
|
* Operator settings: the signed-in operator's own account plus (Owner-only)
|
|
* staff management. All staff mutations are capability-gated server-side and
|
|
* protect the last-Owner and self-role invariants transactionally.
|
|
*/
|
|
#[Layout('layouts.admin')]
|
|
class Settings extends Component
|
|
{
|
|
// My account
|
|
#[Validate('required|string|max:255')]
|
|
public string $name = '';
|
|
|
|
#[Validate('required|email|max:255')]
|
|
public string $email = '';
|
|
|
|
// Invite staff
|
|
#[Validate('required|string|max:255')]
|
|
public string $staffName = '';
|
|
|
|
#[Validate('required|email|max:255')]
|
|
public string $staffEmail = '';
|
|
|
|
#[Validate('required|in:Owner,Admin,Support,Billing,Read-only')]
|
|
public string $staffRole = 'Support';
|
|
|
|
/** Shown once after inviting, since email delivery is still mocked. */
|
|
public ?string $invitedEmail = null;
|
|
|
|
public ?string $invitedPassword = null;
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->name = auth()->user()->name;
|
|
$this->email = auth()->user()->email;
|
|
}
|
|
|
|
public function saveAccount(): void
|
|
{
|
|
$user = auth()->user();
|
|
$data = $this->validate([
|
|
'name' => 'required|string|max:255',
|
|
'email' => 'required|email|max:255|unique:users,email,'.$user->id,
|
|
]);
|
|
|
|
// An operator email must never collide with a customer's — that would
|
|
// block the customer from ever obtaining a portal login (ensureUser).
|
|
if (Customer::query()->where('email', $data['email'])->exists()) {
|
|
$this->addError('email', __('admin_settings.is_customer'));
|
|
|
|
return;
|
|
}
|
|
|
|
$user->update($data);
|
|
$this->dispatch('notify', message: __('admin_settings.account_saved'));
|
|
}
|
|
|
|
public function inviteStaff(): void
|
|
{
|
|
$this->authorize('staff.manage');
|
|
$data = $this->validate([
|
|
'staffName' => 'required|string|max:255',
|
|
'staffEmail' => 'required|email|max:255|unique:users,email',
|
|
'staffRole' => 'required|in:Owner,Admin,Support,Billing,Read-only',
|
|
]);
|
|
|
|
// Only an Owner may create another Owner.
|
|
if ($data['staffRole'] === 'Owner' && ! auth()->user()->hasRole('Owner')) {
|
|
$this->addError('staffRole', __('admin_settings.owner_only'));
|
|
|
|
return;
|
|
}
|
|
// Never turn a customer's portal login into an operator.
|
|
if (Customer::query()->where('email', $data['staffEmail'])->exists()) {
|
|
$this->addError('staffEmail', __('admin_settings.is_customer'));
|
|
|
|
return;
|
|
}
|
|
|
|
// Email/password-setup delivery is still mocked, so generate a temporary
|
|
// password and surface it once to the Owner to share securely — the
|
|
// account is usable immediately (a proper invite link follows with mail).
|
|
$temp = Str::password(14);
|
|
$user = User::create([
|
|
'name' => $data['staffName'],
|
|
'email' => $data['staffEmail'],
|
|
'password' => Hash::make($temp),
|
|
'is_admin' => true,
|
|
]);
|
|
$user->assignRole($data['staffRole']);
|
|
|
|
$this->invitedEmail = $data['staffEmail'];
|
|
$this->invitedPassword = $temp;
|
|
$this->reset('staffName', 'staffEmail');
|
|
$this->staffRole = 'Support';
|
|
$this->dispatch('notify', message: __('admin_settings.staff_invited'));
|
|
}
|
|
|
|
public function setStaffRole(int $id, string $role): void
|
|
{
|
|
$this->authorize('staff.manage');
|
|
if (! in_array($role, User::OPERATOR_ROLES, true)) {
|
|
return;
|
|
}
|
|
|
|
$result = DB::transaction(function () use ($id, $role) {
|
|
// Serialize on the Owner role so the global owner count can't be
|
|
// raced to zero by concurrent demotions of different owners.
|
|
Role::query()->where('name', 'Owner')->lockForUpdate()->first();
|
|
|
|
$target = User::query()->whereKey($id)->lockForUpdate()->first();
|
|
if ($target === null) {
|
|
return 'gone';
|
|
}
|
|
if ($target->id === auth()->id()) {
|
|
return 'self';
|
|
}
|
|
// Only existing operators may be re-roled — never escalate a customer
|
|
// (or any non-staff) user into the console via a tampered id.
|
|
if (! $target->isOperator() || Customer::query()->where('email', $target->email)->exists()) {
|
|
return 'not_staff';
|
|
}
|
|
// Granting or revoking Owner is Owner-only.
|
|
if (($role === 'Owner' || $target->hasRole('Owner')) && ! auth()->user()->hasRole('Owner')) {
|
|
return 'owner_only';
|
|
}
|
|
// Never demote the last Owner.
|
|
if ($target->hasRole('Owner') && $role !== 'Owner' && $this->ownerCount() <= 1) {
|
|
return 'last_owner';
|
|
}
|
|
$target->syncRoles([$role]);
|
|
|
|
return 'ok';
|
|
});
|
|
|
|
$this->flash($result);
|
|
}
|
|
|
|
public function revokeStaff(int $id): void
|
|
{
|
|
$this->authorize('staff.manage');
|
|
|
|
$result = DB::transaction(function () use ($id) {
|
|
Role::query()->where('name', 'Owner')->lockForUpdate()->first();
|
|
|
|
$target = User::query()->whereKey($id)->lockForUpdate()->first();
|
|
if ($target === null || ! $target->isOperator()) {
|
|
return 'gone';
|
|
}
|
|
if ($target->id === auth()->id()) {
|
|
return 'self';
|
|
}
|
|
if ($target->hasRole('Owner') && $this->ownerCount() <= 1) {
|
|
return 'last_owner';
|
|
}
|
|
$target->syncRoles([]);
|
|
$target->update(['is_admin' => false]);
|
|
|
|
// Taking away the console but leaving the tunnel would be the worst
|
|
// of both: no access to the panel, still a route into the
|
|
// management network. Same revocation path as the VPN page uses.
|
|
foreach (VpnPeer::query()->where('user_id', $target->id)->get() as $peer) {
|
|
$peer->purgeSecret();
|
|
$peer->delete();
|
|
ApplyVpnPeer::dispatch($peer->public_key, null, false);
|
|
}
|
|
|
|
return 'revoked';
|
|
});
|
|
|
|
$this->flash($result);
|
|
}
|
|
|
|
private function flash(string $result): void
|
|
{
|
|
$msg = match ($result) {
|
|
'ok' => __('admin_settings.role_updated'),
|
|
'revoked' => __('admin_settings.staff_revoked'),
|
|
'self' => __('admin_settings.not_self'),
|
|
'owner_only' => __('admin_settings.owner_only'),
|
|
'last_owner' => __('admin_settings.last_owner'),
|
|
'not_staff' => __('admin_settings.not_staff'),
|
|
default => null,
|
|
};
|
|
if ($msg !== null) {
|
|
$this->dispatch('notify', message: $msg);
|
|
}
|
|
}
|
|
|
|
private function ownerCount(): int
|
|
{
|
|
return User::query()->whereHas('roles', fn ($q) => $q->where('name', 'Owner'))->count();
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
$staff = User::query()
|
|
->whereHas('roles', fn ($q) => $q->whereIn('name', User::OPERATOR_ROLES))
|
|
->with('roles')
|
|
->orderBy('name')
|
|
->get()
|
|
->map(fn (User $u) => [
|
|
'id' => $u->id,
|
|
'name' => $u->name,
|
|
'email' => $u->email,
|
|
'role' => $u->roles->first()?->name ?? '—',
|
|
'self' => $u->id === auth()->id(),
|
|
]);
|
|
|
|
return view('livewire.admin.settings', [
|
|
'staff' => $staff,
|
|
'roles' => User::OPERATOR_ROLES,
|
|
'canManageStaff' => auth()->user()->can('staff.manage'),
|
|
'isOwner' => auth()->user()->hasRole('Owner'),
|
|
]);
|
|
}
|
|
}
|