CluPilotCloud/resources/views/livewire/admin/integrations.blade.php

342 lines
20 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
{{-- Das Support-Postfach IMAP, nicht die mailcow-API: die API verwaltet
Domains und Postfächer, gelesen wird ein Postfach über IMAP, egal wer
den Server betreibt. --}}
@if ($canSecrets || $canInfra)
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:45ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('integrations.inbound_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('integrations.inbound_body') }}</p>
</div>
@if ($canInfra)
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<x-ui.input name="inboundHost" wire:model="inboundHost" :label="__('integrations.inbound_host')" placeholder="mail.clupilot.com" />
<x-ui.input name="inboundPort" wire:model="inboundPort" :label="__('integrations.inbound_port')" :hint="__('integrations.inbound_port_hint')" inputmode="numeric" />
<x-ui.input name="inboundUsername" wire:model="inboundUsername" :label="__('integrations.inbound_username')" placeholder="support@clupilot.com" />
<x-ui.input name="inboundFolder" wire:model="inboundFolder" :label="__('integrations.inbound_folder')" />
</div>
@endif
@if ($canSecrets)
@if ($canInfra)<hr class="border-line" />@endif
<x-admin.secret-field :entry="$entries['inbound_mail.password']" :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>