name = auth()->user()->name; $this->email = auth()->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()->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'); $revokedPeers = []; $result = DB::transaction(function () use ($id, &$revokedPeers) { 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(); $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 User::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()->user()->email); $this->dispatch('notify', message: __($accepted ? 'admin_settings.update_requested' : 'admin_settings.update_already_requested')); } 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', [ 'update' => app(UpdateChannel::class)->state(), 'updateLog' => app(UpdateChannel::class)->lastLog(), 'sitePublic' => AppSettings::bool('site.public', true), 'canManageSite' => auth()->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' => User::OPERATOR_ROLES, 'canManageStaff' => auth()->user()->can('staff.manage'), 'isOwner' => auth()->user()->hasRole('Owner'), ]); } }