392 lines
14 KiB
PHP
392 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Admin;
|
|
|
|
use App\Models\Customer;
|
|
use App\Models\Operator;
|
|
use App\Models\VpnPeer;
|
|
use App\Services\Deployment\UpdateChannel;
|
|
use App\Support\Settings as AppSettings;
|
|
use App\Provisioning\Jobs\ApplyVpnPeer;
|
|
use Illuminate\Support\Facades\Auth;
|
|
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
|
|
{
|
|
use \App\Livewire\Concerns\ChangesOwnPassword;
|
|
|
|
/** 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
|
|
{
|
|
$this->name = Auth::guard('operator')->user()->name;
|
|
$this->email = Auth::guard('operator')->user()->email;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
$user = Auth::guard('operator')->user();
|
|
$data = $this->validate([
|
|
'name' => 'required|string|max:255',
|
|
'email' => 'required|email|max:255|unique:operators,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:operators,email',
|
|
'staffRole' => 'required|in:Owner,Admin,Support,Billing,Read-only',
|
|
]);
|
|
|
|
// Only an Owner may create another Owner.
|
|
if ($data['staffRole'] === 'Owner' && ! Auth::guard('operator')->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);
|
|
$operator = Operator::create([
|
|
'name' => $data['staffName'],
|
|
'email' => $data['staffEmail'],
|
|
'password' => Hash::make($temp),
|
|
]);
|
|
$operator->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;
|
|
}
|
|
|
|
$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 = Operator::query()->whereKey($id)->lockForUpdate()->first();
|
|
if ($target === null) {
|
|
return 'gone';
|
|
}
|
|
if ($target->id === Auth::guard('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')) && ! Auth::guard('operator')->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');
|
|
|
|
$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 (! \App\Http\Middleware\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 (\App\Http\Middleware\RestrictConsoleNetwork::isRestricted()
|
|
&& ! \App\Http\Middleware\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 = ! \App\Http\Middleware\RestrictConsoleNetwork::isRestricted();
|
|
|
|
if ($on && ! \App\Http\Middleware\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'));
|
|
}
|
|
|
|
|
|
/**
|
|
* Ask the host-side agent to update this installation.
|
|
*
|
|
* 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.
|
|
*/
|
|
public function requestUpdate(): void
|
|
{
|
|
$this->authorize('site.manage');
|
|
|
|
$accepted = app(UpdateChannel::class)->request(Auth::guard('operator')->user()->email);
|
|
|
|
$this->dispatch('notify', message: __($accepted
|
|
? 'admin_settings.update_requested'
|
|
: 'admin_settings.update_already_requested'));
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
$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' => Auth::guard('operator')->user()?->can('site.manage') ?? false,
|
|
// Shown so nobody has to guess why they still see the real site.
|
|
'viewerOnVpn' => \Symfony\Component\HttpFoundation\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' => \App\Http\Middleware\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' => Auth::guard('operator')->user()->can('staff.manage'),
|
|
'isOwner' => Auth::guard('operator')->user()->hasRole('Owner'),
|
|
]);
|
|
}
|
|
}
|