252 lines
17 KiB
PHP
252 lines
17 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
|
|
{{--
|
|
A register of accesses, not a spreadsheet.
|
|
|
|
Seven columns in a fixed table left every one of them too
|
|
narrow: names broke across two lines, "Letzter Kontakt"
|
|
wrapped in its own heading, and the row still did not fit.
|
|
These are attributes of ONE access, not quantities anyone
|
|
compares down a column — so each access gets a row of its
|
|
own with the identity on the first line and the measurements
|
|
on the second, in mono where they line up by themselves.
|
|
--}}
|
|
<ul class="divide-y divide-line">
|
|
@foreach ($peers as $peer)
|
|
@php
|
|
$status = $peer->status();
|
|
$tone = [
|
|
'online' => 'border-success-border bg-success-bg text-success',
|
|
'idle' => 'border-line-strong bg-surface-2 text-muted',
|
|
'blocked' => 'border-danger-border bg-danger-bg text-danger',
|
|
'pending' => 'border-warning-border bg-warning-bg text-warning',
|
|
][$status];
|
|
// The rule carries the status too, so a blocked access is
|
|
// visible while scanning without reading a single pill.
|
|
$rule = [
|
|
'online' => 'bg-success-bright',
|
|
'idle' => 'bg-line-strong',
|
|
'blocked' => 'bg-danger',
|
|
'pending' => 'bg-warning',
|
|
][$status];
|
|
@endphp
|
|
<li wire:key="peer-{{ $peer->uuid }}" class="relative flex flex-wrap items-start gap-x-4 gap-y-3 py-4 pl-6 pr-4 transition hover:bg-surface-hover">
|
|
<span class="absolute left-0 top-4 bottom-4 w-0.5 rounded-pill {{ $rule }}" aria-hidden="true"></span>
|
|
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
|
<span class="font-medium text-ink">{{ $peer->name }}</span>
|
|
|
|
<span class="inline-flex items-center gap-1 rounded-pill border border-line px-2 py-0.5 text-[11px] text-muted">
|
|
<x-ui.icon :name="$peer->host ? 'server' : 'users'" class="size-3" />
|
|
{{ $peer->host ? __('vpn.host_peer') : __('vpn.operator_peer') }}
|
|
</span>
|
|
|
|
@if ($peer->owner)
|
|
<span class="text-sm text-muted">{{ $peer->owner->name }}</span>
|
|
@if ($peer->user_id === auth()->id())
|
|
<span class="rounded-pill border border-accent-border bg-accent-subtle px-1.5 py-0.5 text-[10px] font-medium text-accent-text">{{ __('vpn.you') }}</span>
|
|
@endif
|
|
@elseif (! $peer->host)
|
|
<span class="text-sm text-muted">{{ __('vpn.no_owner') }}</span>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- The measurements, each with its own label: without a
|
|
header row they have to name themselves. --}}
|
|
<div class="mt-1.5 flex flex-wrap items-center gap-x-5 gap-y-1 font-mono text-xs text-muted">
|
|
<span class="text-body">{{ $peer->allowed_ip }}</span>
|
|
@if ($peer->endpoint)
|
|
<span class="text-muted">{{ $peer->endpoint }}</span>
|
|
@endif
|
|
<span class="whitespace-nowrap">↓ {{ \App\Support\Bytes::human($peer->rx_bytes) }} ↑ {{ \App\Support\Bytes::human($peer->tx_bytes) }}</span>
|
|
<span class="whitespace-nowrap">{{ __('vpn.handshake') }}: {{ $peer->last_handshake_at?->diffForHumans() ?? __('vpn.never') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex shrink-0 items-center gap-2">
|
|
<span class="inline-flex items-center gap-1.5 whitespace-nowrap rounded-pill border px-2.5 py-0.5 text-xs font-medium {{ $tone }}">
|
|
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
|
{{ __('vpn.status_'.$status) }}
|
|
</span>
|
|
|
|
<div class="inline-flex gap-1">
|
|
{{-- A stored config can be fetched again; without one there is
|
|
nothing to hand out, and saying so beats an absent button. --}}
|
|
@if (! $peer->hasStoredConfig() && $peer->kind === \App\Models\VpnPeer::KIND_STAFF && $peer->user_id === auth()->id())
|
|
<span title="{{ __('vpn.no_stored_config') }}"
|
|
class="grid size-8 cursor-help place-items-center rounded-md border border-dashed border-line text-muted">
|
|
<x-ui.icon name="download" class="size-4" />
|
|
</span>
|
|
@endif
|
|
@can('downloadConfig', $peer)
|
|
<button type="button" aria-label="{{ __('vpn.get_config') }}" title="{{ __('vpn.get_config') }}"
|
|
x-on:click="$dispatch('openModal', { component: 'admin.vpn-config-access', arguments: { uuid: '{{ $peer->uuid }}' } })"
|
|
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-accent-border hover:text-accent-text">
|
|
<x-ui.icon name="download" class="size-4" />
|
|
</button>
|
|
@endcan
|
|
@can('block', $peer)
|
|
<button type="button" wire:click="toggle('{{ $peer->uuid }}')"
|
|
aria-label="{{ $peer->enabled ? __('vpn.block') : __('vpn.unblock') }}" title="{{ $peer->enabled ? __('vpn.block') : __('vpn.unblock') }}"
|
|
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-warning-border hover:text-warning">
|
|
<x-ui.icon :name="$peer->enabled ? 'lock' : 'unlock'" class="size-4" />
|
|
</button>
|
|
@endcan
|
|
@can('update', $peer)
|
|
@if ($peer->kind === \App\Models\VpnPeer::KIND_STAFF)
|
|
<button type="button"
|
|
x-on:click="$dispatch('openModal', { component: 'admin.confirm-reissue-vpn-peer', arguments: { uuid: '{{ $peer->uuid }}' } })"
|
|
title="{{ __('vpn.reissue') }}" aria-label="{{ __('vpn.reissue') }}"
|
|
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-accent-border hover:text-accent-text">
|
|
<x-ui.icon name="refresh" class="size-4" />
|
|
</button>
|
|
@endif
|
|
@endcan
|
|
@can('delete', $peer)
|
|
<button type="button" aria-label="{{ __('vpn.delete') }}" title="{{ __('vpn.delete') }}"
|
|
x-on:click="$dispatch('openModal', { component: 'admin.confirm-delete-vpn-peer', arguments: { uuid: '{{ $peer->uuid }}' } })"
|
|
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-danger hover:text-danger">
|
|
<x-ui.icon name="trash-2" class="size-4" />
|
|
</button>
|
|
@endcan
|
|
</div>
|
|
</div>
|
|
</li>
|
|
@endforeach
|
|
</ul>
|
|
@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>
|