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
parent
835f3eeb64
commit
3fadaa14b0
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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] ?? '';
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue