264 lines
19 KiB
PHP
264 lines
19 KiB
PHP
<div class="space-y-6">
|
|
<div class="flex flex-wrap items-end justify-between gap-4 animate-rise">
|
|
<div>
|
|
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('users.title') }}</h1>
|
|
<p class="mt-1 text-sm text-muted">{{ __('users.subtitle') }}</p>
|
|
</div>
|
|
<div class="text-right">
|
|
<p class="font-mono text-lg font-semibold text-ink">{{ $used }} / {{ $limit }}</p>
|
|
<p class="text-xs text-muted">{{ __('users.seats_used') }}</p>
|
|
<p class="text-xs text-muted">{{ __('users.seats_note') }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Anlegen. Ausdrücklich NICHT einladen: hier entsteht nur die Zeile,
|
|
die Einladung verschickt der Knopf in der Tabelle. Ein Inhaber soll
|
|
sein Team vorbereiten können, ohne dass jemand eine Mail bekommt. --}}
|
|
{{-- Eine Zeile aus Feldern, und alles, was darunter wachsen kann, wächst
|
|
AUSSERHALB davon.
|
|
|
|
Vorher stand hier `sm:items-end`: Der Fehler unter der E-Mail und der
|
|
Hinweis unter der Rolle machten ihre Zelle höher, und weil unten
|
|
ausgerichtet wurde, schob das ihre Eingabefelder nach OBEN. Drei
|
|
Felder auf drei verschiedenen Höhen, und je nachdem, ob gerade ein
|
|
Fehler stand, sprang die Zeile.
|
|
|
|
`items-start` und gleich hohe Beschriftungen richten die Felder
|
|
aneinander aus. Fehler und Hinweis stehen unter der Zeile, wo sie
|
|
niemanden mehr verschieben. --}}
|
|
<form wire:submit="addSeat" class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
|
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_1fr_auto_auto] sm:items-start">
|
|
<div>
|
|
<label class="block text-sm font-medium text-body" for="inviteEmail">{{ __('users.invite_email') }}</label>
|
|
<input id="inviteEmail" type="email" wire:model="inviteEmail"
|
|
@error('inviteEmail') aria-invalid="true" @enderror
|
|
class="mt-1.5 w-full rounded-md border bg-surface px-3 py-2 text-sm text-ink @error('inviteEmail') border-danger @else border-line-strong @enderror" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-body" for="inviteName">{{ __('users.invite_name') }}</label>
|
|
<input id="inviteName" type="text" wire:model="inviteName" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-body" for="inviteRole">{{ __('users.role') }}</label>
|
|
<select id="inviteRole" wire:model.live="inviteRole" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body sm:w-auto">
|
|
<option value="admin">{{ __('users.role_admin') }}</option>
|
|
<option value="member">{{ __('users.role_member') }}</option>
|
|
<option value="readonly">{{ __('users.role_readonly') }}</option>
|
|
</select>
|
|
</div>
|
|
{{-- Ein leerer Platzhalter in Beschriftungshöhe, damit der Knopf auf
|
|
der Linie der Felder sitzt — und nicht über einen abgezählten
|
|
Abstand, der bei der nächsten Schriftgröße nicht mehr stimmt. --}}
|
|
<div>
|
|
<span class="block text-sm font-medium" aria-hidden="true"> </span>
|
|
<x-ui.button variant="primary" type="submit" class="mt-1.5 w-full sm:w-auto" wire:loading.attr="disabled" wire:target="addSeat">
|
|
<x-ui.icon name="plus" class="size-4" />{{ __('users.add') }}
|
|
</x-ui.button>
|
|
</div>
|
|
</div>
|
|
|
|
@error('inviteEmail')<p class="mt-2 text-xs text-danger">{{ $message }}</p>@enderror
|
|
|
|
{{-- Nur zur Rolle, die wirklich gewählt ist. Der Satz stand vorher
|
|
immer da und beschrieb „Nur Lesen", während „Mitglied" ausgewählt
|
|
war — eine Erklärung zu etwas, das gar nicht passiert. --}}
|
|
@if ($inviteRole === 'readonly')
|
|
<p class="mt-2 text-xs text-muted">{{ __('users.role_readonly_means') }}</p>
|
|
@endif
|
|
</form>
|
|
|
|
{{-- Der Mailversand fehlt: gesagt, bevor jemand drückt.
|
|
|
|
Der Einladen-Knopf bleibt trotzdem stehen. Ihn an jeder Zeile
|
|
verschwinden zu lassen liest sich nicht als „geht hier gerade nicht",
|
|
sondern als „das kann dieses Produkt nicht" — genau die Beschwerde,
|
|
die schon einmal dazu geführt hat, dass die Aktionsspalte IMMER
|
|
gezeichnet wird (siehe Users::render()). Und der Zustand ist
|
|
vorübergehend: er endet, sobald der Betrieb das Versandkonto
|
|
ausfüllt, ohne dass sich an dieser Seite etwas ändert.
|
|
|
|
Einmal über der Tabelle statt einmal je Zeile: der Grund ist für
|
|
alle derselbe, und an jeder Zeile stünde derselbe Satz noch einmal.
|
|
Der Server weist die Einladung mit derselben Begründung ab — der
|
|
Knopf verspricht also nichts, was der Hinweis nicht schon
|
|
zurücknimmt. --}}
|
|
@unless ($versandBereit)
|
|
<x-ui.alert variant="warning" class="animate-rise [animation-delay:90ms]">{{ __('users.mail_not_ready') }}</x-ui.alert>
|
|
@endunless
|
|
|
|
{{-- Seats --}}
|
|
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]">
|
|
<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 font-semibold text-muted">
|
|
<th class="px-4 py-3 font-semibold">{{ __('users.col_person') }}</th>
|
|
<th class="px-4 py-3 font-semibold">{{ __('users.role') }}</th>
|
|
<th class="px-4 py-3 font-semibold">{{ __('users.col_status') }}</th>
|
|
<th class="px-4 py-3 text-right font-semibold">{{ __('users.col_actions') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach ($seats as $seat)
|
|
<tr wire:key="seat-{{ $seat->uuid }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
|
|
<td class="px-4 py-3">
|
|
<p class="font-medium text-ink">{{ $seat->name ?: $seat->email }}</p>
|
|
@if ($seat->name)<p class="text-xs text-muted">{{ $seat->email }}</p>@endif
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
@if ($seat->role === 'owner')
|
|
<span class="inline-flex items-center gap-1.5 rounded-pill bg-accent-subtle px-2.5 py-0.5 text-xs font-semibold text-accent-text">{{ __('users.role_owner') }}</span>
|
|
@else
|
|
<select wire:change="setRole('{{ $seat->uuid }}', $event.target.value)" class="rounded-md border border-line-strong bg-surface px-2 py-1 text-xs text-body">
|
|
@foreach (['admin', 'member', 'readonly'] as $r)
|
|
<option value="{{ $r }}" @selected($seat->role === $r)>{{ __('users.role_'.$r) }}</option>
|
|
@endforeach
|
|
</select>
|
|
@endif
|
|
</td>
|
|
{{-- Was der Inhaber WILL (`status`) und was in der
|
|
Nextcloud WIRKLICH ist (`nc_state`) stehen in
|
|
einer Zelle, und der wahre Zustand geht vor:
|
|
solange der Auftrag läuft oder gescheitert
|
|
ist, wäre „Eingeladen" eine Behauptung über
|
|
etwas, das die Warteschlange noch gar nicht
|
|
erledigt hat. Ohne diese Spalte drückt der
|
|
Inhaber wieder und wieder, weil nichts
|
|
sichtbar geschieht. --}}
|
|
<td class="px-4 py-3">
|
|
@if ($seat->status === 'revoked')
|
|
{{-- Entzogen geht dem Zustand vor: ein
|
|
„wird eingerichtet …" an einer
|
|
geschlossenen Zeile las sich wie ein
|
|
Weg zurück, den es an DIESER Zeile
|
|
nicht gibt. --}}
|
|
<x-ui.badge status="suspended">{{ __('users.status_revoked') }}</x-ui.badge>
|
|
<p class="mt-1 text-xs text-muted">{{ __('users.revoked_closed') }}</p>
|
|
@elseif ($seat->nc_state === \App\Models\Seat::STATE_NONE)
|
|
<x-ui.badge status="info">{{ __('users.state_none') }}</x-ui.badge>
|
|
@elseif ($seat->nc_state === \App\Models\Seat::STATE_PENDING)
|
|
<x-ui.badge status="info">{{ __('users.state_pending') }}</x-ui.badge>
|
|
@elseif ($seat->nc_state !== \App\Models\Seat::STATE_FAILED)
|
|
@php $sb = ['active' => 'active', 'invited' => 'provisioning'][$seat->status] ?? 'info'; @endphp
|
|
<x-ui.badge :status="$sb">{{ __('users.status_'.$seat->status) }}</x-ui.badge>
|
|
@endif
|
|
|
|
{{-- Der Fehlschlag steht NEBEN dem Entzogen,
|
|
nicht dahinter. Sonst verschwände genau
|
|
dort die Rückfahrkarte, wo sie am
|
|
nötigsten ist: scheitert das `disable`,
|
|
ist das Konto im Gast weiter offen,
|
|
während die Zeile „entzogen" sagt — kein
|
|
Wiederholungslauf (tries = 1), kein
|
|
Abgleich im Zeitplan, und diese Seite ist
|
|
die einzige Stelle im Repo, die nc_state
|
|
überhaupt anzeigt. --}}
|
|
@if ($seat->nc_state === \App\Models\Seat::STATE_FAILED)
|
|
<div @class(['mt-1' => $seat->status === 'revoked'])>
|
|
<x-ui.badge status="failed">{{ __('users.state_failed') }}</x-ui.badge>
|
|
{{-- Der Grund im Klartext, nicht der
|
|
Code: 'guest_failed' sagt einem
|
|
Inhaber nichts, und ein Fehlschlag
|
|
ohne Grund ist eine Sackgasse. --}}
|
|
<p class="mt-1 text-xs text-muted">{{ __('users.error_'.($seat->nc_error ?: 'unexpected')) }}</p>
|
|
@php
|
|
// Dieselbe Bedingung wie in retry(): ein Knopf, der
|
|
// nur wieder rot zurückkommt, ist schlimmer als
|
|
// keiner. Eine entzogene oder gesperrte Zeile, die
|
|
// nie in der Nextcloud ankam, hat dort nichts zu
|
|
// sperren — da gäbe es nichts zu wiederholen.
|
|
$wiederholbar = $seat->role !== 'owner'
|
|
&& ($seat->nc_synced_at !== null
|
|
|| ! in_array($seat->status, ['revoked', 'suspended'], true));
|
|
@endphp
|
|
@if ($wiederholbar)
|
|
<button type="button" wire:click="retry('{{ $seat->uuid }}')" class="mt-1 text-xs underline">
|
|
{{ __('users.retry') }}
|
|
</button>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<div class="flex items-center justify-end gap-1.5">
|
|
@if ($seat->status === 'revoked')
|
|
{{-- Kein Knopf an der Zeile. Der Weg
|
|
zurück geht durch die Vordertür: das
|
|
Anlegen-Formular oben, mit derselben
|
|
Adresse — und damit durch die
|
|
Platzprüfung, die es an der Zeile
|
|
nicht gäbe. Gesagt statt leer
|
|
gelassen, wie bei der Inhaber-Zeile —
|
|
eine leere Zelle liesse jemanden nach
|
|
einem Knopf suchen, der absichtlich
|
|
fehlt. --}}
|
|
<span class="cursor-help text-xs text-muted" title="{{ __('users.revoked_closed') }}">{{ __('users.revoked_final') }}</span>
|
|
@else
|
|
{{-- Einladen ist der zweite Vorgang und
|
|
hat deshalb seinen eigenen Knopf. Er
|
|
steht nur an einer Zeile, die noch
|
|
nichts in der Nextcloud hat; ist der
|
|
Auftrag unterwegs, gibt es nichts zu
|
|
drücken, und bei einem Fehlschlag
|
|
steht „Nochmal versuchen" schon in der
|
|
Zustandsspalte. Nie an der
|
|
Inhaber-Zeile: dieses Konto legt die
|
|
Bereitstellung an, nicht das Panel. --}}
|
|
@if ($seat->role !== 'owner' && $seat->nc_state === \App\Models\Seat::STATE_NONE)
|
|
<button type="button" wire:click="sendInvite('{{ $seat->uuid }}')"
|
|
wire:loading.attr="disabled" wire:target="sendInvite"
|
|
class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:bg-surface-hover">{{ __('users.invite') }}</button>
|
|
@elseif ($seat->role !== 'owner' && $seat->nc_state === \App\Models\Seat::STATE_SYNCED && $seat->status === 'invited')
|
|
<button type="button" wire:click="sendInvite('{{ $seat->uuid }}')"
|
|
wire:loading.attr="disabled" wire:target="sendInvite"
|
|
class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:bg-surface-hover">{{ __('users.resend') }}</button>
|
|
@endif
|
|
|
|
{{-- Rename is offered on every row, the owner
|
|
included: a row you can do nothing to reads
|
|
as a broken table, not as a protected one.
|
|
It opens a modal, never the row itself —
|
|
see R20. --}}
|
|
<button type="button"
|
|
x-on:click="$dispatch('openModal', { component: 'edit-seat', arguments: { uuid: '{{ $seat->uuid }}' } })"
|
|
aria-label="{{ __('users.edit') }}" title="{{ __('users.edit') }}"
|
|
class="grid size-9 place-items-center rounded-md border border-line text-muted hover:border-ink hover:text-ink">
|
|
<x-ui.icon name="pencil" class="size-4" />
|
|
</button>
|
|
|
|
@if ($seat->role === 'owner')
|
|
{{-- Said, not hidden. The owner keeps their
|
|
own access; an empty cell would leave
|
|
somebody hunting for a button that is
|
|
deliberately absent. --}}
|
|
<span class="cursor-help text-xs text-muted" title="{{ __('users.owner_no_actions') }}">{{ __('users.owner_protected') }}</span>
|
|
@else
|
|
{{-- Pause first, remove second. Somebody
|
|
leaving needs their access stopped
|
|
today; deleting the seat also throws
|
|
away who held it, which is the half
|
|
an audit asks about. --}}
|
|
<button type="button" wire:click="suspend('{{ $seat->uuid }}')"
|
|
aria-label="{{ $seat->status === 'suspended' ? __('users.reactivate') : __('users.suspend') }}"
|
|
title="{{ $seat->status === 'suspended' ? __('users.reactivate') : __('users.suspend') }}"
|
|
class="grid size-9 place-items-center rounded-md border border-line text-muted hover:border-warning hover:text-warning">
|
|
<x-ui.icon :name="$seat->status === 'suspended' ? 'unlock' : 'lock'" class="size-4" />
|
|
</button>
|
|
<button type="button"
|
|
x-on:click="$dispatch('openModal', { component: 'confirm-revoke-seat', arguments: { uuid: '{{ $seat->uuid }}' } })"
|
|
aria-label="{{ __('users.revoke') }}" title="{{ __('users.revoke') }}"
|
|
class="grid size-9 place-items-center rounded-md border border-line text-muted hover:border-danger hover:text-danger">
|
|
<x-ui.icon name="trash-2" class="size-4" />
|
|
</button>
|
|
@endif
|
|
@endif
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|