316 lines
18 KiB
PHP
316 lines
18 KiB
PHP
<div class="mx-auto max-w-[1120px] space-y-6">
|
|
<div class="animate-rise">
|
|
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('integrations.title') }}</h1>
|
|
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('integrations.subtitle') }}</p>
|
|
</div>
|
|
|
|
{{-- ── The tabs ──────────────────────────────────────────────────────
|
|
This was one narrow column of six cards: an operator connecting DNS
|
|
scrolled past Stripe to reach it, and the raw .env editor sat at the
|
|
bottom of everything. Split by what a section is FOR — services we buy
|
|
from outside, our own machines, the file underneath — which is the same
|
|
axis this page was rebuilt around when it stopped being split by
|
|
storage mechanism.
|
|
|
|
The tab lives in the query string, so a reload or a bookmark lands
|
|
where the operator was. Unlocking survives a switch: the password
|
|
confirmation is a session fact, not a property of a tab. --}}
|
|
<div class="flex flex-wrap items-center gap-x-1 gap-y-2 border-b border-line animate-rise [animation-delay:40ms]" role="tablist">
|
|
@foreach ($tabs as $name)
|
|
<button type="button" role="tab" wire:click="$set('tab', '{{ $name }}')"
|
|
aria-selected="{{ $tab === $name ? 'true' : 'false' }}"
|
|
@class([
|
|
'flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors -mb-px',
|
|
'border-accent-active text-ink' => $tab === $name,
|
|
'border-transparent text-muted hover:text-ink' => $tab !== $name,
|
|
])>
|
|
<x-ui.icon :name="['services' => 'plug', 'platform' => 'server', 'env' => 'file-text'][$name] ?? 'settings'"
|
|
class="size-4" />{{ __('integrations.tab.'.$name) }}
|
|
</button>
|
|
@endforeach
|
|
</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, and the .env editor further down. 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
|
|
|
|
@if ($tab === 'services')
|
|
{{-- Two columns from lg up. Each card is a form of two or three fields —
|
|
stacked, they left half the window empty and pushed monitoring below
|
|
the fold. --}}
|
|
<div class="grid gap-5 lg:grid-cols-2 lg:items-start">
|
|
{{-- 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
|
|
|
|
</div>
|
|
@endif
|
|
|
|
@if ($tab === 'platform')
|
|
<div class="grid gap-5 lg:grid-cols-2 lg:items-start">
|
|
{{-- 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
|
|
|
|
</div>
|
|
@endif
|
|
|
|
{{-- One button for both tabs' plain settings. Livewire keeps the values of
|
|
a hidden field, so saving from either tab writes everything the
|
|
operator has typed — which is what they mean by "speichern". --}}
|
|
@if ($canInfra && in_array($tab, ['services', 'platform'], true))
|
|
<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
|
|
|
|
{{-- Stripe's own answer, under the Stripe card it belongs to. --}}
|
|
@if ($tab === 'services' && $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
|
|
|
|
{{-- Part B: the raw .env editor. Everything above has a field; everything
|
|
that does not still needs to be reachable from the console, or an
|
|
operator needing it needs a shell and this whole page misses the
|
|
point. Shares the lock above: secrets.manage AND a confirmed
|
|
password, because it is the one place on this page that can reach
|
|
every credential the vault otherwise keeps write-only. --}}
|
|
@if ($tab === 'env' && $canSecrets)
|
|
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:150ms]">
|
|
<div>
|
|
<h2 class="font-semibold text-ink">{{ __('integrations.env_title') }}</h2>
|
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.env_subtitle') }}</p>
|
|
</div>
|
|
|
|
@if (! $unlocked)
|
|
<p class="flex items-center gap-1.5 text-sm text-muted">
|
|
<x-ui.icon name="lock" class="size-4" />
|
|
{{ __('integrations.env_locked') }}
|
|
</p>
|
|
@else
|
|
<x-ui.alert variant="danger">
|
|
<p class="font-semibold">{{ __('integrations.env_danger_title') }}</p>
|
|
<p class="mt-1">{{ __('integrations.env_danger_body') }}</p>
|
|
</x-ui.alert>
|
|
|
|
<div class="rounded-lg border border-line bg-surface-2 px-4 py-3">
|
|
<p class="text-sm font-semibold text-ink">{{ __('integrations.env_vault_title') }}</p>
|
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.env_vault_body') }}</p>
|
|
<ul class="mt-3 space-y-1.5">
|
|
@foreach ($entries as $entry)
|
|
<li class="flex flex-wrap items-center justify-between gap-2 text-sm">
|
|
<span class="font-mono text-xs text-body">{{ $entry['envKey'] }}</span>
|
|
<span class="text-xs {{ $entry['source'] === 'stored' ? 'font-medium text-warning' : 'text-muted' }}">
|
|
{{ $entry['source'] === 'stored' ? __('integrations.env_vault_active') : __('integrations.env_vault_inactive') }}
|
|
</span>
|
|
</li>
|
|
@endforeach
|
|
</ul>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="envContent" class="block text-sm font-medium text-body">{{ __('integrations.env_label') }}</label>
|
|
<textarea id="envContent" name="envContent" rows="18" autocomplete="off" spellcheck="false"
|
|
wire:model="envContent"
|
|
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"></textarea>
|
|
@error('envContent')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
|
|
</div>
|
|
|
|
<div class="flex justify-end">
|
|
<x-ui.button variant="primary"
|
|
x-on:click="$dispatch('openModal', { component: 'admin.confirm-save-env' })">
|
|
{{ __('integrations.env_save') }}
|
|
</x-ui.button>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
<div class="rounded-lg border border-line bg-surface-2 px-4 py-3">
|
|
<p class="text-sm font-semibold text-ink">{{ __('integrations.env_backup_title') }}</p>
|
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.env_backup_body') }}</p>
|
|
</div>
|
|
{{-- What saving now does automatically (config:clear here,
|
|
the restart via UpdateChannel) — not homework anymore,
|
|
see App\Livewire\Admin\Integrations::saveEnv(). The
|
|
spinner below is this component's own wire:poll, not
|
|
the full-screen deployment overlay: a restart of four
|
|
containers is seconds, not minutes, and `app` — the
|
|
container answering this very poll — is never among
|
|
them, so this page never goes offline for it. --}}
|
|
<div class="rounded-lg border border-line bg-surface-2 px-4 py-3">
|
|
<p class="text-sm font-semibold text-ink">{{ __('integrations.env_restart_title') }}</p>
|
|
<p class="mt-1 text-sm text-muted">{{ __('integrations.env_restart_body') }}</p>
|
|
|
|
@if ($envRestartWatching || $restart['restarting'])
|
|
<p class="mt-2 flex items-center gap-1.5 text-xs font-medium text-accent-text" wire:poll.2s>
|
|
<x-ui.icon name="refresh" class="size-4 animate-spin" />
|
|
{{ __('integrations.env_restarting') }}
|
|
</p>
|
|
@elseif (! $restart['agent_seen'])
|
|
{{-- Honest, not silent: told plainly, with the
|
|
fallback right here — same shape as the update
|
|
card's own admin_settings.update_no_agent. --}}
|
|
<x-ui.alert variant="warning" class="mt-2">{{ __('admin_settings.update_no_agent') }}</x-ui.alert>
|
|
<pre class="mt-2 overflow-x-auto rounded border border-line bg-surface px-3 py-2 font-mono text-[11px] text-body">docker compose restart queue queue-provisioning scheduler reverb</pre>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|