dnsZone = ProvisioningSettings::dnsZone(); $this->wgEndpoint = ProvisioningSettings::wgEndpoint(); $this->wgHubPubkey = ProvisioningSettings::wgHubPublicKey(); $this->traefikDynamicPath = ProvisioningSettings::traefikDynamicPath(); $this->sshPublicKey = ProvisioningSettings::sshPublicKey(); $this->monitoringUrl = ProvisioningSettings::monitoringUrl(); } } // ---- Plain settings — App\Support\Settings, hosts.manage, no password. ---- public function saveInfra(): void { $this->guardInfra(); $data = $this->validate([ 'dnsZone' => ['nullable', 'string', 'max:255'], 'wgEndpoint' => ['nullable', 'string', 'max:255'], 'wgHubPubkey' => ['nullable', 'string', 'max:255'], 'traefikDynamicPath' => ['nullable', 'string', 'max:255'], 'sshPublicKey' => ['nullable', 'string', 'max:1000'], 'monitoringUrl' => ['nullable', 'url', 'max:255'], ]); Settings::set('provisioning.dns_zone', trim((string) $data['dnsZone'])); Settings::set('provisioning.wg_endpoint', trim((string) $data['wgEndpoint'])); Settings::set('provisioning.wg_hub_pubkey', trim((string) $data['wgHubPubkey'])); Settings::set('provisioning.traefik_dynamic_path', trim((string) $data['traefikDynamicPath'])); Settings::set('provisioning.ssh_public_key', trim((string) $data['sshPublicKey'])); Settings::set('monitoring.api_url', trim((string) $data['monitoringUrl'])); $this->dispatch('notify', message: __('integrations.settings_saved')); } // ---- Vault entries — SecretVault, secrets.manage + confirmed password. ---- public function save(string $key): void { $this->guardSecrets(); $field = self::field($key); $value = trim((string) ($this->entered[$field] ?? '')); if ($value === '') { $this->addError('entered.'.$field, __('secrets.empty')); return; } try { app(SecretVault::class)->put($key, $value, Auth::guard('operator')->user()); } catch (Throwable $e) { $this->addError('entered.'.$field, $e->getMessage()); return; } $this->entered[$field] = ''; $this->check = null; $this->dispatch('notify', message: __('secrets.saved')); } /** ConfirmSaveSecret dispatches this back (R23) — see that class. */ #[On('secret-save-confirmed')] public function onSaveConfirmed(string $key): void { $this->save($key); } public function forget(string $key): void { $this->guardSecrets(); app(SecretVault::class)->forget($key); $this->check = null; $this->dispatch('notify', message: __('secrets.removed')); } /** ConfirmForgetSecret dispatches this back (R23) — see that class. */ #[On('secret-forget-confirmed')] public function onForgetConfirmed(string $key): void { $this->forget($key); } public function test(string $key): void { $this->guardSecrets(); $checker = SecretVault::REGISTRY[$key]['check'] ?? null; abort_if($checker === null, 404); $candidate = trim((string) ($this->entered[self::field($key)] ?? '')) ?: null; $this->check = app($checker)->run($candidate); } /** The dotless form key for a registry key (a dot means nesting to Livewire). */ public static function field(string $key): string { return str_replace('.', '_', $key); } // ---- The .env editor — Part B. secrets.manage + confirmed password. ---- /** * Validate, back up, write. EnvFileEditor does the actual work and the * actual refusing; this only translates its one exception into a form * error instead of a 500, and reports where the backup landed. */ public function saveEnv(): void { $this->guardSecrets(); try { $backup = app(EnvFileEditor::class)->write($this->envContent); } catch (InvalidEnvContentException $e) { $this->addError('envContent', $e->invalidLine !== null ? __('integrations.env_invalid', ['line' => $e->invalidLine]) : __('integrations.env_empty')); return; } // '' means there was no previous file to protect — EnvFileEditor's // own docblock explains why that is not an error. $this->dispatch('notify', message: __('integrations.env_saved', [ 'backup' => $backup !== '' ? basename($backup) : __('integrations.env_no_previous_file'), ])); } /** ConfirmSaveEnv dispatches this back (R23) — see that class. */ #[On('env-save-confirmed')] public function onEnvSaveConfirmed(): void { $this->saveEnv(); } /** * Overrides ConfirmsPassword's own method (aliased above to lockAgain) to * also drop the Stripe check result — the render()-side check just below * handles $envContent/$envLoaded, and handles it for BOTH ways a session * can end up locked: this explicit click, and the confirmation window * simply expiring with nobody clicking anything. A clear here alone would * only ever cover the first. */ public function forgetPasswordConfirmation(): void { $this->lockAgain(); $this->check = null; } private function guardInfra(): void { $this->authorize('hosts.manage'); } /** Capability AND a recently confirmed password, on every vault/.env action. */ private function guardSecrets(): void { $this->authorize('secrets.manage'); abort_unless($this->passwordRecentlyConfirmed(), 403); } public function render() { $vault = app(SecretVault::class); $canSecrets = Gate::allows('secrets.manage'); $canInfra = Gate::allows('hosts.manage'); $unlocked = $this->passwordRecentlyConfirmed(); // Loaded here rather than in mount(): a page that is merely reachable // must not already hold the whole credential file in component state // for an operator who has not confirmed a password this session. if ($canSecrets && $unlocked && ! $this->envLoaded) { $this->envContent = app(EnvFileEditor::class)->read(); $this->envLoaded = true; } // The mirror case, and the one forgetPasswordConfirmation() alone // does not cover: the confirmation window can also expire on its // own, with nobody clicking "Wieder sperren" — $unlocked simply goes // false on whatever the next render happens to be. Without this, the // textarea disappears from the page but $envContent — the ENTIRE // credential file — stays sitting in the component snapshot, reachable // to anyone with the browser this was left open on (Codex review, // P1). Checked on every render, not only the explicit lock action. if (! $unlocked && $this->envLoaded) { $this->envContent = ''; $this->envLoaded = false; } return view('livewire.admin.integrations', [ 'canSecrets' => $canSecrets, 'canInfra' => $canInfra, 'unlocked' => $unlocked, 'usable' => $vault->isUsable(), 'entries' => collect(SecretVault::REGISTRY) ->map(fn (array $meta, string $key) => [ 'key' => $key, 'field' => self::field($key), 'label' => __($meta['label']), 'envKey' => $meta['env_key'], 'testable' => isset($meta['check']), 'source' => $vault->source($key), 'outline' => $unlocked ? $vault->outline($key) : null, 'updated_at' => $unlocked ? $vault->updatedAt($key) : null, // The SSH identity is a multi-line PEM key: a single-line // password field would mangle it on paste. 'multiline' => $key === 'ssh.private_key', ]) ->keyBy('key'), ]); } }