diff --git a/app/Livewire/Admin/Setup.php b/app/Livewire/Admin/Setup.php new file mode 100644 index 0000000..6d3649c --- /dev/null +++ b/app/Livewire/Admin/Setup.php @@ -0,0 +1,77 @@ + $this->visibleGroups(), + ])->title(__('setup.title')); + } + + /** + * Nur, was dieser Betreiber auch öffnen darf — und keine leere Gruppe. + * + * Eine Kachel, die zu einer 403 führt, ist schlimmer als eine fehlende: + * sie sieht aus wie ein Weg. Dieselbe Prüfung, die x-shell.nav je Eintrag + * anwendet, damit beide Wege zu einer Seite dieselbe Antwort geben. + * + * @return array> + */ + private function visibleGroups(): array + { + $sichtbar = []; + + foreach (Navigation::setup() as $gruppe => $kacheln) { + $erlaubt = array_values(array_filter( + $kacheln, + fn (array $kachel) => $this->mayOpen($kachel[3]), + )); + + if ($erlaubt !== []) { + $sichtbar[$gruppe] = $erlaubt; + } + } + + return $sichtbar; + } + + private function mayOpen(mixed $capability): bool + { + if ($capability === null) { + return true; + } + + $betreiber = auth('operator')->user(); + + return is_array($capability) + ? collect($capability)->contains(fn (string $eine) => (bool) $betreiber?->can($eine)) + : (bool) $betreiber?->can($capability); + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index 56a73c7..15d7b8e 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -2,6 +2,12 @@ namespace App\Support; +use App\Models\DunningCase; +use App\Models\FailedCheckout; +use App\Models\Incident; +use App\Services\Deployment\UpdateChannel; +use Illuminate\Support\Facades\Cache; + /** * The navigation of both shells, in one place. * @@ -41,19 +47,52 @@ final class Navigation ]; } - /** @return array}> */ + /** + * Die Konsole, geordnet nach dem, was man mit ihr tut. + * + * Es waren achtundzwanzig Eintraege in sieben Gruppen, und „System" war der + * Platz fuer alles, was sonst nirgends hinpasste: Mail-Einrichtung neben + * einem Rechtsdokument, neben den persoenlichen Kontoeinstellungen, neben + * der Mitarbeiterverwaltung — und ganz unten die Seite, die sagt, was + * liegt. Der Betreiber hat es so beschrieben: „offene Punkte ist der letzte + * Punkt, dann Rolle drueber und Einstellungen wieder drueber". + * + * Zwei Regeln ordnen es jetzt. Was man einmal einrichtet, verlaesst die + * Leiste in den Einrichtungsbereich (admin.setup). Und die drei Seiten, auf + * denen etwas auf jemanden WARTET, stehen ganz oben, mit einer Zahl daneben + * — das ist die Frage, mit der man eine Konsole oeffnet. + * + * Jede Seite steht genau EINMAL. Stoerungen sind aus „Betrieb" nach oben + * gezogen und Zahlungsprobleme aus „Geld"; sie sind dort nicht zusaetzlich + * geblieben. Eine Seite an zwei Stellen ist ihre eigene Verwirrung. + * + * Ein Eintrag ist [Route, Symbol, Uebersetzungsschluessel, Berechtigung], + * optional gefolgt vom Schluessel, unter dem attentionCounts() seine Zahl + * fuehrt. + * + * @return array}> + */ public static function console(): array { return [ - // Allein und ohne Überschrift: die Seite, auf der man landet. + // Allein und ohne Ueberschrift: die Seite, auf der man landet. ['label' => null, 'items' => [ ['admin.overview', 'gauge', 'overview', null], ]], + // Wo etwas auf jemanden wartet. Der einzige Block, der eine Zahl + // traegt — und der einzige, der nach Dringlichkeit sortiert ist + // statt nach Gegenstand. + ['label' => __('admin.nav_group.attention'), 'items' => [ + // Ohne Berechtigung: wer die Konsole oeffnen darf, soll wissen, + // worauf er sich verlassen kann und worauf nicht. + ['admin.open-work', 'alert-triangle', 'open_work', null, 'open_work'], + ['admin.incidents', 'bell', 'incidents', null, 'incidents'], + ['admin.payment-problems', 'alert-triangle', 'payment_problems', 'billing.manage', 'payment_problems'], + ]], // 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.plans', 'tag', 'plans', 'plans.manage'], ]], // Die Maschinen und ihre Adressen — der Bestand, den man ansieht. ['label' => __('admin.nav_group.fleet'), 'items' => [ @@ -63,86 +102,149 @@ final class Navigation // 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. + // Eine LISTE VON GEGENSTELLEN, die man beim Arbeiten ansieht, + // so wie die Hostliste — nicht etwas, das man einrichtet. ['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. + // Was gerade laeuft. Vorgaenge, kein Bestand. Stoerungen standen + // hier und stehen jetzt oben: sie sind kein Vorgang, den man + // verfolgt, sondern einer, der jemanden braucht. ['label' => __('admin.nav_group.operations'), 'items' => [ ['admin.provisioning', 'activity', 'provisioning', null], ['admin.maintenance', 'alert-triangle', 'maintenance', null], - ['admin.incidents', 'bell', 'incidents', null], - // Läuft, statt eingerichtet zu werden — deshalb hier und nicht - // neben admin.mail unter System. + // Laeuft, statt eingerichtet zu werden — deshalb hier und nicht + // im Einrichtungsbereich neben admin.mail. ['admin.mail-pace', 'gauge', 'mail_pace', 'mail.manage'], ]], // Alles, wo Geld drinsteht. ['label' => __('admin.nav_group.billing'), 'items' => [ ['admin.revenue', 'trending-up', 'revenue', null], ['admin.invoices', 'file-text', 'invoices', 'site.manage'], - ['admin.payment-problems', 'alert-triangle', 'payment_problems', 'billing.manage'], // Eigener Eintrag, kein Abschnitt der Einstellungen: was hier // gesetzt wird, steht auf einem Rechtsdokument, und neben dem - // Sichtbarkeitsschalter ändert das jemand im Vorbeigehen. + // Sichtbarkeitsschalter aendert das jemand im Vorbeigehen. ['admin.finance', 'receipt', 'finance', 'site.manage'], ]], - // Was geschrieben wird — gelesen beim Antworten, nicht beim - // Einrichten. + // Was geschrieben wird — gelesen beim Antworten. Die Vorlagen und + // ihre Vorschau sind dagegen etwas, das man einmal schreibt: sie + // stehen im Einrichtungsbereich. ['label' => __('admin.nav_group.post'), 'items' => [ ['admin.inbox', 'mail', 'inbox', 'customers.manage'], ['admin.mail-log', 'send', 'mail_log', 'customers.manage'], - ['admin.templates', 'file-text', 'templates', 'customers.manage'], - // 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'], ]], - // 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 - // capability — see App\Livewire\Admin\Integrations — so the - // entry stays visible to exactly who could reach at least one - // of the two before: an array here means "any of these", - // never "all of these" (contrast a single string elsewhere in - // this file, which x-shell.nav checks with plain ->can()). - ['admin.integrations', 'plug', 'integrations', ['hosts.manage', 'secrets.manage']], - // Every check App\Support\Readiness knows about, gathered on - // 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 - // to change something about their own access. The standalone - // page stays reachable at its route, because that is where - // RequireOperatorTwoFactor sends somebody who may not open - // anything else yet, Settings included. - ['admin.settings', 'settings', 'settings', null], - ['admin.roles', 'users', 'roles', 'staff.manage'], - // Ohne Berechtigung: wer die Konsole öffnen darf, soll wissen, - // worauf er sich verlassen kann und worauf nicht. - ['admin.open-work', 'alert-triangle', 'open_work', null], + // Ohne Ueberschrift und ganz unten: eine Tuer, keine Gruppe. + ['label' => null, 'items' => [ + ['admin.setup', 'settings', 'setup', null], ]], ]; } + /** + * Die Zahlen fuer den Block „Braucht dich". + * + * Diese Leiste rendert auf JEDER Konsolenseite. Ohne Zwischenspeicher + * waeren das zwei Abfragen je Seitenaufruf — eine Abgabe, die man spaeter + * sucht, wenn die Seiten langsam werden. Eine Minute ist kurz genug, dass + * niemand eine geloeste Stoerung noch lange gezaehlt sieht, und lang genug, + * dass ein Klick durch fuenf Seiten die Datenbank einmal fragt statt zehnmal. + * + * „Offene Punkte" kostet ohnehin nichts: das Register liegt im Code. + * + * @return array + */ + public static function attentionCounts(): array + { + return Cache::remember('nav.attention', now()->addMinute(), fn () => [ + 'open_work' => count(OpenWork::all()), + 'incidents' => Incident::query()->whereNull('resolved_at')->count(), + // Beides, weil die Seite beides zeigt: eine offene Mahnung und ein + // gescheiterter Bezahlvorgang sind zwei Wege zu demselben Problem. + 'payment_problems' => DunningCase::query()->whereNull('settled_at')->count() + + FailedCheckout::query()->whereNull('resolved_at')->count(), + // Die Aktualisierung, obwohl sie in keinem Eintrag der Leiste + // steht: sie steht im FUSS, neben der laufenden Version. + // + // Der Betreiber hat es so gemeldet: „Einstellungen ist in der + // Einrichtung, wegen den Versionsupdates — man sieht es nicht, + // ohne genau hinzuklicken." Die Seite dorthin zu verschieben war + // richtig (ihr Konto-Teil gehoert dahin), die Folge nicht: eine + // verfuegbare Aktualisierung ist nichts, wonach man sucht. + // + // Hier statt in einem eigenen Eintrag, weil die Version im Fuss + // ohnehin schon steht. Ein vierter Eintrag unter „Braucht dich" + // haette dieselbe Auskunft an einer zweiten Stelle wiederholt. + 'update' => app(UpdateChannel::class)->state()['available'] ? 1 : 0, + ]); + } + + /** + * Der Einrichtungsbereich: die neun Seiten, die man einmal einrichtet. + * + * Sie stehen hier und nicht im Livewire-Bauteil, das sie zeichnet — aus + * demselben Grund, aus dem die Seitenleiste hier steht: es ist Navigation, + * und wer sie liest, sind drei (die Kachelseite, die Markierung „du bist + * hier" und die Brotkrume). Drei Leser, eine Liste. + * + * Dieselbe Form wie ein Eintrag der Leiste, und dieselbe Regel: ein Array + * bei der Berechtigung heisst „eine davon genuegt", nie „alle". + * + * @return array> + */ + public static function setup(): array + { + return [ + // Womit und woraus dieses Haus schreibt. + 'delivery' => [ + ['admin.mail', 'mail', 'mail', 'mail.manage'], + ['admin.templates', 'file-text', 'templates', 'customers.manage'], + ['admin.mail.preview', 'send', 'mail_preview', 'mail.manage'], + ], + // Was verkauft wird. + 'offer' => [ + ['admin.plans', 'tag', 'plans', 'plans.manage'], + ], + // Wer hereindarf und womit dieses Haus nach aussen spricht. + 'access' => [ + ['admin.roles', 'users', 'roles', 'staff.manage'], + ['admin.settings', 'settings', 'settings', null], + ['admin.integrations', 'plug', 'integrations', ['hosts.manage', 'secrets.manage']], + ], + // Der Zustand des Hauses selbst. + 'house' => [ + ['admin.readiness', 'shield-check', 'readiness', ['hosts.manage', 'secrets.manage']], + ['admin.dpa', 'file-text', 'dpa', 'dpa.manage'], + ], + ]; + } + + /** + * Ist dieser Eintrag der, auf dem man gerade steht? + * + * Fuer alles ausser dem Einrichtungsbereich ist das die Route selbst. Der + * Einrichtungsbereich bleibt zusaetzlich markiert, solange man auf einer + * der Seiten steht, die er sammelt — sonst verliert die Leiste auf allen + * neun verschobenen Seiten ihr „du bist hier", und man steht in einer + * Konsole, die nicht mehr sagt, wo man ist. (Codex R15, P2 am Umbau.) + */ + public static function isCurrent(string $route, bool $console = false): bool + { + if ($console ? AdminArea::routeIs($route) : request()->routeIs($route)) { + return true; + } + + if ($console && $route === 'admin.setup') { + foreach (self::setup() as $kacheln) { + foreach ($kacheln as [$ziel]) { + if (AdminArea::routeIs($ziel)) { + return true; + } + } + } + } + + return false; + } + /** * The label of the entry the current request belongs to. * @@ -167,6 +269,20 @@ final class Navigation } } + // Die neun verschobenen Seiten stehen nicht mehr in der Leiste. Ohne + // diesen zweiten Blick verloeren sie ihre Brotkrume und die Konsole + // sagte auf ihnen nur noch „Konsole" — derselbe Schaden wie die + // fehlende Markierung, aus derselben Ursache. + if ($console) { + foreach (self::setup() as $kacheln) { + foreach ($kacheln as [$route, , $key]) { + if (AdminArea::routeIs($route)) { + return __($prefix.$key); + } + } + } + } + return null; } } diff --git a/lang/de/admin.php b/lang/de/admin.php index ef6d14e..52adfa2 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -5,7 +5,9 @@ return [ 'badge' => 'Admin', 'to_portal' => 'Zum Kundenportal', + 'nav_update_waiting' => 'Aktualisierung verfügbar', 'nav_group' => [ + 'attention' => 'Braucht dich', 'customers' => 'Kunden', 'fleet' => 'Flotte', 'operations' => 'Betrieb', @@ -15,6 +17,7 @@ return [ ], 'nav' => [ + 'setup' => 'Einrichtung', 'proxy_hosts' => 'Hostnamen', 'dpa' => 'AV-Vertrag', 'mail_preview' => 'E-Mail-Vorschau', diff --git a/lang/de/setup.php b/lang/de/setup.php new file mode 100644 index 0000000..80bec24 --- /dev/null +++ b/lang/de/setup.php @@ -0,0 +1,36 @@ + 'Konsole', + 'title' => 'Einrichtung', + 'subtitle' => 'Was einmal eingerichtet wird und danach gilt. Diese Seiten standen bis hierher zwischen dem Tagesgeschäft in der Seitenleiste — sie sind gleich wichtig und ungleich häufig.', + + 'group' => [ + 'delivery' => 'Zustellung', + 'offer' => 'Angebot', + 'access' => 'Zugang', + 'house' => 'Das Haus', + ], + 'group_hint' => [ + 'delivery' => 'Womit und woraus dieses Haus schreibt.', + 'offer' => 'Was verkauft wird — Familien, Versionen, Preise.', + 'access' => 'Wer hereindarf, und womit dieses Haus nach außen spricht.', + 'house' => 'Der Zustand der Installation selbst.', + ], + + 'tile' => [ + 'mail' => 'Der Server, die Postfächer und die Frage, welche Mailart aus welchem verschickt wird.', + 'templates' => 'Die Texte, die dieses Haus verschickt.', + 'mail_preview' => 'Eine Mail ansehen, bevor ein Kunde sie sieht.', + 'plans' => 'Der Katalog: Paketfamilien, Versionen, Preise und Zusatzmodule.', + 'roles' => 'Wer die Konsole öffnen darf und was er darin tun kann.', + 'settings' => 'Das eigene Konto, Zwei-Faktor, und die Version dieser Installation.', + 'integrations' => 'Stripe, DNS, die Überwachung — und die Datei, die unter allem liegt.', + 'readiness' => 'Jede Prüfung, die diese Installation über sich selbst kennt.', + 'dpa' => 'Der Auftragsverarbeitungsvertrag, der für die ganze Installation gilt.', + ], +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index d862065..b7f0c4b 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -5,7 +5,9 @@ return [ 'badge' => 'Admin', 'to_portal' => 'To customer portal', + 'nav_update_waiting' => 'Update available', 'nav_group' => [ + 'attention' => 'Needs you', 'customers' => 'Customers', 'fleet' => 'Fleet', 'operations' => 'Operations', @@ -15,6 +17,7 @@ return [ ], 'nav' => [ + 'setup' => 'Setup', 'proxy_hosts' => 'Hostnames', 'dpa' => 'Processing agreement', 'mail_preview' => 'Mail preview', diff --git a/lang/en/setup.php b/lang/en/setup.php new file mode 100644 index 0000000..0b88241 --- /dev/null +++ b/lang/en/setup.php @@ -0,0 +1,32 @@ + 'Console', + 'title' => 'Setup', + 'subtitle' => 'What is configured once and then holds. These pages used to sit among the daily work in the sidebar — equally important, and nowhere near as often needed.', + + 'group' => [ + 'delivery' => 'Delivery', + 'offer' => 'Offering', + 'access' => 'Access', + 'house' => 'The installation', + ], + 'group_hint' => [ + 'delivery' => 'What this house writes with, and what it writes from.', + 'offer' => 'What is sold — families, versions, prices.', + 'access' => 'Who may come in, and what this house speaks to outside.', + 'house' => 'The state of the installation itself.', + ], + + 'tile' => [ + 'mail' => 'The server, the mailboxes, and which kind of mail leaves from which.', + 'templates' => 'The texts this house sends.', + 'mail_preview' => 'Look at a mail before a customer does.', + 'plans' => 'The catalogue: plan families, versions, prices and add-ons.', + 'roles' => 'Who may open the console, and what they may do in it.', + 'settings' => 'Your own account, two-factor, and this installation’s version.', + 'integrations' => 'Stripe, DNS, monitoring — and the file underneath all of it.', + 'readiness' => 'Every check this installation knows about itself.', + 'dpa' => 'The data processing agreement that applies to the whole installation.', + ], +]; diff --git a/resources/views/components/shell/nav.blade.php b/resources/views/components/shell/nav.blade.php index d4d164b..9f07195 100644 --- a/resources/views/components/shell/nav.blade.php +++ b/resources/views/components/shell/nav.blade.php @@ -8,6 +8,13 @@ 'console' => false, 'footer' => null, ]) +@php + // Die Zahlen fuer den Block „Braucht dich". Einmal je Seitenaufruf geholt, + // nicht je Eintrag — und dahinter ein Zwischenspeicher, weil diese Leiste + // auf JEDER Konsolenseite rendert. Das Portal hat keine, dort bleibt es + // ein leeres Feld ohne Abfrage. + $counts = $console ? \App\Support\Navigation::attentionCounts() : []; +@endphp {{-- The sidebar, shared by both shells. @@ -77,22 +84,65 @@

{{ $group['label'] }}

@endif @endforeach + {{-- Die laufende Version — und, wenn es eine neuere gibt, dass es sie gibt. + Sie stand hier schon, aber als toter Text: die Seite, auf der man + etwas damit tun kann, liegt seit dem Umbau der Leiste im + Einrichtungsbereich. Der Betreiber hat genau das gemeldet — „man sieht + es nicht, ohne genau hinzuklicken". + + Also bleibt die Zeile, wo sie war, und wird zum Weg dorthin. Ist etwas + verfuegbar, sagt sie es in der Akzentfarbe statt in Grau; ist nichts + verfuegbar, bleibt sie so leise wie vorher. Eine Zeile, die immer + ruft, ruft nie. --}} @if ($footer) -

{{ $footer }}

+ @php $updateWaiting = ($counts['update'] ?? 0) > 0; @endphp + @if ($console) + $updateWaiting, + 'text-muted hover:text-ink hover:bg-surface-hover' => ! $updateWaiting, + ])> + {{ $footer }} + @if ($updateWaiting) + {{ __('admin.nav_update_waiting') }} + @endif + + @else +

{{ $footer }}

+ @endif @endif diff --git a/resources/views/components/ui/nav-item.blade.php b/resources/views/components/ui/nav-item.blade.php index aa4beb8..b675d63 100644 --- a/resources/views/components/ui/nav-item.blade.php +++ b/resources/views/components/ui/nav-item.blade.php @@ -28,10 +28,20 @@ merge(['class' => $base.' cursor-not-allowed font-medium text-faint']) }}> @isset($icon){{ $icon }}@endisset {{ $slot }} + {{-- Rechtsbuendig, per ms-auto statt justify-between: der Abstand + zwischen Symbol und Text bleibt damit derselbe wie bei jedem + Eintrag ohne Plakette. shrink-0, damit eine zweistellige Zahl den + Text nicht umbricht (R18: ein Eintrag ist einzeilig). --}} + @isset($badge){{ $badge }}@endisset @else merge(['class' => $base.' '.$state]) }}> @isset($icon){{ $icon }}@endisset {{ $slot }} + {{-- Rechtsbuendig, per ms-auto statt justify-between: der Abstand + zwischen Symbol und Text bleibt damit derselbe wie bei jedem + Eintrag ohne Plakette. shrink-0, damit eine zweistellige Zahl den + Text nicht umbricht (R18: ein Eintrag ist einzeilig). --}} + @isset($badge){{ $badge }}@endisset @endif diff --git a/resources/views/livewire/admin/setup.blade.php b/resources/views/livewire/admin/setup.blade.php new file mode 100644 index 0000000..c0a18fa --- /dev/null +++ b/resources/views/livewire/admin/setup.blade.php @@ -0,0 +1,36 @@ +
+
+

{{ __('setup.eyebrow') }}

+

+ {{ __('setup.title') }} +

+

{{ __('setup.subtitle') }}

+
+ + @foreach ($groups as $gruppe => $kacheln) +
+

{{ __('setup.group.'.$gruppe) }}

+

{{ __('setup.group_hint.'.$gruppe) }}

+ + {{-- Kacheln statt einer Reiterleiste: neun Reiter waeren wieder + die Wurst, die hier gerade abgeschafft wird. Und Kacheln + tragen einen Satz darueber, WAS die Seite einrichtet — in + einer Leiste steht nur ihr Name. --}} + +
+ @endforeach +
diff --git a/routes/admin.php b/routes/admin.php index bc7ee96..0ffb7dc 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -127,6 +127,10 @@ Route::get('/mail', Admin\Mail::class)->name('mail'); // eines weiteren Abschnitts auf admin/mail (siehe deren Kommentar oben), weil // diese Seite den laufenden Betrieb beobachtet statt etwas einzurichten. Route::get('/mail-pace', Admin\MailPace::class)->name('mail-pace'); +// Die Tuer zu allem, was man einmal einrichtet — neun Seiten, die bis hierher +// zwischen dem Tagesgeschaeft in der Seitenleiste standen. Ohne eigene +// Berechtigung: die Seite zeigt nur Kacheln, und jede prueft ihre eigene. +Route::get('/setup', Admin\Setup::class)->name('setup'); Route::get('/integrations', Admin\Integrations::class)->name('integrations'); // The former admin.secrets and admin.infrastructure pages, merged into the // one above — grouped by what each value configures, not by which of the two diff --git a/tests/Feature/Admin/ConsoleNavigationTest.php b/tests/Feature/Admin/ConsoleNavigationTest.php index 121d97e..c2f5f8e 100644 --- a/tests/Feature/Admin/ConsoleNavigationTest.php +++ b/tests/Feature/Admin/ConsoleNavigationTest.php @@ -1,8 +1,11 @@ */ function consoleItems(): array { @@ -41,10 +64,37 @@ it('verliert beim Umsortieren keinen Eintrag und legt keinen doppelt an', functi // 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. - // 28 statt 27 seit admin.mail-pace (Versandtakt) dazukam. - expect($routes)->toHaveCount(28); + // Auch über beide Orte zusammen darf nichts doppelt stehen: eine Seite in + // der Leiste UND als Kachel wäre die Verwirrung, die dieser Umbau + // abschaffen soll. + $alle = consoleRouteNames(); + expect($alle)->toHaveCount(count(array_unique($alle))); +}); + +it('laesst keine Seite fallen, die vor dem Umbau in der Leiste stand', function () { + // Die achtundzwanzig Einträge von vor dem 4.8.2026, ausgeschrieben statt + // aus der Geschichte gelesen: ein Test, der sich seine Erwartung aus + // demselben Repository holt, das er prüft, prüft nichts. + $vorher = [ + 'admin.overview', 'admin.customers', 'admin.instances', 'admin.plans', + 'admin.hosts', 'admin.datacenters', 'admin.capacity', 'admin.proxy-hosts', 'admin.vpn', + 'admin.provisioning', 'admin.maintenance', 'admin.incidents', 'admin.mail-pace', + 'admin.revenue', 'admin.invoices', 'admin.payment-problems', 'admin.finance', + 'admin.inbox', 'admin.mail-log', 'admin.templates', 'admin.mail.preview', + 'admin.mail', 'admin.integrations', 'admin.readiness', 'admin.dpa', + 'admin.settings', 'admin.roles', 'admin.open-work', + ]; + + expect(array_values(array_diff($vorher, consoleRouteNames())))->toBe([]); +}); + +it('fuehrt keine Kachel, deren Route es nicht gibt', function () { + foreach (Navigation::setup() as $gruppe => $kacheln) { + foreach ($kacheln as [$route, , $key]) { + expect(Route::has($route))->toBeTrue("Route {$route} (Kachel {$key} in {$gruppe}) existiert nicht."); + expect(__('setup.tile.'.$key))->not->toBe('setup.tile.'.$key, "Kachel {$key} hat keinen Text."); + } + } }); it('führt keinen Eintrag, dessen Route es nicht gibt', function () { @@ -78,10 +128,18 @@ it('stellt jede Gruppe unter eine Überschrift — außer der Übersicht', funct ->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) { + // Die LETZTE trägt allein die Tür zum Einrichtungsbereich und braucht + // ebenfalls keine: eine Überschrift über einem einzigen Eintrag benennt + // nur ihn selbst noch einmal. + $letzte = $groups[count($groups) - 1]; + expect($letzte['label'])->toBeNull() + ->and($letzte['items'])->toHaveCount(1) + ->and($letzte['items'][0][0])->toBe('admin.setup'); + + // Alle dazwischen haben eine. Eine namenlose Gruppe MITTEN in der Leiste + // wäre wieder der Zustand, aus dem diese Arbeit kommt: schwebende Einträge + // ohne Zugehörigkeit. + foreach (array_slice($groups, 1, -1) as $group) { expect($group['label'])->not->toBeNull()->and($group['label'])->not->toBe(''); } }); @@ -124,3 +182,109 @@ it('zeigt die Gruppe, sobald der Betreiber einen ihrer Einträge sehen darf', fu } } }); + +it('zaehlt die Zahlen neben den Eintraegen nur einmal je Minute', function () { + // Diese Leiste rendert auf JEDER Konsolenseite. Ohne Zwischenspeicher wären + // das drei Abfragen je Seitenaufruf — eine Abgabe, die man erst sucht, wenn + // die Konsole längst langsam ist. + Cache::forget('nav.attention'); + + $erste = Navigation::attentionCounts(); + + expect(Cache::has('nav.attention'))->toBeTrue() + ->and(Navigation::attentionCounts())->toBe($erste); +}); + +it('zeigt keine Plakette, laesst den Eintrag aber stehen, wenn nichts offen ist', function () { + // Eine Seite, die verschwindet, sobald nichts offen ist, ist genau dann + // nicht erreichbar, wenn man nachsehen will, ob wirklich nichts offen ist. + Cache::forever('nav.attention', ['open_work' => 0, 'incidents' => 0, 'payment_problems' => 0]); + + $this->actingAs(admin(), 'operator'); + $html = $this->get(route('admin.overview'))->assertOk()->getContent(); + + expect($html)->toContain(navLabel(__('admin.nav_group.attention'))) + ->and($html)->toContain(__('admin.nav.incidents')); +}); + +it('nennt die Zahl, sobald etwas offen ist', function () { + Cache::forever('nav.attention', ['open_work' => 0, 'incidents' => 7, 'payment_problems' => 0]); + + $this->actingAs(admin(), 'operator'); + $html = $this->get(route('admin.overview'))->assertOk()->getContent(); + + expect($html)->toContain('>7'); +}); + +it('zeigt im Einrichtungsbereich nur Kacheln, die dieser Betreiber oeffnen darf', function () { + // Eine Kachel, die zu einer 403 führt, ist schlimmer als eine fehlende: + // sie sieht aus wie ein Weg. + Livewire::actingAs(admin(), 'operator') + ->test(Setup::class) + ->assertSee(__('setup.group.delivery')) + ->assertSee(__('setup.tile.plans')); + + $html = Livewire::actingAs(Operator::factory()->role('Read-only')->create(), 'operator') + ->test(Setup::class) + ->html(); + + expect($html)->not->toContain(__('setup.tile.plans')); +}); + +it('haelt die Tuer zur Einrichtung markiert, solange man dahinter steht', function () { + // Codex R15, P2 am Umbau: die neun verschobenen Seiten stehen nicht mehr in + // der Leiste. Ohne diese Regel ist auf keiner von ihnen ein Eintrag + // markiert — die Konsole sagt dann nicht mehr, wo man ist. + $this->actingAs(admin(), 'operator'); + + foreach (Navigation::setup() as $kacheln) { + foreach ($kacheln as [$route, , $key]) { + $this->get(route($route)); + + expect(Navigation::isCurrent('admin.setup', console: true)) + ->toBeTrue("Auf {$key} ({$route}) ist die Einrichtung nicht markiert."); + } + } +}); + +it('nennt eine verschobene Seite weiter in der Brotkrume', function () { + // Derselbe Schaden aus derselben Ursache, nur an der anderen Stelle: + // currentLabel() sah nur in der Leiste nach und lieferte fuer die neun + // Seiten null — der Kopf haette dort nur noch „Konsole" gesagt. + $this->actingAs(admin(), 'operator'); + $this->get(route('admin.plans')); + + expect(Navigation::currentLabel(console: true))->toBe(__('admin.nav.plans')); +}); + +it('markiert die Einrichtung nicht, wenn man ganz woanders steht', function () { + // Die Kehrseite: eine Markierung, die immer leuchtet, ist keine. + $this->actingAs(admin(), 'operator'); + $this->get(route('admin.customers')); + + expect(Navigation::isCurrent('admin.setup', console: true))->toBeFalse(); +}); + +it('macht die Versionszeile im Fuss zum Weg zur Aktualisierung', function () { + // Gemeldet vom Betreiber: „Einstellungen ist in der Einrichtung, wegen den + // Versionsupdates — man sieht es nicht, ohne genau hinzuklicken." Die + // Version stand im Fuss schon, aber als toter Text. + Cache::forever('nav.attention', ['open_work' => 0, 'incidents' => 0, 'payment_problems' => 0, 'update' => 0]); + + $this->actingAs(admin(), 'operator'); + $html = $this->get(route('admin.overview'))->assertOk()->getContent(); + + expect($html)->toContain(route('admin.settings').'?tab=installation') + // Solange nichts ansteht, bleibt sie leise: eine Zeile, die immer + // ruft, ruft nie. + ->and($html)->not->toContain(__('admin.nav_update_waiting')); +}); + +it('sagt im Fuss, dass eine Aktualisierung wartet', function () { + Cache::forever('nav.attention', ['open_work' => 0, 'incidents' => 0, 'payment_problems' => 0, 'update' => 1]); + + $this->actingAs(admin(), 'operator'); + $html = $this->get(route('admin.overview'))->assertOk()->getContent(); + + expect($html)->toContain(__('admin.nav_update_waiting')); +}); diff --git a/tests/Feature/Admin/PlanAdminTest.php b/tests/Feature/Admin/PlanAdminTest.php index 26a3ea0..0c5568e 100644 --- a/tests/Feature/Admin/PlanAdminTest.php +++ b/tests/Feature/Admin/PlanAdminTest.php @@ -510,9 +510,13 @@ it('closes the marketing modal to operators without the capability', function () }); it('shows the console entry only to those who may use it', function () { - $this->actingAs(owner(), 'operator')->get(route('admin.overview'))->assertSee(__('admin.nav.plans')); + // Der Katalog aendert sich selten und steht seit dem 4.8.2026 nicht mehr in + // der Seitenleiste, sondern als Kachel im Einrichtungsbereich. Die + // Zusicherung ist dieselbe geblieben — wer ihn nicht pflegen darf, sieht + // auch den Weg dorthin nicht. + $this->actingAs(owner(), 'operator')->get(route('admin.setup'))->assertSee(__('admin.nav.plans')); $this->actingAs(Operator::factory()->role('Support')->create(), 'operator') - ->get(route('admin.overview')) + ->get(route('admin.setup')) ->assertDontSee(__('admin.nav.plans')); });