551 lines
20 KiB
PHP
551 lines
20 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\Services\Deployment\UpdateWindow;
|
|
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);
|
|
|
|
$window = app(UpdateWindow::class);
|
|
$this->autoUpdate = $window->enabled();
|
|
$this->autoDays = $window->days();
|
|
$this->autoFrom = $window->from();
|
|
$this->autoTo = $window->to();
|
|
}
|
|
|
|
/**
|
|
* 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, downtime and all —
|
|
* but only a released version above the one installed. There is no longer
|
|
* such a thing as "update to whatever is on the branch".
|
|
*/
|
|
/** The update window — see App\Services\Deployment\UpdateWindow. */
|
|
public bool $autoUpdate = false;
|
|
|
|
/** @var array<int, int> ISO weekdays */
|
|
public array $autoDays = [];
|
|
|
|
public string $autoFrom = '';
|
|
|
|
public string $autoTo = '';
|
|
|
|
/**
|
|
* Switch the automation on or off and set its window.
|
|
*
|
|
* One action for all four values: a half-saved window — enabled with no
|
|
* days, or a start without an end — is a configuration that reads as
|
|
* working and does nothing.
|
|
*/
|
|
public function saveUpdateWindow(): void
|
|
{
|
|
$this->authorize('site.manage');
|
|
|
|
$this->validate([
|
|
'autoDays' => 'array',
|
|
'autoDays.*' => 'integer|min:1|max:7',
|
|
'autoFrom' => ['required', 'regex:/^([01]\\d|2[0-3]):[0-5]\\d$/'],
|
|
'autoTo' => ['required', 'regex:/^([01]\\d|2[0-3]):[0-5]\\d$/'],
|
|
]);
|
|
|
|
// Refused rather than silently corrected: an automation switched on
|
|
// with no day selected would never run, and an interface that accepts
|
|
// that has told the operator something untrue.
|
|
if ($this->autoUpdate && $this->autoDays === []) {
|
|
$this->addError('autoDays', __('admin_settings.auto_days'));
|
|
|
|
return;
|
|
}
|
|
|
|
AppSettings::set(UpdateWindow::KEY_ENABLED, $this->autoUpdate);
|
|
AppSettings::set(UpdateWindow::KEY_DAYS, array_values(array_map('intval', $this->autoDays)));
|
|
AppSettings::set(UpdateWindow::KEY_FROM, $this->autoFrom);
|
|
AppSettings::set(UpdateWindow::KEY_TO, $this->autoTo);
|
|
|
|
$this->dispatch('notify', message: __('admin_settings.auto_saved'));
|
|
}
|
|
|
|
public function requestUpdate(): void
|
|
{
|
|
$this->authorize('site.manage');
|
|
|
|
if (! $operator = $this->currentOperator()) {
|
|
return;
|
|
}
|
|
|
|
// Checked here as well as in the markup. The button is disabled when
|
|
// there is nothing released to install, and the agent refuses such a
|
|
// request too — but a Livewire action is a public endpoint, and the one
|
|
// place a rule may not live is only in the disabled attribute of a
|
|
// button. Queueing a run for nothing costs a maintenance window.
|
|
if (! app(UpdateChannel::class)->state()['available']) {
|
|
$this->dispatch('notify', message: __('admin_settings.update_nothing_released'));
|
|
|
|
return;
|
|
}
|
|
|
|
$accepted = app(UpdateChannel::class)->request($operator->email);
|
|
|
|
$this->dispatch('notify', message: __($accepted
|
|
? 'admin_settings.update_requested'
|
|
: 'admin_settings.update_already_requested'));
|
|
|
|
// Opens the maintenance overlay on THIS click rather than on the
|
|
// watcher's next poll. Without it there were visibly two screens: this
|
|
// component re-rendered at once and grew a little "läuft gerade" panel
|
|
// inside the settings card, and up to three seconds later the overlay
|
|
// covered the page and said the same thing again. Reported as "first a
|
|
// small window, then the big one".
|
|
//
|
|
// Dispatched only once a run is actually pending — an overlay that
|
|
// covers the console because a button was pressed, over nothing, is
|
|
// worse than a slow one.
|
|
if ($accepted) {
|
|
$this->dispatch('update-started');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
]);
|
|
}
|
|
}
|