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

362 lines
24 KiB
PHP

<div class="mx-auto max-w-3xl space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin_settings.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.subtitle') }}</p>
</div>
{{-- Version & update --}}
@if ($canManageSite)
{{-- Polls itself: an operator watching an update run should not have to
reload the page to find out whether it finished. Fast while
something is happening, slow when nothing is. --}}
<div class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise"
wire:poll.{{ $update['running'] ? '3s' : '30s' }}>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h2 class="font-semibold text-ink">{{ __('admin_settings.update_title') }}</h2>
@if ($update['running'])
<span class="inline-flex items-center gap-1.5 rounded-pill border border-info-border bg-info-bg px-2.5 py-0.5 text-xs font-medium text-info">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_running') }}
</span>
@elseif ($update['available'])
<span class="inline-flex items-center gap-1.5 rounded-pill border border-accent-border bg-accent-subtle px-2.5 py-0.5 text-xs font-medium text-accent-text">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_available', ['n' => $update['behind']]) }}
</span>
@elseif ($update['behind'] === 0)
<span class="inline-flex items-center gap-1.5 rounded-pill border border-success-border bg-success-bg px-2.5 py-0.5 text-xs font-medium text-success">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_current') }}
</span>
@else
{{-- Never shown as "up to date": not knowing and being
current are different answers. --}}
<span class="inline-flex items-center gap-1.5 rounded-pill border border-line-strong bg-surface-2 px-2.5 py-0.5 text-xs font-medium text-muted">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>{{ __('admin_settings.update_unknown') }}
</span>
@endif
</div>
<dl class="mt-3 space-y-1 text-sm">
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_version') }}:</dt>
<dd class="font-mono text-body">{{ $update['version'] }}@if ($update['commit']) · {{ substr($update['commit'], 0, 7) }}@endif</dd></div>
@if ($update['source'])
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_source') }}:</dt>
<dd class="font-mono text-body">{{ $update['source'] }}</dd></div>
@endif
@if ($update['deployed_at'])
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_deployed') }}:</dt>
<dd class="text-body">{{ $update['deployed_at']->diffForHumans() }}</dd></div>
@endif
@if ($update['checked_at'])
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.update_checked') }}:</dt>
<dd class="text-body">{{ $update['checked_at']->diffForHumans() }}</dd></div>
@endif
</dl>
</div>
{{-- Offered only when there is something to install. Bound, not
@disabled(): the directive compiles to inline PHP inside the
component tag, and Blade then stops seeing a component. --}}
<div class="flex shrink-0 items-center gap-2">
@if ($update['running'])
<x-ui.button variant="secondary" :disabled="true">
<x-ui.icon name="refresh" class="size-4 animate-spin" />
{{ __('admin_settings.update_running') }}
</x-ui.button>
@elseif ($update['available'])
<x-ui.button variant="primary" wire:click="requestUpdate" wire:loading.attr="disabled" wire:target="requestUpdate">
{{ __('admin_settings.update_now') }}
</x-ui.button>
@else
<x-ui.button variant="secondary" :disabled="true">
{{ $update['behind'] === 0 ? __('admin_settings.update_current') : __('admin_settings.update_unknown') }}
</x-ui.button>
@endif
</div>
</div>
@if (! $update['agent_seen'])
<x-ui.alert variant="warning" class="mt-4">{{ __('admin_settings.update_no_agent') }}</x-ui.alert>
@elseif ($update['last_error'])
<x-ui.alert variant="danger" class="mt-4">{{ $update['last_error'] }}</x-ui.alert>
@endif
{{-- Where it is. "Läuft gerade" alone is what sends an operator to
the shell: the service checks on a timer, so a queued update
does nothing at all for up to five minutes, and while it runs
the site is in maintenance mode and this page is unreachable.
Both of those look identical to "wedged" without a time. --}}
@if ($update['running'])
<div class="mt-4 space-y-1 rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm">
@if ($update['phase'])
<p class="font-medium text-body">{{ __('admin_settings.update_step', ['step' => $update['phase']]) }}</p>
@if ($update['started_at'])
<p class="text-xs text-muted">{{ __('admin_settings.update_since', ['when' => $update['started_at']->diffForHumans()]) }}</p>
@endif
@elseif ($update['next_check_at'])
<p class="font-medium text-body">{{ __('admin_settings.update_queued', ['time' => $update['next_check_at']->timezone(config('app.timezone'))->format('H:i')]) }}</p>
@endif
<p class="text-xs text-muted">{{ __('admin_settings.update_offline_hint') }}</p>
</div>
@endif
@if ($update['last_state'] !== null && ! $update['running'])
<p class="mt-3 text-xs text-muted">
{{ __('admin_settings.update_last_run', [
'state' => __('admin_settings.update_state.'.$update['last_state']),
'when' => $update['last_finished_at']?->diffForHumans() ?? '—',
]) }}
@if ($update['last_started_at'] && $update['last_finished_at'])
· {{ __('admin_settings.update_took', ['duration' => $update['last_started_at']->diffForHumans($update['last_finished_at'], true)]) }}
@endif
{{-- Only on a failure: on a run that worked the last step is
"left maintenance mode", which says nothing. --}}
@if ($update['last_state'] === 'failed' && $update['last_phase'])
· {{ __('admin_settings.update_failed_at', ['step' => $update['last_phase']]) }}
@endif
</p>
@endif
{{-- Open while it runs: "läuft gerade" on its own tells an operator
nothing about whether it is progressing or wedged. --}}
@if ($updateLog)
<details class="mt-4" @if ($update['running']) open @endif>
<summary class="cursor-pointer text-sm font-medium text-accent-text">{{ __('admin_settings.update_log') }}</summary>
<pre class="mt-2 max-h-64 overflow-auto rounded-lg border border-line bg-surface-2 p-3 font-mono text-xs leading-relaxed text-body">{{ $updateLog }}</pre>
</details>
@endif
</div>
@endif
{{-- My account --}}
@if ($canManageSite)
<div class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<h2 class="font-semibold text-ink">{{ __('admin_settings.site_title') }}</h2>
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $sitePublic ? 'border-success-border bg-success-bg text-success' : 'border-warning-border bg-warning-bg text-warning' }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ $sitePublic ? __('admin_settings.site_public') : __('admin_settings.site_hidden') }}
</span>
</div>
<p class="mt-1.5 max-w-xl text-sm text-muted">
{{ $sitePublic ? __('admin_settings.site_public_body') : __('admin_settings.site_hidden_body') }}
</p>
<p class="mt-2 text-xs text-faint">
{{ __('admin_settings.site_your_view', ['ip' => request()->ip()]) }}
<span class="{{ $viewerOnVpn ? 'text-success' : '' }}">
{{ $viewerOnVpn ? __('admin_settings.site_via_vpn') : __('admin_settings.site_not_vpn') }}
</span>
</p>
</div>
<x-ui.button :variant="$sitePublic ? 'secondary' : 'primary'" wire:click="toggleSiteVisibility" wire:loading.attr="disabled" wire:target="toggleSiteVisibility">
<x-ui.icon :name="$sitePublic ? 'lock' : 'unlock'" class="size-4" />
{{ $sitePublic ? __('admin_settings.site_hide') : __('admin_settings.site_show') }}
</x-ui.button>
</div>
</div>
@endif
{{-- Who may reach this console --}}
@if ($canManageSite)
<div class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:30ms]">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<h2 class="font-semibold text-ink">{{ __('admin_settings.console_title') }}</h2>
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $consoleRestricted ? 'border-success-border bg-success-bg text-success' : 'border-warning-border bg-warning-bg text-warning' }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ $consoleRestricted ? __('admin_settings.console_locked_badge') : __('admin_settings.console_open_badge') }}
</span>
</div>
<p class="mt-1 max-w-xl text-sm text-muted">
{{ $consoleRestricted ? __('admin_settings.console_locked_body') : __('admin_settings.console_open_body') }}
</p>
<p class="mt-2 font-mono text-xs text-faint">
{{ __('admin_settings.console_your_ip', ['ip' => $viewerIp]) }}
</p>
</div>
<x-ui.button :variant="$consoleRestricted ? 'secondary' : 'primary'"
wire:click="toggleConsoleRestriction" wire:loading.attr="disabled" wire:target="toggleConsoleRestriction">
<x-ui.icon :name="$consoleRestricted ? 'unlock' : 'lock'" class="size-4" />
{{ $consoleRestricted ? __('admin_settings.console_unlock') : __('admin_settings.console_lock') }}
</x-ui.button>
</div>
<div class="mt-5 space-y-2 border-t border-line pt-4">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('admin_settings.console_always') }}</p>
@foreach ($consoleVpnRanges as $range)
<div class="flex items-center justify-between rounded-lg border border-line-strong bg-surface-2 px-3 py-2">
<span class="font-mono text-sm text-body">{{ $range }}</span>
<span class="text-xs text-faint">{{ __('admin_settings.console_vpn_note') }}</span>
</div>
@endforeach
<p class="pt-3 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('admin_settings.console_extra') }}</p>
@forelse ($consoleIps as $ip)
<div wire:key="cip-{{ $ip }}" class="flex items-center justify-between rounded-lg border border-line-strong bg-surface-2 px-3 py-2">
<span class="font-mono text-sm text-body">{{ $ip }}</span>
<x-ui.button variant="ghost" size="sm" wire:click="removeConsoleIp('{{ $ip }}')">
{{ __('admin_settings.console_remove') }}
</x-ui.button>
</div>
@empty
<p class="text-sm text-muted">{{ __('admin_settings.console_none') }}</p>
@endforelse
{{-- The hint sits under the row, not inside it: as a sibling of
the field it made the flex row as tall as field-plus-hint,
and the button stretched to match. --}}
<form wire:submit="addConsoleIp" class="pt-2">
<div class="flex items-start gap-2">
<div class="flex-1">
<x-ui.input name="consoleIp" wire:model="consoleIp" placeholder="203.0.113.7" />
</div>
<x-ui.button type="submit" wire:loading.attr="disabled" wire:target="addConsoleIp">{{ __('admin_settings.console_add') }}</x-ui.button>
</div>
<p class="mt-1.5 text-xs text-faint">{{ __('admin_settings.console_ip_hint') }}</p>
</form>
</div>
</div>
@endif
{{-- Own password. There was no way to change one at all: an account created
with a generated password kept it until someone opened a shell. --}}
<form wire:submit="updateOwnPassword" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('admin_settings.password_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.password_sub') }}</p>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<x-ui.input name="currentPassword" type="password" autocomplete="current-password"
:label="__('admin_settings.password_current')" wire:model="currentPassword" />
<x-ui.input name="newPassword" type="password" autocomplete="new-password"
:label="__('admin_settings.password_new')" wire:model="newPassword" />
<x-ui.input name="newPasswordConfirmation" type="password" autocomplete="new-password"
:label="__('admin_settings.password_repeat')" wire:model="newPasswordConfirmation" />
</div>
<div class="flex justify-end">
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="updateOwnPassword">{{ __('admin_settings.password_save') }}</x-ui.button>
</div>
</form>
<form wire:submit="saveAccount" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('admin_settings.account_title') }}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<x-ui.input name="name" wire:model="name" :label="__('admin_settings.name')" />
<x-ui.input name="email" wire:model="email" :label="__('admin_settings.email')" type="email" />
</div>
<p class="text-xs text-faint">{{ __('admin_settings.twofa_hint') }}</p>
<div class="flex justify-end">
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveAccount">{{ __('admin_settings.save') }}</x-ui.button>
</div>
</form>
{{-- Staff & access (Owner-only) --}}
@if ($canManageStaff)
<div class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('admin_settings.staff_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.staff_sub') }}</p>
</div>
{{-- Invite --}}
<form wire:submit="inviteStaff" class="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
<div>
<label class="text-sm font-medium text-body" for="staffName">{{ __('admin_settings.name') }}</label>
<input id="staffName" type="text" wire:model="staffName" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('staffName')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="staffEmail">{{ __('admin_settings.email') }}</label>
<input id="staffEmail" type="email" wire:model="staffEmail" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('staffEmail')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="staffRole">{{ __('admin_settings.role') }}</label>
<select id="staffRole" wire:model="staffRole" class="mt-1.5 rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
@foreach ($roles as $r)
@if ($r !== 'Owner' || $isOwner)
<option value="{{ $r }}">{{ __('admin_settings.role_'.\Illuminate\Support\Str::slug($r)) }}</option>
@endif
@endforeach
</select>
@error('staffRole')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="inviteStaff">
<x-ui.icon name="plus" class="size-4" />{{ __('admin_settings.invite') }}
</x-ui.button>
</form>
<p class="text-xs text-faint">{{ __('admin_settings.invite_hint') }}</p>
@if ($invitedPassword)
<div class="rounded-lg border border-warning-border bg-warning-bg p-4">
<p class="text-sm font-semibold text-ink">{{ __('admin_settings.temp_title') }}</p>
<p class="mt-1 text-xs text-body">{{ __('admin_settings.temp_hint') }}</p>
<dl class="mt-2 space-y-1 font-mono text-sm text-ink">
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.email') }}:</dt><dd>{{ $invitedEmail }}</dd></div>
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.temp_password') }}:</dt><dd class="select-all">{{ $invitedPassword }}</dd></div>
</dl>
</div>
@endif
{{-- Staff list --}}
<div class="overflow-hidden rounded-xl border border-line">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
<th class="px-4 py-3 font-semibold">{{ __('admin_settings.col_person') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin_settings.role') }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ __('admin_settings.col_actions') }}</th>
</tr>
</thead>
<tbody>
@foreach ($staff as $s)
<tr wire:key="staff-{{ $s['id'] }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3">
<p class="font-medium text-ink">{{ $s['name'] }}
@if ($s['self'])<span class="ml-1 text-xs text-faint">({{ __('admin_settings.you') }})</span>@endif
</p>
<p class="text-xs text-muted">{{ $s['email'] }}</p>
</td>
<td class="px-4 py-3">
@if ($s['self'])
<span class="text-body">{{ __('admin_settings.role_'.\Illuminate\Support\Str::slug($s['role'])) }}</span>
@else
<select wire:change="setStaffRole({{ $s['id'] }}, $event.target.value)" class="rounded-md border border-line-strong bg-surface px-2 py-1 text-xs text-body">
@foreach ($roles as $r)
@if ($r !== 'Owner' || $isOwner)
<option value="{{ $r }}" @selected($s['role'] === $r)>{{ __('admin_settings.role_'.\Illuminate\Support\Str::slug($r)) }}</option>
@endif
@endforeach
</select>
@endif
</td>
<td class="px-4 py-3 text-right">
{{-- Your own row has no actions you cannot revoke
yourself and an empty cell reads as a bug rather
than as a rule. --}}
@if ($s['self'])
<span class="text-xs text-faint"></span>
@endif
@unless ($s['self'])
<button type="button" wire:click="revokeStaff({{ $s['id'] }})" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:border-danger hover:text-danger">{{ __('admin_settings.revoke') }}</button>
@endunless
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
@endif
</div>