CluPilotCloud/app/Livewire/Admin/Settings.php

475 lines
17 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Http\Middleware\RestrictConsoleNetwork;
use App\Livewire\Concerns\ChangesOwnPassword;
use App\Livewire\Concerns\ResolvesOperator;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use App\Services\Deployment\UpdateChannel;
use App\Support\Settings as AppSettings;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Permission\Models\Role;
use Symfony\Component\HttpFoundation\IpUtils;
/**
* 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.
*
* Two-factor ENROLMENT lives on its own page, Admin\TwoFactorSetup — see
* RequireOperatorTwoFactor for why. Only the POLICY switch
* (saveTwoFactorPolicy(), below) stays here: only someone who already
* satisfies the policy should be the one changing it.
*/
#[Layout('layouts.admin')]
class Settings extends Component
{
use ChangesOwnPassword;
use ResolvesOperator;
/** This page changes the signed-in OPERATOR's password, never a portal one. */
protected function passwordAccountGuard(): string
{
return 'operator';
}
// 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
{
if (! $operator = $this->currentOperator()) {
return;
}
$this->name = $operator->name;
$this->email = $operator->email;
$this->requireTwoFactor = AppSettings::bool('console.require_2fa', false);
}
/**
* Take the marketing site and the customer portal offline, or back online.
* The console keeps working either way — otherwise this switch could only
* ever be flipped once.
*/
public function toggleSiteVisibility(): void
{
$this->authorize('site.manage');
$public = ! AppSettings::bool('site.public', true);
AppSettings::set('site.public', $public);
$this->dispatch('notify', message: $public
? __('admin_settings.site_now_public')
: __('admin_settings.site_now_hidden'));
}
public function saveAccount(): void
{
if (! $operator = $this->currentOperator()) {
return;
}
$data = $this->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|max:255|unique:operators,email,'.$operator->id,
]);
// An operator email must never collide with a customer's — that would
// block the customer from ever obtaining a portal login (ensureUser).
// Checked directly against `users`, not only `customers`: see
// Customer::emailTaken().
if (Customer::emailTaken($data['email'])) {
$this->addError('email', __('admin_settings.is_customer'));
return;
}
$operator->update($data);
$this->dispatch('notify', message: __('admin_settings.account_saved'));
}
public function inviteStaff(): void
{
$this->authorize('staff.manage');
if (! $operator = $this->currentOperator()) {
return;
}
$data = $this->validate([
'staffName' => 'required|string|max:255',
'staffEmail' => 'required|email|max:255|unique:operators,email',
'staffRole' => 'required|in:Owner,Admin,Support,Billing,Read-only',
]);
// Only an Owner may create another Owner.
if ($data['staffRole'] === 'Owner' && ! $operator->hasRole('Owner')) {
$this->addError('staffRole', __('admin_settings.owner_only'));
return;
}
// Never turn a customer's portal login into an operator.
if (Customer::emailTaken($data['staffEmail'])) {
$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);
$newStaff = Operator::create([
'name' => $data['staffName'],
'email' => $data['staffEmail'],
'password' => Hash::make($temp),
]);
$newStaff->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, Operator::OPERATOR_ROLES, true)) {
return;
}
if (! $operator = $this->currentOperator()) {
return;
}
$result = DB::transaction(function () use ($id, $role, $operator) {
// 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 = Operator::query()->whereKey($id)->lockForUpdate()->first();
if ($target === null) {
return 'gone';
}
if ($target->id === $operator->id) {
return 'self';
}
// Only existing operators may be re-roled. The Customer check below
// can no longer fire in practice — operators and customers are
// different tables now, so a tampered id cannot resolve to one from
// the other — but it costs nothing to leave as a second line.
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')) && ! $operator->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');
$revokedPeers = [];
$result = DB::transaction(function () use ($id, &$revokedPeers) {
Role::query()->where('name', 'Owner')->lockForUpdate()->first();
$target = Operator::query()->whereKey($id)->lockForUpdate()->first();
if ($target === null || ! $target->isOperator()) {
return 'gone';
}
if ($target->id === Auth::guard('operator')->id()) {
return 'self';
}
if ($target->hasRole('Owner') && $this->ownerCount() <= 1) {
return 'last_owner';
}
$target->syncRoles([]);
// 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();
$revokedPeers[] = $peer->public_key;
}
return 'revoked';
});
// Dispatched only once the rows are committed: a worker picking the job
// up mid-transaction would still see the peer as active, leave it on the
// hub, and never be asked again — a revoked colleague would keep their
// tunnel until the next reconciliation happened to notice.
foreach ($revokedPeers as $publicKey) {
ApplyVpnPeer::dispatch($publicKey, null, false);
}
$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 Operator::query()->whereHas('roles', fn ($q) => $q->where('name', 'Owner'))->count();
}
/** A new entry for the console allowlist: a single address or a CIDR range. */
public string $consoleIp = '';
/**
* Add a network that may reach the console without the VPN.
*
* Validated as an address or CIDR before it is stored: a typo that silently
* matches nothing is how someone locks themselves out while believing they
* have not.
*/
public function addConsoleIp(): void
{
$this->authorize('site.manage');
$value = trim($this->consoleIp);
if (! RestrictConsoleNetwork::isNetwork($value)) {
$this->addError('consoleIp', __('admin_settings.console_ip_invalid'));
return;
}
$list = (array) AppSettings::get('console.allowed_ips', []);
if (! in_array($value, $list, true)) {
$list[] = $value;
AppSettings::set('console.allowed_ips', array_values($list));
}
$this->consoleIp = '';
$this->dispatch('notify', message: __('admin_settings.console_ip_added', ['ip' => $value]));
}
/**
* Remove one — unless it is the reason you can see this page.
*
* Refusing here rather than warning afterwards: by the time the page
* reloads, the request that would show the warning has already been
* rejected. The VPN is never in this list, so an operator on the VPN can
* always clear it out.
*/
public function removeConsoleIp(string $value): void
{
$this->authorize('site.manage');
$list = array_values(array_filter(
(array) AppSettings::get('console.allowed_ips', []),
fn ($entry) => $entry !== $value,
));
$ip = (string) request()->ip();
if (RestrictConsoleNetwork::isRestricted()
&& ! RestrictConsoleNetwork::wouldStillAllow($ip, $list)) {
$this->dispatch('notify', message: __('admin_settings.console_ip_last', ['ip' => $ip]));
return;
}
AppSettings::set('console.allowed_ips', $list);
$this->dispatch('notify', message: __('admin_settings.console_ip_removed', ['ip' => $value]));
}
/**
* Switch the restriction on or off.
*
* Turning it ON is refused unless the address doing the switching is
* already covered — otherwise the click that secures the console is also
* the click that locks its owner out of it.
*/
public function toggleConsoleRestriction(): void
{
$this->authorize('site.manage');
$on = ! RestrictConsoleNetwork::isRestricted();
if ($on && ! RestrictConsoleNetwork::allows((string) request()->ip())) {
$this->dispatch('notify', message: __('admin_settings.console_lock_refused', ['ip' => (string) request()->ip()]));
return;
}
AppSettings::set('console.network_restricted', $on);
$this->dispatch('notify', message: __($on ? 'admin_settings.console_locked' : 'admin_settings.console_unlocked'));
}
/** Whether two-factor is compulsory for every operator on the console. */
public bool $requireTwoFactor = false;
/**
* Make two-factor compulsory for every operator, or make it voluntary again.
*
* The lock-out guard, same shape as console.allowed_ips: the page that
* could switch this back off sits behind the switch.
*/
public function saveTwoFactorPolicy(): void
{
$this->authorize('site.manage');
if ($this->requireTwoFactor && Auth::guard('operator')->user()?->two_factor_confirmed_at === null) {
$this->addError('requireTwoFactor', __('admin_settings.two_factor_self_first'));
return;
}
AppSettings::set('console.require_2fa', $this->requireTwoFactor);
$this->dispatch('notify', message: __('admin_settings.saved'));
}
/**
* Ask the host-side agent to update this installation — a real run.
*
* Deliberately a request rather than an action: see UpdateChannel. The
* button cannot report success, because success means this container is
* restarted out from under the response — so it reports that the update was
* asked for, and the page shows the outcome once the agent has written it.
*
* The sibling of requestCheck() below: this one applies whatever it finds,
* downtime and all, regardless of whether the last check found anything —
* that reading is a few minutes old, and the agent re-fetches before it
* decides.
*/
public function requestUpdate(): void
{
$this->authorize('site.manage');
if (! $operator = $this->currentOperator()) {
return;
}
$accepted = app(UpdateChannel::class)->request($operator->email);
$this->dispatch('notify', message: __($accepted
? 'admin_settings.update_requested'
: 'admin_settings.update_already_requested'));
}
/**
* Ask the host-side agent to look for updates — never to apply one.
*
* For the operator who only wants an answer: no maintenance window, no
* restart, just the next tick's reading reported back.
*/
public function requestCheck(): void
{
$this->authorize('site.manage');
if (! $operator = $this->currentOperator()) {
return;
}
$accepted = app(UpdateChannel::class)->requestCheck($operator->email);
$this->dispatch('notify', message: __($accepted
? 'admin_settings.update_check_requested'
: 'admin_settings.update_already_requested'));
}
public function render()
{
$operator = $this->currentOperator();
$staff = Operator::query()
->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::OPERATOR_ROLES))
->with('roles')
->orderBy('name')
->get()
->map(fn (Operator $u) => [
'id' => $u->id,
'name' => $u->name,
'email' => $u->email,
'role' => $u->roles->first()?->name ?? '—',
'self' => $u->id === Auth::guard('operator')->id(),
]);
return view('livewire.admin.settings', [
'update' => app(UpdateChannel::class)->state(),
'updateLog' => app(UpdateChannel::class)->lastLog(),
'sitePublic' => AppSettings::bool('site.public', true),
'canManageSite' => $operator?->can('site.manage') ?? false,
// Shown so nobody has to guess why they still see the real site.
'viewerOnVpn' => IpUtils::checkIp(
(string) request()->ip(),
(array) config('admin_access.trusted_ranges', []),
),
// Who may reach the console, and from where the viewer is asking —
// shown so the consequence of a change is visible before it is made.
'consoleRestricted' => RestrictConsoleNetwork::isRestricted(),
'consoleIps' => (array) AppSettings::get('console.allowed_ips', []),
'consoleVpnRanges' => (array) config('admin_access.trusted_ranges', []),
'viewerIp' => (string) request()->ip(),
'staff' => $staff,
'roles' => Operator::OPERATOR_ROLES,
'canManageStaff' => $operator?->can('staff.manage') ?? false,
'isOwner' => $operator?->hasRole('Owner') ?? false,
]);
}
}