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

166 lines
9.6 KiB
PHP

{{-- Polling pauses while a config is on screen: every poll re-renders this
page, and that would put the private key into an HTTP response every five
seconds. Traffic figures can wait the minute someone needs to copy a key. --}}
<div class="space-y-5" @if (! $newConfig) wire:poll.5s="refreshPeers" @endif>
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('vpn.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('vpn.subtitle') }}</p>
</div>
@if ($hubEndpoint === '' || $hubPublicKey === '')
<div class="flex items-start gap-3 rounded-lg border border-warning-border bg-warning-bg p-4 animate-rise">
<x-ui.icon name="alert-triangle" class="mt-0.5 size-5 shrink-0 text-warning" />
<div class="text-sm">
<p class="font-medium text-ink">{{ __('vpn.hub_incomplete_title') }}</p>
<p class="mt-0.5 text-muted">{{ __('vpn.hub_incomplete_body') }}</p>
</div>
</div>
@endif
{{-- Shown once. The private key exists only in this response; a reload loses it. --}}
@if ($newConfig)
<div class="rounded-lg border border-accent-border bg-accent-bg p-5 animate-rise">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="font-semibold text-ink">{{ __('vpn.config_ready', ['name' => $newConfigName]) }}</h2>
<p class="mt-0.5 text-sm text-muted">{{ __('vpn.config_once') }}</p>
</div>
<button type="button" wire:click="dismissConfig" aria-label="{{ __('common.close') }}"
class="grid size-8 shrink-0 place-items-center rounded-md border border-line text-muted hover:text-ink">
<x-ui.icon name="x" class="size-4" />
</button>
</div>
<div class="mt-3" x-data="vpnConfigActions(@js($this->configFilename()))">
<pre x-ref="config" class="max-h-64 overflow-auto rounded-lg border border-line bg-surface p-4 font-mono text-xs leading-relaxed text-body">{{ $newConfig }}</pre>
<div class="mt-3 flex flex-wrap items-center gap-2">
<x-ui.button variant="primary" x-on:click="download()">
<x-ui.icon name="download" class="size-4" />{{ __('vpn.download') }}
</x-ui.button>
<x-ui.button variant="secondary" x-on:click="copy()">
<x-ui.icon name="copy" class="size-4" />
<span x-text="copied ? @js(__('vpn.copied')) : @js(__('vpn.copy'))"></span>
</x-ui.button>
<x-ui.button variant="secondary" wire:click="toggleQr" wire:loading.attr="disabled">
<x-ui.icon name="qr-code" class="size-4" />
{{ $showQr ? __('vpn.qr_hide') : __('vpn.qr_show') }}
</x-ui.button>
{{-- Only surfaces when copying is impossible (no clipboard API
outside a secure context) silence would look like a dead button. --}}
<p x-show="failed" x-cloak class="text-xs text-danger">{{ __('vpn.copy_failed') }}</p>
</div>
@if ($qrSvg)
<div class="mt-4 flex flex-col items-center gap-2 rounded-lg border border-line bg-surface p-4">
<div class="[&>svg]:rounded [&>svg]:shadow-xs">{!! $qrSvg !!}</div>
<p class="text-xs text-muted">{{ __('vpn.qr_hint') }}</p>
</div>
@endif
</div>
</div>
@endif
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
{{-- Peer list --}}
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex items-center justify-between border-b border-line px-4 py-3">
<h2 class="text-sm font-semibold text-ink">{{ __('vpn.peers') }}</h2>
<span class="text-xs text-muted">
{{ $lastSync ? __('vpn.last_sync', ['time' => \Illuminate\Support\Carbon::parse($lastSync)->diffForHumans()]) : __('vpn.never_synced') }}
</span>
</div>
@if ($peers->isEmpty())
<p class="p-8 text-center text-sm text-muted">{{ __('vpn.empty') }}</p>
@else
{{-- Zwei Gruppen, nicht eine Liste.
Personen und Hosts standen gemischt untereinander, nach
Verbindungszustand sortiert ein Host-Zugang konnte also
zwischen zwei Mitarbeitern stehen. Beide sind Peers im
selben Netz und haben dieselben Messwerte, aber es sind
zwei verschiedene Fragen: „wer von uns ist im Netz" und
„welche Maschinen hängen dran". Wer die eine stellt, liest
die Antworten der anderen als Rauschen.
Eine Gruppe ohne Einträge wird gar nicht erst gezeichnet:
eine leere Überschrift ist eine Aussage über nichts. --}}
@foreach ([
['title' => __('vpn.group_people'), 'peers' => $peopleAccesses],
['title' => __('vpn.group_hosts'), 'peers' => $hostAccesses],
] as $group)
@continue($group['peers']->isEmpty())
<div class="flex items-baseline justify-between gap-3 border-b border-line bg-surface-2 px-4 py-2">
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">{{ $group['title'] }}</h3>
<span class="font-mono text-xs text-faint">{{ $group['peers']->count() }}</span>
</div>
<ul class="divide-y divide-line">
@foreach ($group['peers'] as $peer)
<x-admin.vpn-peer-row :peer="$peer" />
@endforeach
</ul>
@endforeach
@endif
</div>
<div class="space-y-5">
{{-- New access --}}
@if ($canManage)
<form wire:submit="create" class="space-y-4 rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('vpn.add') }}</h2>
<x-ui.input name="name" wire:model="name" :label="__('vpn.name')" placeholder="Notebook Boban" />
<div>
<label class="text-sm font-medium text-body" for="vpn-owner">{{ __('vpn.owner') }}</label>
<select id="vpn-owner" wire:model="ownerId" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
@foreach ($operators as $operator)
<option value="{{ $operator->id }}">{{ $operator->name }} ({{ $operator->email }})</option>
@endforeach
</select>
<p class="mt-1 text-xs text-muted">{{ __('vpn.owner_hint') }}</p>
@error('ownerId') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
</div>
<x-ui.input name="publicKey" wire:model="publicKey" :label="__('vpn.own_key')" :hint="__('vpn.own_key_hint')" placeholder="{{ __('vpn.own_key_placeholder') }}" />
<div>
<label class="flex items-start gap-2 text-sm text-body">
<input type="checkbox" wire:model="storeConfig" @disabled(! $vaultAvailable)
class="mt-0.5 size-4 rounded border-line-strong text-accent focus:ring-accent">
<span>
{{ __('vpn.store_config') }}
<span class="mt-0.5 block text-xs text-muted">
{{ $vaultAvailable ? __('vpn.store_config_hint') : __('vpn.vault_unavailable') }}
</span>
</span>
</label>
@error('storeConfig') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
</div>
<x-ui.button variant="primary" type="submit" class="w-full">
<x-ui.icon name="plus" class="size-4" />{{ __('vpn.add') }}
</x-ui.button>
</form>
@endif
{{-- Hub facts an operator needs when configuring a peer by hand --}}
<div class="space-y-3 rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
<h2 class="font-semibold text-ink">{{ __('vpn.hub') }}</h2>
<div>
<div class="text-xs font-semibold text-muted">{{ __('vpn.endpoint') }}</div>
<div class="mt-0.5 break-all font-mono text-xs text-body">{{ $hubEndpoint ?: '—' }}</div>
</div>
<div>
<div class="text-xs font-semibold text-muted">{{ __('vpn.hub_key') }}</div>
<div class="mt-0.5 break-all font-mono text-xs text-body">{{ $hubPublicKey ?: '—' }}</div>
</div>
<div>
<div class="text-xs font-semibold text-muted">{{ __('vpn.subnet') }}</div>
<div class="mt-0.5 font-mono text-xs text-body">{{ config('provisioning.wireguard.subnet') }}</div>
</div>
</div>
</div>
</div>
</div>