tab, self::TABS, true)) { $this->tab = self::TABS[0]; } if (! $operator = $this->currentOperator()) { return; } // ?tab=team without the capability would open a tab that renders // nothing — the section is gated on its own, as it must be. Landing on // the first tab says the same thing without the blank page. if ($this->tab === 'team' && ! $operator->can('staff.manage')) { $this->tab = self::TABS[0]; } $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 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, // Only the tabs this operator can actually open. A tab that raises // a 403 is worse than one that is not there. 'tabs' => array_values(array_filter( self::TABS, fn (string $tab) => $tab !== 'team' || ($operator?->can('staff.manage') ?? false), )), ]); } }