Merge credentials and infrastructure into one Integrations page

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 <noreply@anthropic.com>
feat/granted-plans
nexxo 2026-07-29 01:49:39 +02:00
parent 835f3eeb64
commit 3fadaa14b0
15 changed files with 891 additions and 22 deletions

View File

@ -0,0 +1,221 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Services\Secrets\SecretVault;
use App\Support\ProvisioningSettings;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Throwable;
/**
* Connected services, one page, grouped by what they are for Zahlungen,
* DNS, Monitoring, VPN/WireGuard, SSH-Identität not by which of the two
* mechanisms below happens to hold a given value.
*
* Replaces Admin\Secrets and Admin\Infrastructure, which is what they were:
* one console area split across two pages by storage mechanism, reported as
* exactly the wrong axis to split on. An operator setting up an integration
* should not have to know whether a given field happens to be encrypted.
*
* Both mechanisms are unchanged underneath this is a presentation change,
* not a data migration:
*
* - SecretVault: a curated, encrypted key/value store for credentials that
* actually stop the business when they leak or expire. Gated by
* `secrets.manage` (Owner only) AND a recently confirmed password the
* realistic threat is an unlocked machine, not a stranger. A stored value
* is write-only: the page only ever shows an outline of it.
* - App\Support\Settings: plain deployment configuration. Gated by
* `hosts.manage` (Owner + Admin) alone none of it opens anything by
* itself. Shows its value in full, because there is nothing to protect.
*
* mount() is reachable with EITHER capability, because the two halves are no
* longer two pages an operator's role happens to see one, both, or neither
* of they are sections of the SAME page now. Each section, and each save
* action, still checks its OWN capability (guardSecrets()/guardInfra())
* server-side; an Admin who can reach this page for the DNS zone still gets
* 403 the moment they try to touch a vault entry.
*/
#[Layout('layouts.admin')]
class Integrations extends Component
{
use ConfirmsPassword;
// Vault entries — same shape ConfirmSaveSecret/ConfirmForgetSecret expect
// and dispatch back into (secret-save-confirmed / secret-forget-confirmed).
public array $entered = [];
public ?array $check = null;
// Plain settings — App\Support\ProvisioningSettings' full list.
public string $dnsZone = '';
public string $wgEndpoint = '';
public string $wgHubPubkey = '';
public string $traefikDynamicPath = '';
public string $sshPublicKey = '';
public string $monitoringUrl = '';
protected function confirmationGuard(): string
{
return 'operator';
}
public function mount(): void
{
abort_unless(Gate::any(['hosts.manage', 'secrets.manage']), 403);
if (Gate::allows('hosts.manage')) {
$this->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'),
]);
}
}

View File

@ -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);

View File

@ -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);

View File

@ -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

View File

@ -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',
],

36
lang/de/integrations.php Normal file
View File

@ -0,0 +1,36 @@
<?php
return [
'title' => '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 <subdomain>.<Zone>.',
'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.',
];

View File

@ -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',
],

36
lang/en/integrations.php Normal file
View File

@ -0,0 +1,36 @@
<?php
return [
'title' => '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 <subdomain>.<zone>.',
'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.',
];

View File

@ -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.
--}}
<div wire:key="secret-{{ $entry['key'] }}" class="space-y-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-ink">{{ $entry['label'] }}</h3>
<p class="mt-0.5 font-mono text-xs text-muted">{{ $entry['key'] }}</p>
</div>
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $entry['source'] === 'stored' ? 'border-success-border bg-success-bg text-success'
: ($entry['source'] === 'environment' ? 'border-info-border bg-info-bg text-info'
: 'border-warning-border bg-warning-bg text-warning') }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('secrets.source_'.$entry['source']) }}
</span>
</div>
@if (! $unlocked)
<p class="flex items-center gap-1.5 text-sm text-muted">
<x-ui.icon name="lock" class="size-4" />
{{ __('secrets.locked_title') }}
</p>
@else
@if ($entry['outline'])
<dl class="rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-muted">{{ __('secrets.stored_value') }}</dt>
<dd class="font-mono text-body">{{ $entry['outline'] }}</dd>
</div>
@if ($entry['updated_at'])
<div class="mt-1 flex justify-between gap-4">
<dt class="text-muted">{{ __('secrets.changed') }}</dt>
<dd class="text-body">{{ \Illuminate\Support\Carbon::parse($entry['updated_at'])->diffForHumans() }}</dd>
</div>
@endif
</dl>
@endif
<div>
@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. --}}
<label for="entered-{{ $entry['field'] }}" class="block text-sm font-medium text-body">{{ __('secrets.new_value') }}</label>
<textarea id="entered-{{ $entry['field'] }}" name="entered.{{ $entry['field'] }}" rows="6" autocomplete="off" spellcheck="false"
wire:model="entered.{{ $entry['field'] }}"
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 font-mono text-xs text-ink placeholder:text-faint transition"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
<p class="mt-1.5 text-xs text-muted">{{ __('secrets.ssh_private_key_hint') }}</p>
@error('entered.'.$entry['field'])<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
@else
<x-ui.input name="entered.{{ $entry['field'] }}" type="password" autocomplete="off"
:label="__('secrets.new_value')" :hint="__('secrets.new_value_hint')"
wire:model="entered.{{ $entry['field'] }}" />
@endif
</div>
<div class="flex flex-wrap gap-2">
{{-- Test first: a key that is saved and wrong fails later,
somewhere else, usually in front of a customer. --}}
@if ($entry['testable'])
<x-ui.button variant="secondary" wire:click="test('{{ $entry['key'] }}')"
wire:loading.attr="disabled" wire:target="test('{{ $entry['key'] }}')">
{{ __('secrets.test') }}
</x-ui.button>
@endif
<x-ui.button variant="primary"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-save-secret', arguments: { key: '{{ $entry['key'] }}' } })">
{{ __('secrets.save') }}
</x-ui.button>
@if ($entry['source'] === 'stored')
<x-ui.button variant="ghost"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-forget-secret', arguments: { key: '{{ $entry['key'] }}' } })">
{{ __('secrets.forget') }}
</x-ui.button>
@endif
</div>
@endif
</div>

View File

@ -49,7 +49,17 @@
@endif
<nav class="space-y-0.5">
@foreach ($group['items'] as [$route, $icon, $key, $capability])
@continue($capability !== null && ! auth()->user()?->can($capability))
{{-- $capability is a single ability name, an array of
ability names meaning "any of these" (App\Support\
Navigation documents which entries use that), or
null for "everyone". Laravel's own can() treats an
array as "all of these", which is the wrong logic
for an entry reachable with either of two
capabilities hence the explicit branch instead of
a bare ->can($capability) for both shapes. --}}
@continue($capability !== null && ! (is_array($capability)
? collect($capability)->contains(fn ($one) => auth()->user()?->can($one))
: auth()->user()?->can($capability)))
<x-ui.nav-item
:href="route($route)"
:active="$console ? \App\Support\AdminArea::routeIs($route) : request()->routeIs($route)"

View File

@ -41,6 +41,7 @@
'mail' => '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
'globe' => '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
'send' => '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>',
'plug' => '<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/>',
];
$body = $icons[$name] ?? '';

View File

@ -0,0 +1,183 @@
<div class="mx-auto max-w-3xl space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('integrations.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('integrations.subtitle') }}</p>
</div>
@if ($canSecrets && ! $usable)
<x-ui.alert variant="warning">{{ __('secrets.no_key') }}</x-ui.alert>
@endif
{{-- The one lock for everything write-only on this page: every vault
entry below. Plain settings never sit behind this they were never
gated by a password before, and nothing about merging the pages
makes them more dangerous. --}}
@if ($canSecrets && ! $unlocked)
<form wire:submit="confirmPassword" class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<h2 class="font-semibold text-ink">{{ __('secrets.locked_title') }}</h2>
<p class="mt-1.5 max-w-xl text-sm text-muted">{{ __('secrets.locked_body') }}</p>
<div class="mt-4 flex flex-wrap items-start gap-2">
<div class="min-w-56 flex-1">
<x-ui.input name="confirmablePassword" type="password" autocomplete="current-password"
:label="__('admin_settings.password_current')" wire:model="confirmablePassword" />
</div>
<x-ui.button type="submit" variant="primary" class="mt-7" wire:loading.attr="disabled" wire:target="confirmPassword">
{{ __('secrets.unlock') }}
</x-ui.button>
</div>
</form>
@elseif ($canSecrets)
<div class="flex items-center justify-between rounded-lg border border-accent-border bg-accent-subtle px-4 py-2.5 animate-rise">
<p class="text-sm text-accent-text">{{ __('secrets.unlocked_note') }}</p>
<button type="button" wire:click="forgetPasswordConfirmation"
class="text-xs font-semibold text-accent-text hover:underline">{{ __('secrets.lock_again') }}</button>
</div>
@endif
{{-- Zahlungen (Stripe) vault only, no plain setting belongs here. --}}
@if ($canSecrets)
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div>
<h2 class="font-semibold text-ink">{{ __('integrations.payments_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('integrations.payments_body') }}</p>
</div>
<x-admin.secret-field :entry="$entries['stripe.secret']" :unlocked="$unlocked" />
@if ($unlocked)
<x-ui.alert variant="info">{{ __('secrets.webhook_secret_note') }}</x-ui.alert>
@endif
</div>
@endif
{{-- DNS (Hetzner) token and the zone/Traefik settings that use it. --}}
@if ($canSecrets || $canInfra)
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:30ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('integrations.dns_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('integrations.dns_body') }}</p>
</div>
@if ($canInfra)
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<x-ui.input name="dnsZone" wire:model="dnsZone" :label="__('integrations.dns_zone')" :hint="__('integrations.dns_zone_hint')" />
<x-ui.input name="traefikDynamicPath" wire:model="traefikDynamicPath" :label="__('integrations.traefik_path')" :hint="__('integrations.traefik_path_hint')" />
</div>
@endif
@if ($canSecrets)
@if ($canInfra)<hr class="border-line" />@endif
<x-admin.secret-field :entry="$entries['dns.token']" :unlocked="$unlocked" />
@endif
</div>
@endif
{{-- Monitoring token and where the Kuma bridge is reachable. --}}
@if ($canSecrets || $canInfra)
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('integrations.monitoring_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('integrations.monitoring_body') }}</p>
</div>
@if ($canInfra)
<x-ui.input name="monitoringUrl" type="url" wire:model="monitoringUrl" :label="__('integrations.monitoring_url')" :hint="__('integrations.monitoring_url_hint')" placeholder="http://kuma-bridge:8080" />
@endif
@if ($canSecrets)
@if ($canInfra)<hr class="border-line" />@endif
<x-admin.secret-field :entry="$entries['monitoring.token']" :unlocked="$unlocked" />
@endif
</div>
@endif
{{-- VPN/WireGuard no vault entry: the private hub key stays in .env. --}}
@if ($canInfra)
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('integrations.vpn_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('integrations.vpn_body') }}</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<x-ui.input name="wgEndpoint" wire:model="wgEndpoint" :label="__('integrations.wg_endpoint')" :hint="__('integrations.wg_endpoint_hint')" placeholder="vpn.clupilot.com:51820" />
<x-ui.input name="wgHubPubkey" wire:model="wgHubPubkey" :label="__('integrations.wg_hub_pubkey')" :hint="__('integrations.wg_hub_pubkey_hint')" />
</div>
</div>
@endif
{{-- SSH-Identität public half a setting, private half the vault entry. --}}
@if ($canSecrets || $canInfra)
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('integrations.ssh_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('integrations.ssh_body') }}</p>
</div>
@if ($canInfra)
<div>
<label for="sshPublicKey" class="block text-sm font-medium text-body">{{ __('integrations.ssh_public_key') }}</label>
<textarea id="sshPublicKey" name="sshPublicKey" rows="3" autocomplete="off" spellcheck="false"
wire:model="sshPublicKey"
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 font-mono text-xs text-ink placeholder:text-faint transition"
placeholder="ssh-ed25519 AAAA... clupilot"></textarea>
<p class="mt-1.5 text-xs text-muted">{{ __('integrations.ssh_public_key_hint') }}</p>
@error('sshPublicKey')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
</div>
@endif
@if ($canSecrets)
@if ($canInfra)<hr class="border-line" />@endif
<x-admin.secret-field :entry="$entries['ssh.private_key']" :unlocked="$unlocked" />
@endif
</div>
@endif
@if ($canInfra)
<div class="flex justify-end">
<x-ui.button variant="primary" wire:click="saveInfra" wire:loading.attr="disabled" wire:target="saveInfra">
{{ __('integrations.save_settings') }}
</x-ui.button>
</div>
@endif
@if ($canSecrets && $unlocked && $check !== null)
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<h2 class="font-semibold text-ink">{{ __('secrets.check_title') }}</h2>
@if (! $check['ok'])
<x-ui.alert variant="danger" class="mt-3">{{ __('secrets.check_'.$check['reason']) }}</x-ui.alert>
@else
<dl class="mt-3 space-y-1 text-sm">
<div class="flex gap-2"><dt class="text-muted">{{ __('secrets.check_account') }}:</dt>
<dd class="font-mono text-body">{{ $check['account'] }}{{ $check['business'] ? ' · '.$check['business'] : '' }}</dd></div>
<div class="flex gap-2"><dt class="text-muted">{{ __('secrets.check_mode') }}:</dt>
<dd class="{{ $check['live'] ? 'font-medium text-warning' : 'text-body' }}">
{{ $check['live'] ? __('secrets.mode_live') : __('secrets.mode_test') }}{{ $check['restricted'] ? ' · '.__('secrets.mode_restricted') : '' }}
</dd></div>
</dl>
<h3 class="mt-5 text-sm font-semibold text-ink">{{ __('secrets.check_webhooks') }}</h3>
@if ($check['webhooks'] === null)
<p class="mt-1 text-sm text-muted">{{ __('secrets.check_webhooks_unknown') }}</p>
@elseif ($check['webhooks'] === [])
<x-ui.alert variant="warning" class="mt-2">{{ __('secrets.check_webhooks_none') }}</x-ui.alert>
@else
<ul class="mt-2 space-y-3">
@foreach ($check['webhooks'] as $hook)
<li class="rounded-lg border border-line bg-surface-2 px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-mono text-xs text-body">{{ $hook['url'] }}</span>
<span class="text-xs {{ $hook['status'] === 'enabled' ? 'text-success' : 'text-warning' }}">{{ $hook['status'] }}</span>
</div>
<div class="mt-2 flex flex-wrap gap-1.5">
@foreach ($hook['events'] as $event)
<span class="rounded border border-line px-1.5 py-0.5 font-mono text-[11px] text-muted">{{ $event }}</span>
@endforeach
</div>
</li>
@endforeach
</ul>
@endif
@endif
</div>
@endif
</div>

View File

@ -40,8 +40,15 @@ Route::get('/vpn', Admin\Vpn::class)->name('vpn');
Route::get('/revenue', Admin\Revenue::class)->name('revenue');
Route::get('/finance', Admin\Finance::class)->name('finance');
Route::get('/mail', Admin\Mail::class)->name('mail');
Route::get('/secrets', Admin\Secrets::class)->name('secrets');
Route::get('/infrastructure', Admin\Infrastructure::class)->name('infrastructure');
Route::get('/integrations', Admin\Integrations::class)->name('integrations');
// The former admin.secrets and admin.infrastructure pages, merged into the
// one above — grouped by what each value configures, not by which of the two
// mechanisms (SecretVault vs App\Support\Settings) happens to hold it. Kept
// as redirects, permanently, so a bookmark or a link out in the wild does not
// 404 — the page has genuinely moved, the same reasoning legal.status already
// uses in routes/web.php.
Route::get('/secrets', fn () => redirect()->route('admin.integrations', status: 301))->name('secrets');
Route::get('/infrastructure', fn () => redirect()->route('admin.integrations', status: 301))->name('infrastructure');
Route::get('/settings', Admin\Settings::class)->name('settings');
// Its own route so RequireOperatorTwoFactor can exempt ENROLMENT without
// exempting the rest of admin.settings — see that middleware for why.

View File

@ -0,0 +1,277 @@
<?php
use App\Livewire\Admin\ConfirmForgetSecret;
use App\Livewire\Admin\ConfirmSaveSecret;
use App\Livewire\Admin\Integrations;
use App\Services\Secrets\SecretVault;
use App\Support\ProvisioningSettings;
use Livewire\Livewire;
/**
* Admin\Integrations merges the former admin.secrets and admin.infrastructure
* pages into one, grouped by what a field configures rather than by which of
* the two storage mechanisms holds it. This file replaces SecretsPageTest and
* InfrastructureSettingsTest the underlying mechanisms (SecretVault,
* App\Support\Settings) are unchanged, only where they are displayed, so most
* of their coverage is ported here unchanged in substance.
*
* New in this file: the two capabilities are independently checked (an
* operator with only one of them sees only the matching half), and the
* redirects off the two retired routes. The raw .env editor gets its own
* coverage once it lands on this page.
*/
beforeEach(function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
});
// ---- Reachability: either capability opens the page, neither 403s it. ----
it('is not reachable with neither hosts.manage nor secrets.manage', function () {
Livewire::actingAs(operator('Support'), 'operator')
->test(Integrations::class)
->assertForbidden();
});
it('shows the settings sections but not the vault sections to an operator with only hosts.manage', function () {
Livewire::actingAs(operator('Admin'), 'operator')
->test(Integrations::class)
->assertOk()
->assertViewHas('canInfra', true)
->assertViewHas('canSecrets', false)
->assertSee(__('integrations.dns_zone'))
->assertDontSee(__('secrets.item.stripe_secret'));
});
it('shows the vault sections but not the settings sections to an operator with only secrets.manage', function () {
$operator = operator('Support');
$operator->givePermissionTo('secrets.manage');
Livewire::actingAs($operator, 'operator')
->test(Integrations::class)
->assertOk()
->assertViewHas('canInfra', false)
->assertViewHas('canSecrets', true)
->assertDontSee(__('integrations.dns_zone'))
->assertSee(__('secrets.item.stripe_secret'));
});
it('shows both halves to the Owner, who has both capabilities', function () {
Livewire::actingAs(operator('Owner'), 'operator')
->test(Integrations::class)
->assertOk()
->assertSee(__('integrations.dns_zone'))
->assertSee(__('secrets.item.stripe_secret'));
});
// ---- The retired pages redirect rather than 404 a bookmark. ----
it('redirects the retired /admin/secrets route to /admin/integrations', function () {
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.secrets'))
->assertRedirect(route('admin.integrations'))
->assertStatus(301);
});
it('redirects the retired /admin/infrastructure route to /admin/integrations', function () {
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.infrastructure'))
->assertRedirect(route('admin.integrations'))
->assertStatus(301);
});
// ---- Vault entries: ported from the former SecretsPageTest. ----
it('shows the no-key banner for a malformed SECRETS_KEY, not only a missing one', function () {
config()->set('admin_access.secrets_key', 'zu-kurz');
Livewire::actingAs(operator('Owner'), 'operator')
->test(Integrations::class)
->assertSee(__('secrets.no_key'));
});
it('shows nothing until the password is confirmed', function () {
$owner = operator('Owner');
app(SecretVault::class)->put('stripe.secret', 'sk_live_ABCDEFGH9999', $owner);
Livewire::actingAs($owner, 'operator')->test(Integrations::class)
->assertSee(__('secrets.locked_title'))
->assertDontSee('9999')
->assertViewHas('unlocked', false);
});
it('refuses every vault action while locked, not merely hiding the buttons', function () {
$owner = operator('Owner');
foreach ([['save', 'stripe.secret'], ['forget', 'stripe.secret'], ['test', 'stripe.secret']] as [$action, $arg]) {
Livewire::actingAs($owner, 'operator')
->test(Integrations::class)
->call($action, $arg)
->assertForbidden();
}
});
it('does not let an operator with only hosts.manage touch a vault action', function () {
Livewire::actingAs(operator('Admin'), 'operator')
->test(Integrations::class)
->call('save', 'stripe.secret')
->assertForbidden();
});
it('unlocks with the password, then stores and clears the entered value', function () {
$owner = operator('Owner');
$page = Livewire::actingAs($owner, 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->assertHasNoErrors()
->assertViewHas('unlocked', true);
$page->set('entered.stripe_secret', 'sk_live_NEWKEY1234')
->call('save', 'stripe.secret')
->assertHasNoErrors();
expect(app(SecretVault::class)->get('stripe.secret'))->toBe('sk_live_NEWKEY1234')
->and($page->get('entered')['stripe_secret'])->toBe('');
});
it('never puts a stored secret into the component snapshot', function () {
$owner = operator('Owner');
app(SecretVault::class)->put('stripe.secret', 'sk_live_TOPSECRET42', $owner);
$page = Livewire::actingAs($owner, 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'password')
->call('confirmPassword');
expect(json_encode($page->snapshot))->not->toContain('sk_live_TOPSECRET42');
});
it('can be locked again without signing out', function () {
$owner = operator('Owner');
Livewire::actingAs($owner, 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->assertViewHas('unlocked', true)
->call('forgetPasswordConfirmation')
->assertViewHas('unlocked', false);
});
it('does not open the save/forget confirmation without the capability', function () {
Livewire::actingAs(operator('Admin'), 'operator')
->test(ConfirmSaveSecret::class, ['key' => 'stripe.secret'])
->assertForbidden();
Livewire::actingAs(operator('Admin'), 'operator')
->test(ConfirmForgetSecret::class, ['key' => 'stripe.secret'])
->assertForbidden();
});
it('confirms a save through the modal without storing anything itself', function () {
$owner = operator('Owner');
Livewire::actingAs($owner, 'operator')
->test(ConfirmSaveSecret::class, ['key' => 'stripe.secret'])
->assertSee(__('secrets.item.stripe_secret'))
->call('confirm')
->assertDispatched('secret-save-confirmed', key: 'stripe.secret');
expect(app(SecretVault::class)->has('stripe.secret'))->toBeFalse();
});
it('saves the typed value once the page receives the confirmed event', function () {
$owner = operator('Owner');
Livewire::actingAs($owner, 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->set('entered.stripe_secret', 'sk_live_VIACONFIRM1')
->call('onSaveConfirmed', 'stripe.secret')
->assertHasNoErrors();
expect(app(SecretVault::class)->get('stripe.secret'))->toBe('sk_live_VIACONFIRM1');
});
it('forgets the stored value once the page receives the confirmed event', function () {
$owner = operator('Owner');
app(SecretVault::class)->put('stripe.secret', 'sk_live_TOFORGET999', $owner);
Livewire::actingAs($owner, 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->call('onForgetConfirmed', 'stripe.secret');
expect(app(SecretVault::class)->has('stripe.secret'))->toBeFalse();
});
it('stores the SSH identity with its internal newlines intact', function () {
$owner = operator('Owner');
$pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nabcdef\nghijkl\n-----END OPENSSH PRIVATE KEY-----";
Livewire::actingAs($owner, 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->set('entered.ssh_private_key', $pem)
->call('save', 'ssh.private_key')
->assertHasNoErrors();
expect(app(SecretVault::class)->get('ssh.private_key'))->toBe($pem);
});
it('binds the form to a key Livewire can actually write to', function () {
expect(Integrations::field('stripe.secret'))->toBe('stripe_secret')
->and(str_contains(Integrations::field('stripe.secret'), '.'))->toBeFalse();
});
// ---- Plain settings: ported from the former InfrastructureSettingsTest. ----
it('loads the .env-derived value when nothing has been saved from the console yet', function () {
config()->set('provisioning.dns.zone', 'env-zone.example');
Livewire::actingAs(operator('Owner'), 'operator')
->test(Integrations::class)
->assertSet('dnsZone', 'env-zone.example');
});
it('saves every settings field, and the consumer reads the saved value back', function () {
Livewire::actingAs(operator('Owner'), 'operator')
->test(Integrations::class)
->set('dnsZone', 'console-zone.example')
->set('wgEndpoint', 'vpn.example:51820')
->set('wgHubPubkey', 'hubpubkey==')
->set('traefikDynamicPath', '/etc/traefik/dynamic')
->set('sshPublicKey', 'ssh-ed25519 AAAAC3 clupilot')
->set('monitoringUrl', 'http://kuma-bridge:8080')
->call('saveInfra')
->assertHasNoErrors();
expect(ProvisioningSettings::dnsZone())->toBe('console-zone.example')
->and(ProvisioningSettings::wgEndpoint())->toBe('vpn.example:51820')
->and(ProvisioningSettings::wgHubPublicKey())->toBe('hubpubkey==')
->and(ProvisioningSettings::traefikDynamicPath())->toBe('/etc/traefik/dynamic')
->and(ProvisioningSettings::sshPublicKey())->toBe('ssh-ed25519 AAAAC3 clupilot')
->and(ProvisioningSettings::monitoringUrl())->toBe('http://kuma-bridge:8080');
});
it('refuses a monitoring URL that is not a URL', function () {
Livewire::actingAs(operator('Owner'), 'operator')
->test(Integrations::class)
->set('monitoringUrl', 'not a url')
->call('saveInfra')
->assertHasErrors('monitoringUrl');
});
it('does not let an operator with only secrets.manage save the settings half', function () {
$operator = operator('Support');
$operator->givePermissionTo('secrets.manage');
Livewire::actingAs($operator, 'operator')
->test(Integrations::class)
->call('saveInfra')
->assertForbidden();
});

View File

@ -1,6 +1,6 @@
<?php
use App\Livewire\Admin\Secrets;
use App\Livewire\Admin\Integrations;
use App\Livewire\Settings;
use App\Models\Operator;
use App\Models\User;
@ -12,7 +12,7 @@ beforeEach(fn () => config()->set('admin_access.secrets_key', 'base64:'.base64_e
it('confirms a console password against the operator record, not a portal user', function () {
$operator = Operator::factory()->role('Owner')->create(['password' => 'operator-passwort']);
Livewire::actingAs($operator, 'operator')->test(Secrets::class)
Livewire::actingAs($operator, 'operator')->test(Integrations::class)
->set('confirmablePassword', 'operator-passwort')
->call('confirmPassword')
->assertHasNoErrors();
@ -26,7 +26,7 @@ it('rejects a password that belongs to a portal user with the same address', fun
]);
User::factory()->create(['email' => 'doppelt@clupilot.test', 'password' => 'kunden-passwort']);
Livewire::actingAs($operator, 'operator')->test(Secrets::class)
Livewire::actingAs($operator, 'operator')->test(Integrations::class)
->set('confirmablePassword', 'kunden-passwort')
->call('confirmPassword')
->assertHasErrors('confirmablePassword');
@ -54,7 +54,7 @@ it('resolves the operator explicitly by guard, not by whichever guard happens to
//
// Signing in with Auth::guard('operator')->login() alone (no actingAs)
// does NOT reach this test: it proves confirmationGuard is genuinely
// consulted, but Secrets::mount()'s own $this->authorize('secrets.manage')
// consulted, but Integrations::mount()'s own $this->authorize('secrets.manage')
// ALSO reads the ambient default guard (Laravel's Gate resolves the
// current user the same bare way) — so mount() itself 403s before
// confirmPassword() is ever reached, on the very account this test means
@ -75,7 +75,7 @@ it('resolves the operator explicitly by guard, not by whichever guard happens to
// web guard, default or not.
$operator = Operator::factory()->role('Owner')->create(['password' => 'operator-passwort']);
$page = Livewire::actingAs($operator, 'operator')->test(Secrets::class);
$page = Livewire::actingAs($operator, 'operator')->test(Integrations::class);
Auth::shouldUse('web');
@ -96,8 +96,8 @@ it("does not let a portal customer's password confirmation satisfy the console's
// shared-host mode (this suite's default, and this VM's own mode) the
// portal and console guards keep their login key in the SAME session, so
// signing into both and confirming the CUSTOMER's password on the portal
// settings page left the marker in place for the OPERATOR-only Secrets
// page too. This is the escalation itself, not a proxy for it.
// settings page left the marker in place for the OPERATOR-only
// Integrations page too. This is the escalation itself, not a proxy for it.
$operator = Operator::factory()->role('Owner')->create(['password' => 'operator-passwort']);
$customer = User::factory()->create(['password' => 'kunden-passwort']);
@ -120,9 +120,9 @@ it("does not let a portal customer's password confirmation satisfy the console's
// Walk into the console as the OPERATOR with that confirmation still
// sitting in the session. actingAs() only sets the AMBIENT default guard
// Gate reads for Secrets::mount()'s authorize() call — it does not touch
// Gate reads for Integrations::mount()'s authorize() call — it does not touch
// the 'web' guard's own session entry set above, so this is the real
// shape of the attack: one browser, two identities, one session.
Livewire::actingAs($operator, 'operator')->test(Secrets::class)
Livewire::actingAs($operator, 'operator')->test(Integrations::class)
->assertViewHas('unlocked', false);
});