Seitenleiste neu geordnet: Betrieb trug zwoelf Eintraege aus drei Themen
parent
5413628c26
commit
c0f83ab957
|
|
@ -41,58 +41,69 @@ final class Navigation
|
|||
public static function console(): array
|
||||
{
|
||||
return [
|
||||
// Allein und ohne Überschrift: die Seite, auf der man landet.
|
||||
['label' => null, 'items' => [
|
||||
['admin.overview', 'gauge', 'overview', null],
|
||||
]],
|
||||
// Wer bei uns ist und was er hat.
|
||||
['label' => __('admin.nav_group.customers'), 'items' => [
|
||||
['admin.customers', 'users', 'customers', null],
|
||||
['admin.instances', 'box', 'instances', null],
|
||||
['admin.hosts', 'server', 'hosts', null],
|
||||
['admin.datacenters', 'database', 'datacenters', null],
|
||||
['admin.plans', 'tag', 'plans', 'plans.manage'],
|
||||
]],
|
||||
// Die Maschinen und ihre Adressen — der Bestand, den man ansieht.
|
||||
['label' => __('admin.nav_group.fleet'), 'items' => [
|
||||
['admin.hosts', 'server', 'hosts', null],
|
||||
['admin.datacenters', 'database', 'datacenters', null],
|
||||
['admin.capacity', 'database', 'capacity', 'hosts.manage'],
|
||||
// Hostnamen und ihre Zertifikate: was hier steht, entscheidet,
|
||||
// ob eine Adresse ANTWORTET. Kein Einrichten, sondern Bestand.
|
||||
['admin.proxy-hosts', 'globe', 'proxy_hosts', 'site.manage'],
|
||||
// Stand vorher unter System, mit der Begründung, der Tunnel
|
||||
// müsse stehen, bevor irgendetwas geht. Das stimmt — gilt aber
|
||||
// genauso für Integrationen und Bereitschaft, die dort bleiben.
|
||||
// Der Unterschied: das hier ist eine LISTE VON GEGENSTELLEN,
|
||||
// die man beim Arbeiten ansieht, so wie die Hostliste. Dieselbe
|
||||
// Begründung, mit der Hostnamen schon aus System herauskamen.
|
||||
['admin.vpn', 'shield', 'vpn', null],
|
||||
]],
|
||||
// Was gerade läuft oder klemmt. Vorgänge, kein Bestand.
|
||||
//
|
||||
// Diese Gruppe trug bis hierher zwölf Einträge — Maschinen, Geld und
|
||||
// Post unter einem Wort, das auf alle drei zutrifft und deshalb
|
||||
// keinem hilft. Sie heißt weiter „Betrieb" und meint jetzt nur noch
|
||||
// das eine.
|
||||
['label' => __('admin.nav_group.operations'), 'items' => [
|
||||
['admin.provisioning', 'activity', 'provisioning', null],
|
||||
['admin.maintenance', 'alert-triangle', 'maintenance', null],
|
||||
['admin.incidents', 'bell', 'incidents', null],
|
||||
['admin.capacity', 'database', 'capacity', 'hosts.manage'],
|
||||
// Hostnamen und ihre Zertifikate. Unter Betrieb und nicht unter
|
||||
// System: was hier steht, entscheidet, ob eine Adresse
|
||||
// ANTWORTET — das ist kein Einrichten, das ist Betrieb. Ohne
|
||||
// diesen Eintrag war die Seite zwar vorhanden, aber über die
|
||||
// Navigation nicht erreichbar, und eine Seite, die man nur mit
|
||||
// der URL findet, gibt es für den Betreiber nicht.
|
||||
['admin.proxy-hosts', 'globe', 'proxy_hosts', 'site.manage'],
|
||||
]],
|
||||
// Alles, wo Geld drinsteht.
|
||||
['label' => __('admin.nav_group.billing'), 'items' => [
|
||||
['admin.revenue', 'trending-up', 'revenue', null],
|
||||
// Its own entry, not a section of Settings: what is set here
|
||||
// appears on a legal document, and burying it beside the
|
||||
// site-visibility switch invites somebody to change one in passing.
|
||||
['admin.finance', 'receipt', 'finance', 'site.manage'],
|
||||
['admin.invoices', 'file-text', 'invoices', 'site.manage'],
|
||||
['admin.payment-problems', 'alert-triangle', 'payment_problems', 'billing.manage'],
|
||||
// What customers wrote to us, and what we sent them. Under
|
||||
// Betrieb rather than System: both are read while answering
|
||||
// somebody, not while configuring a machine.
|
||||
// Eigener Eintrag, kein Abschnitt der Einstellungen: was hier
|
||||
// gesetzt wird, steht auf einem Rechtsdokument, und neben dem
|
||||
// Sichtbarkeitsschalter ändert das jemand im Vorbeigehen.
|
||||
['admin.finance', 'receipt', 'finance', 'site.manage'],
|
||||
]],
|
||||
// Was geschrieben wird — gelesen beim Antworten, nicht beim
|
||||
// Einrichten.
|
||||
['label' => __('admin.nav_group.post'), 'items' => [
|
||||
['admin.inbox', 'mail', 'inbox', 'customers.manage'],
|
||||
['admin.mail-log', 'send', 'mail_log', 'customers.manage'],
|
||||
// The answers, written once. Beside the two mail pages because
|
||||
// that is where they are used, not under System — nothing about
|
||||
// a template configures the installation.
|
||||
['admin.templates', 'file-text', 'templates', 'customers.manage'],
|
||||
]],
|
||||
['label' => __('admin.nav_group.system'), 'items' => [
|
||||
['admin.mail', 'mail', 'mail', 'mail.manage'],
|
||||
// Looking at a mail before a customer does. Beside the mailboxes
|
||||
// it is sent from, not under Betrieb: both are about how mail
|
||||
// leaves this installation.
|
||||
// Stand vorher unter System, neben den Postfächern. Sie zeigt
|
||||
// aber VORLAGEN, keine Postfächer — und gehört deshalb neben
|
||||
// die Vorlagen, die sie darstellt.
|
||||
['admin.mail.preview', 'send', 'mail_preview', 'mail.manage'],
|
||||
// The processing agreement. Under System with the other things
|
||||
// that are true of the whole installation rather than of one
|
||||
// customer.
|
||||
['admin.dpa', 'file-text', 'dpa', 'dpa.manage'],
|
||||
// System, not Betrieb: the tunnel is how the console reaches
|
||||
// the estate at all. Nothing is provisioned, monitored or
|
||||
// updated through anything else, so it belongs beside the other
|
||||
// things that have to work before any of the day's work can.
|
||||
['admin.vpn', 'shield', 'vpn', null],
|
||||
]],
|
||||
// Was einmal eingerichtet wird und danach gilt.
|
||||
['label' => __('admin.nav_group.system'), 'items' => [
|
||||
// Die Postfächer und der Server, über den Mail dieses Haus
|
||||
// verlässt. Einrichtung, anders als die vier Seiten unter Post.
|
||||
['admin.mail', 'mail', 'mail', 'mail.manage'],
|
||||
// Merged from the former admin.secrets + admin.infrastructure
|
||||
// pages, which split the same subject by storage mechanism
|
||||
// instead of by what it configures. Reachable with EITHER
|
||||
|
|
@ -106,6 +117,9 @@ final class Navigation
|
|||
// one page — same gate as Integrations just above, because
|
||||
// both halves it configures come back together here.
|
||||
['admin.readiness', 'shield-check', 'readiness', ['hosts.manage', 'secrets.manage']],
|
||||
// Der AV-Vertrag: gilt für die ganze Installation, nicht für
|
||||
// einen Kunden.
|
||||
['admin.dpa', 'file-text', 'dpa', 'dpa.manage'],
|
||||
// Two-factor enrolment used to sit here as an entry of its
|
||||
// own. It is a tab of Settings now — it is a setting, and it
|
||||
// was the one place an operator had to leave the settings page
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ return [
|
|||
'to_portal' => 'Zum Kundenportal',
|
||||
|
||||
'nav_group' => [
|
||||
'customers' => 'Kunden',
|
||||
'fleet' => 'Flotte',
|
||||
'operations' => 'Betrieb',
|
||||
'billing' => 'Abrechnung',
|
||||
'post' => 'Post',
|
||||
'system' => 'System',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ return [
|
|||
'to_portal' => 'To customer portal',
|
||||
|
||||
'nav_group' => [
|
||||
'customers' => 'Customers',
|
||||
'fleet' => 'Fleet',
|
||||
'operations' => 'Operations',
|
||||
'billing' => 'Billing',
|
||||
'post' => 'Mail',
|
||||
'system' => 'System',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -41,22 +41,43 @@
|
|||
aside past the viewport — same mechanism <main> already relies on. --}}
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
@foreach ($groups as $group)
|
||||
@php
|
||||
// Sichtbarkeit EINMAL je Gruppe entscheiden, nicht erst je
|
||||
// Eintrag. Vorher wurde die Überschrift bedingungslos gedruckt
|
||||
// und stand deshalb auch über einer Gruppe, von der dieser
|
||||
// Betreiber keinen einzigen Eintrag sehen darf — eine
|
||||
// Überschrift mit nichts darunter liest sich wie ein
|
||||
// Ladefehler, nicht wie eine Berechtigung.
|
||||
//
|
||||
// Bis zur Neuordnung der Konsolen-Navigation fiel das nicht
|
||||
// auf, weil jede Gruppe mindestens einen ungeschützten Eintrag
|
||||
// trug. „Post" tut das nicht.
|
||||
//
|
||||
// $capability ist ein einzelner Fähigkeitsname, ein Array von
|
||||
// Namen im Sinne von „irgendeine davon" (App\Support\Navigation
|
||||
// hält fest, welche Einträge das benutzen), oder null für
|
||||
// „alle". Laravels eigenes can() liest ein Array als „alle
|
||||
// davon" — die falsche Logik für einen Eintrag, den man mit
|
||||
// der einen ODER der anderen Fähigkeit erreicht. Daher die
|
||||
// ausgeschriebene Verzweigung statt eines bloßen ->can().
|
||||
$visible = collect($group['items'])->filter(function (array $item) {
|
||||
$capability = $item[3];
|
||||
|
||||
if ($capability === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_array($capability)
|
||||
? collect($capability)->contains(fn ($one) => auth()->user()?->can($one))
|
||||
: (bool) auth()->user()?->can($capability);
|
||||
});
|
||||
@endphp
|
||||
@continue($visible->isEmpty())
|
||||
@if (! empty($group['label']))
|
||||
<p class="px-3 pb-2 pt-5 lbl">{{ $group['label'] }}</p>
|
||||
@endif
|
||||
<nav class="space-y-0.5">
|
||||
@foreach ($group['items'] as [$route, $icon, $key, $capability])
|
||||
{{-- $capability is a single ability name, an array of
|
||||
ability names meaning "any of these" (App\Support\
|
||||
Navigation documents which entries use that), or
|
||||
null for "everyone". Laravel's own can() treats an
|
||||
array as "all of these", which is the wrong logic
|
||||
for an entry reachable with either of two
|
||||
capabilities — hence the explicit branch instead of
|
||||
a bare ->can($capability) for both shapes. --}}
|
||||
@continue($capability !== null && ! (is_array($capability)
|
||||
? collect($capability)->contains(fn ($one) => auth()->user()?->can($one))
|
||||
: auth()->user()?->can($capability)))
|
||||
@foreach ($visible as [$route, $icon, $key, $capability])
|
||||
<x-ui.nav-item
|
||||
:href="route($route)"
|
||||
:active="$console ? \App\Support\AdminArea::routeIs($route) : request()->routeIs($route)"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Operator;
|
||||
use App\Support\Navigation;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/**
|
||||
* Die Seitenleiste der Konsole.
|
||||
*
|
||||
* Sie trug 26 Einträge in drei Gruppen, von denen eine — „Betrieb" — allein
|
||||
* zwölf hielt: Maschinen, Geld und Post unter einem Wort, das auf alle drei
|
||||
* zutrifft und deshalb keinem hilft. Neu geordnet in sechs Gruppen.
|
||||
*
|
||||
* Was diese Tests festhalten, ist nicht die Ordnung selbst (die darf sich
|
||||
* ändern), sondern dass beim Umsortieren nichts verlorengeht und keine leere
|
||||
* Überschrift entsteht.
|
||||
*/
|
||||
|
||||
/** @return list<array{0:string,1:string,2:string,3:mixed}> */
|
||||
function consoleItems(): array
|
||||
{
|
||||
return collect(Navigation::console())->flatMap(fn (array $g) => $g['items'])->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Gruppen-Überschrift, wie x-shell.nav sie zeichnet.
|
||||
*
|
||||
* Auf das Element geprüft und nicht auf das blanke Wort: „Post" steckt auch in
|
||||
* „Posteingang". Ein Teilstring-Vergleich auf ein kurzes Wort besteht aus
|
||||
* Gründen, die mit der Prüfung nichts zu tun haben.
|
||||
*/
|
||||
function navLabel(string $label): string
|
||||
{
|
||||
return 'lbl">'.e($label).'</p>';
|
||||
}
|
||||
|
||||
it('verliert beim Umsortieren keinen Eintrag und legt keinen doppelt an', function () {
|
||||
$routes = array_column(consoleItems(), 0);
|
||||
|
||||
// Doppelte wären beim Verschieben von Hand die naheliegende Panne: einmal
|
||||
// an der neuen Stelle eingefügt, an der alten vergessen.
|
||||
expect($routes)->toHaveCount(count(array_unique($routes)));
|
||||
|
||||
// Die Zahl steht hier bewusst als Zahl: sinkt sie, ist beim Umsortieren
|
||||
// ein Eintrag unter den Tisch gefallen, und genau das sähe niemand.
|
||||
expect($routes)->toHaveCount(26);
|
||||
});
|
||||
|
||||
it('führt keinen Eintrag, dessen Route es nicht gibt', function () {
|
||||
foreach (consoleItems() as [$route, , $key]) {
|
||||
expect(Route::has($route))->toBeTrue("Route {$route} (Eintrag {$key}) existiert nicht.");
|
||||
}
|
||||
});
|
||||
|
||||
it('gibt jedem Eintrag eine übersetzte Beschriftung in beiden Sprachen', function () {
|
||||
foreach (['de', 'en'] as $locale) {
|
||||
app()->setLocale($locale);
|
||||
|
||||
foreach (consoleItems() as [, , $key]) {
|
||||
expect(__('admin.nav.'.$key))->not->toBe('admin.nav.'.$key, "{$key} fehlt in lang/{$locale}/admin.php");
|
||||
}
|
||||
|
||||
foreach (Navigation::console() as $group) {
|
||||
if ($group['label'] !== null) {
|
||||
expect($group['label'])->not->toStartWith('admin.nav_group.');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('stellt jede Gruppe unter eine Überschrift — außer der Übersicht', function () {
|
||||
$groups = Navigation::console();
|
||||
|
||||
// Die erste Gruppe trägt allein die Übersicht und braucht keine
|
||||
// Überschrift: sie ist die Seite, auf der man landet.
|
||||
expect($groups[0]['label'])->toBeNull()
|
||||
->and($groups[0]['items'])->toHaveCount(1)
|
||||
->and($groups[0]['items'][0][0])->toBe('admin.overview');
|
||||
|
||||
// Alle weiteren haben eine. Eine namenlose Gruppe mitten in der Leiste
|
||||
// wäre wieder der Zustand, aus dem diese Arbeit kommt: sechs schwebende
|
||||
// Einträge ohne Zugehörigkeit.
|
||||
foreach (array_slice($groups, 1) as $group) {
|
||||
expect($group['label'])->not->toBeNull()->and($group['label'])->not->toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('lässt keine Gruppe so groß werden, dass sie wieder alles verschluckt', function () {
|
||||
// „Betrieb" hielt zwölf. Die Grenze ist kein Selbstzweck: ab etwa sieben
|
||||
// liest man eine Gruppe nicht mehr auf einen Blick, und dann fängt das
|
||||
// Sortieren von vorne an.
|
||||
foreach (Navigation::console() as $group) {
|
||||
expect(count($group['items']))->toBeLessThanOrEqual(6, "Gruppe „{$group['label']}\" ist auf ".count($group['items']).' Einträge gewachsen.');
|
||||
}
|
||||
});
|
||||
|
||||
it('zeigt einem Betreiber ohne Berechtigungen keine leere Überschrift', function () {
|
||||
// „Post" besteht ausschließlich aus geschützten Einträgen. Vor dieser
|
||||
// Arbeit druckte x-shell.nav die Überschrift bedingungslos — die Gruppe
|
||||
// hätte also als Wort ohne Inhalt dagestanden.
|
||||
//
|
||||
// „Read-only" trägt weder customers.manage noch mail.manage, sieht also
|
||||
// keinen einzigen Eintrag dieser Gruppe.
|
||||
$this->actingAs(Operator::factory()->role('Read-only')->create(), 'operator');
|
||||
|
||||
$html = $this->get(route('admin.overview'))->assertOk()->getContent();
|
||||
|
||||
// Auf das Überschriften-Element geprüft, nicht auf das blanke Wort:
|
||||
// „Post" steckt auch in „Posteingang" und „Postausgang", und ein
|
||||
// Teilstring-Vergleich hätte hier etwas anderes geprüft als behauptet.
|
||||
expect($html)->not->toContain(navLabel(__('admin.nav_group.post')));
|
||||
});
|
||||
|
||||
it('zeigt die Gruppe, sobald der Betreiber einen ihrer Einträge sehen darf', function () {
|
||||
// Die Kehrseite: die Überschrift darf nicht aus Vorsicht verschwinden.
|
||||
$this->actingAs(admin(), 'operator');
|
||||
|
||||
$html = $this->get(route('admin.overview'))->assertOk()->getContent();
|
||||
|
||||
foreach (Navigation::console() as $group) {
|
||||
if ($group['label'] !== null) {
|
||||
expect($html)->toContain(navLabel($group['label']));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -75,19 +75,29 @@ it('lists every tab it can render, and renders every tab it lists', function ()
|
|||
}
|
||||
});
|
||||
|
||||
it('moves the tunnel to System and drops the two-factor entry from the menu', function () {
|
||||
// VPN is not a task of the day's operations — it is how the console reaches
|
||||
// the estate at all, so it belongs beside the other things that have to
|
||||
// work first. Two-factor left the menu entirely: it is a tab now.
|
||||
it('keeps the tunnel with the fleet and drops the two-factor entry from the menu', function () {
|
||||
// The tunnel sat under System, on the argument that it is how the console
|
||||
// reaches the estate at all and therefore belongs beside the things that
|
||||
// have to work before any of the day's work can.
|
||||
//
|
||||
// That argument holds — and it holds just as well for Integrations and
|
||||
// Readiness, which stayed under System. What decided it instead: this page
|
||||
// is a LIST OF PEERS an operator reads while working, the same way they
|
||||
// read the host list. Reading a list of machines is not configuring an
|
||||
// installation. Same reasoning that already pulled Hostnamen out of System.
|
||||
//
|
||||
// Two-factor left the menu entirely: it is a tab now.
|
||||
$groups = collect(Navigation::console());
|
||||
|
||||
$routesIn = fn (string $label) => $groups
|
||||
->firstWhere('label', __($label))['items'] ?? [];
|
||||
|
||||
$fleet = collect($routesIn('admin.nav_group.fleet'))->pluck(0);
|
||||
$system = collect($routesIn('admin.nav_group.system'))->pluck(0);
|
||||
$operations = collect($routesIn('admin.nav_group.operations'))->pluck(0);
|
||||
|
||||
expect($system)->toContain('admin.vpn')
|
||||
expect($fleet)->toContain('admin.vpn')
|
||||
->and($system)->not->toContain('admin.vpn')
|
||||
->and($operations)->not->toContain('admin.vpn')
|
||||
->and($groups->pluck('items')->flatten(1)->pluck(0))
|
||||
->not->toContain('admin.two-factor-setup');
|
||||
|
|
|
|||
Loading…
Reference in New Issue