From 3fadaa14b02c8a959b0c580135f75a53a546aec7 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 01:49:39 +0200 Subject: [PATCH] Merge credentials and infrastructure into one Integrations page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zugangsdaten and Infrastruktur split the same subject by storage mechanism — SecretVault vs App\Support\Settings — instead of by what each field configures, which is not a distinction an operator setting up Stripe or DNS should have to know or care about. One page now, grouped by integration: Zahlungen (Stripe), DNS (Hetzner), Monitoring, VPN/WireGuard, SSH-Identität. A secret and a plain setting sit side by side within a section; the difference stays visible — a vault entry is write-only and shows only an outline, a setting shows its value in full — as a property of the field, not a reason for a separate page. Both storage mechanisms are unchanged underneath: this is a presentation change, not a data migration. The vault keeps its own gate (secrets.manage + a recently confirmed password); plain settings keep theirs (hosts.manage alone). Reachable with either capability, since the two are no longer two pages a role happens to see one, both, or neither of — every section and every save action still checks its own capability server-side. admin.secrets and admin.infrastructure redirect here permanently rather than 404ing a bookmark. The nav entry that replaces both is visible to either capability — Navigation's capability field now accepts an array meaning "any of these", not only a single ability. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/Integrations.php | 221 ++++++++++++++ app/Livewire/Admin/Mail.php | 4 +- app/Livewire/EditMailbox.php | 5 +- app/Support/Navigation.php | 11 +- lang/de/admin.php | 3 +- lang/de/integrations.php | 36 +++ lang/en/admin.php | 3 +- lang/en/integrations.php | 36 +++ .../components/admin/secret-field.blade.php | 92 ++++++ .../views/components/shell/nav.blade.php | 12 +- resources/views/components/ui/icon.blade.php | 1 + .../livewire/admin/integrations.blade.php | 183 ++++++++++++ routes/admin.php | 11 +- tests/Feature/Admin/IntegrationsPageTest.php | 277 ++++++++++++++++++ .../Admin/PasswordConfirmationGuardTest.php | 18 +- 15 files changed, 891 insertions(+), 22 deletions(-) create mode 100644 app/Livewire/Admin/Integrations.php create mode 100644 lang/de/integrations.php create mode 100644 lang/en/integrations.php create mode 100644 resources/views/components/admin/secret-field.blade.php create mode 100644 resources/views/livewire/admin/integrations.blade.php create mode 100644 tests/Feature/Admin/IntegrationsPageTest.php diff --git a/app/Livewire/Admin/Integrations.php b/app/Livewire/Admin/Integrations.php new file mode 100644 index 0000000..2c8bc94 --- /dev/null +++ b/app/Livewire/Admin/Integrations.php @@ -0,0 +1,221 @@ +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); + } + + private function guardInfra(): void + { + $this->authorize('hosts.manage'); + } + + /** Capability AND a recently confirmed password, on every vault 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(); + + 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']), + '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'), + ]); + } +} diff --git a/app/Livewire/Admin/Mail.php b/app/Livewire/Admin/Mail.php index 2e73439..84c19cd 100644 --- a/app/Livewire/Admin/Mail.php +++ b/app/Livewire/Admin/Mail.php @@ -76,8 +76,8 @@ class Mail extends Component // mailbox at once — pointing it at an attacker's server intercepts // everything CluPilot sends. The capability decides who may open this // page; a recent password decides whether THIS session may repoint - // it, the same second gate Admin\Secrets uses and for the same - // reason: the realistic threat is an unlocked machine, not a + // it, the same second gate Admin\Integrations' vault entries use and + // for the same reason: the realistic threat is an unlocked machine, not a // stranger. savePurposes() and test() stay on the capability alone — // this is the "changing an address" split's one exception. abort_unless($this->passwordRecentlyConfirmed(), 403); diff --git a/app/Livewire/EditMailbox.php b/app/Livewire/EditMailbox.php index bb9cca5..8c9149f 100644 --- a/app/Livewire/EditMailbox.php +++ b/app/Livewire/EditMailbox.php @@ -89,8 +89,9 @@ class EditMailbox extends ModalComponent // Setting a new SMTP password is one of the two actions the // mail.manage split still leaves able to intercept mail outright (the // other is Admin\Mail::saveServer()) — gated the same second way - // Admin\Secrets is: a capability decides who may open this modal at - // all, a recent password decides whether THIS session may point the + // Admin\Integrations' vault entries are: a capability decides who + // may open this modal at all, a recent password decides whether + // THIS session may point the // outgoing account at new credentials. if ($this->password !== '') { abort_unless($this->passwordRecentlyConfirmed(), 403); diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index d3a01c4..396bc37 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -57,8 +57,15 @@ final class Navigation ]], ['label' => __('admin.nav_group.system'), 'items' => [ ['admin.mail', 'mail', 'mail', 'mail.manage'], - ['admin.secrets', 'lock', 'secrets', 'secrets.manage'], - ['admin.infrastructure', 'globe', 'infrastructure', 'hosts.manage'], + // Merged from the former admin.secrets + admin.infrastructure + // pages, which split the same subject by storage mechanism + // instead of by what it configures. Reachable with EITHER + // capability — see App\Livewire\Admin\Integrations — so the + // entry stays visible to exactly who could reach at least one + // of the two before: an array here means "any of these", + // never "all of these" (contrast a single string elsewhere in + // this file, which x-shell.nav checks with plain ->can()). + ['admin.integrations', 'plug', 'integrations', ['hosts.manage', 'secrets.manage']], ['admin.settings', 'settings', 'settings', null], // null capability, deliberately: the compulsory two-factor // switch (Admin\Settings::saveTwoFactorPolicy()) applies to diff --git a/lang/de/admin.php b/lang/de/admin.php index 0f45efe..d65bce7 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -23,8 +23,7 @@ return [ 'finance' => 'Finanzen', 'revenue' => 'Umsatz', 'mail' => 'E-Mail', - 'secrets' => 'Zugangsdaten', - 'infrastructure' => 'Infrastruktur', + 'integrations' => 'Integrationen', 'settings' => 'Einstellungen', 'two_factor_setup' => 'Zwei-Faktor-Anmeldung', ], diff --git a/lang/de/integrations.php b/lang/de/integrations.php new file mode 100644 index 0000000..c32561c --- /dev/null +++ b/lang/de/integrations.php @@ -0,0 +1,36 @@ + 'Integrationen', + 'subtitle' => 'Angebundene Dienste, gruppiert nach Zweck — Zugangsdaten und Einstellungen nebeneinander, nicht auf getrennten Seiten nach Speicherort.', + + 'payments_title' => 'Zahlungen (Stripe)', + 'payments_body' => 'Der Secret Key, mit dem Zahlungen entgegengenommen und Webhooks geprüft werden.', + + 'dns_title' => 'DNS (Hetzner)', + 'dns_body' => 'API-Token, Zone und wo Traefik seine dynamische Konfiguration erwartet.', + 'dns_zone' => 'DNS-Zone', + 'dns_zone_hint' => 'Zum Beispiel clupilot.com — Kundeninstanzen erhalten ..', + 'traefik_path' => 'Traefik: Pfad für dynamische Konfiguration', + 'traefik_path_hint' => 'Verzeichnis auf dem Traffic-Host, in das Routen geschrieben werden.', + + 'monitoring_title' => 'Monitoring', + 'monitoring_body' => 'API-Token und wo die Kuma-Bridge erreichbar ist.', + 'monitoring_url' => 'Monitoring-Bridge-URL', + 'monitoring_url_hint' => 'Zum Beispiel http://kuma-bridge:8080. Leer lässt Monitoring aus.', + + 'vpn_title' => 'VPN/WireGuard', + 'vpn_body' => 'Wie ein neuer Host CluPilot als VPN-Hub erreicht. Der private Hub-Schlüssel bleibt in der Serverdatei — hier stehen nur der öffentliche Teil und die Adresse.', + 'wg_endpoint' => 'Hub-Endpunkt', + 'wg_endpoint_hint' => 'Öffentlich erreichbare Adresse und Port, z. B. vpn.clupilot.com:51820.', + 'wg_hub_pubkey' => 'Hub-Public-Key', + 'wg_hub_pubkey_hint' => 'Ausgabe von wg pubkey < /etc/wireguard/privatekey auf diesem Server.', + + 'ssh_title' => 'SSH-Identität', + 'ssh_body' => 'Der Schlüssel, der auf jedem neuen Host hinterlegt wird — öffentlicher und privater Teil zusammen an einer Stelle.', + 'ssh_public_key' => 'SSH-Schlüssel (öffentlich)', + 'ssh_public_key_hint' => 'Wird bei der Host-Aufnahme in authorized_keys eingetragen.', + + 'save_settings' => 'Einstellungen speichern', + 'settings_saved' => 'Gespeichert. Der neue Wert gilt ab sofort.', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 413ae6f..9a0dfd2 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -23,8 +23,7 @@ return [ 'finance' => 'Finance', 'revenue' => 'Revenue', 'mail' => 'Email', - 'secrets' => 'Credentials', - 'infrastructure' => 'Infrastructure', + 'integrations' => 'Integrations', 'settings' => 'Settings', 'two_factor_setup' => 'Two-factor login', ], diff --git a/lang/en/integrations.php b/lang/en/integrations.php new file mode 100644 index 0000000..86a7ec2 --- /dev/null +++ b/lang/en/integrations.php @@ -0,0 +1,36 @@ + 'Integrations', + 'subtitle' => 'Connected services, grouped by purpose — credentials and settings side by side, not on separate pages by storage mechanism.', + + 'payments_title' => 'Payments (Stripe)', + 'payments_body' => 'The secret key used to accept payments and verify webhooks.', + + 'dns_title' => 'DNS (Hetzner)', + 'dns_body' => 'The API token, the zone, and where Traefik expects its dynamic configuration.', + 'dns_zone' => 'DNS zone', + 'dns_zone_hint' => 'For example clupilot.com — customer instances get ..', + 'traefik_path' => 'Traefik dynamic config path', + 'traefik_path_hint' => 'Directory on the traffic host that routes get written into.', + + 'monitoring_title' => 'Monitoring', + 'monitoring_body' => 'The API token and where the Kuma bridge is reachable.', + 'monitoring_url' => 'Monitoring bridge URL', + 'monitoring_url_hint' => 'For example http://kuma-bridge:8080. Leave blank to leave monitoring off.', + + 'vpn_title' => 'VPN/WireGuard', + 'vpn_body' => 'How a new host reaches CluPilot as the VPN hub. The private hub key stays in the server file — only the public half and the address live here.', + 'wg_endpoint' => 'Hub endpoint', + 'wg_endpoint_hint' => 'Publicly reachable address and port, e.g. vpn.clupilot.com:51820.', + 'wg_hub_pubkey' => 'Hub public key', + 'wg_hub_pubkey_hint' => 'Output of wg pubkey < /etc/wireguard/privatekey on this server.', + + 'ssh_title' => 'SSH identity', + 'ssh_body' => 'The key deployed to every fresh host — its public and private halves, together in one place.', + 'ssh_public_key' => 'SSH key (public)', + 'ssh_public_key_hint' => 'Written into authorized_keys during host onboarding.', + + 'save_settings' => 'Save settings', + 'settings_saved' => 'Saved. The new value applies immediately.', +]; diff --git a/resources/views/components/admin/secret-field.blade.php b/resources/views/components/admin/secret-field.blade.php new file mode 100644 index 0000000..9e3ec5d --- /dev/null +++ b/resources/views/components/admin/secret-field.blade.php @@ -0,0 +1,92 @@ +@props(['entry', 'unlocked']) +{{-- + One vault entry — outline, new-value field, test/save/forget — reusable + across every integration section on App\Livewire\Admin\Integrations. Kept + as its own component so a section can sit a secret and a plain setting + side by side (R20 exception: this is the page, not a table row) without + repeating this block per section. + + Locked and unlocked are two genuinely different renders, not one hidden + behind CSS: while locked, nothing about the stored value — not even + whether one exists beyond the badge above this component — reaches the + page beyond a "gesperrt" note. +--}} +
+
+
+

{{ $entry['label'] }}

+

{{ $entry['key'] }}

+
+ + + {{ __('secrets.source_'.$entry['source']) }} + +
+ + @if (! $unlocked) +

+ + {{ __('secrets.locked_title') }} +

+ @else + @if ($entry['outline']) +
+
+
{{ __('secrets.stored_value') }}
+
{{ $entry['outline'] }}
+
+ @if ($entry['updated_at']) +
+
{{ __('secrets.changed') }}
+
{{ \Illuminate\Support\Carbon::parse($entry['updated_at'])->diffForHumans() }}
+
+ @endif +
+ @endif + +
+ @if ($entry['multiline']) + {{-- A multi-line PEM key: a single-line password field would + mangle it on paste. Not masked — a textarea cannot mask + its content in any browser. --}} + + +

{{ __('secrets.ssh_private_key_hint') }}

+ @error('entered.'.$entry['field'])

{{ $message }}

@enderror + @else + + @endif +
+ +
+ {{-- Test first: a key that is saved and wrong fails later, + somewhere else, usually in front of a customer. --}} + @if ($entry['testable']) + + {{ __('secrets.test') }} + + @endif + + + {{ __('secrets.save') }} + + + @if ($entry['source'] === 'stored') + + {{ __('secrets.forget') }} + + @endif +
+ @endif +
diff --git a/resources/views/components/shell/nav.blade.php b/resources/views/components/shell/nav.blade.php index 8bbddf2..732919c 100644 --- a/resources/views/components/shell/nav.blade.php +++ b/resources/views/components/shell/nav.blade.php @@ -49,7 +49,17 @@ @endif