diff --git a/VERSION b/VERSION index 10e70bd..5574de9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.27 +1.3.28 diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index 4065a7c..8d9f78c 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -7,6 +7,7 @@ use App\Services\Billing\AddonCatalogue; use App\Services\Billing\CustomDomainAccess; use App\Services\Billing\PlanCatalogue; use App\Services\Provisioning\HostCapacity; +use App\Support\CompanyProfile; use App\Support\ProvisioningSettings; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Log; @@ -195,8 +196,24 @@ class LandingController extends Controller // print two different figures for the same thing. $modules = $this->modulePrices(); + $tax = CompanyProfile::taxRate(); + $setupNet = CompanyProfile::setupFeeCents(); + return view('landing', [ 'plans' => $plans, + // What the page has to say about tax and about the one-off fee, in + // one place. Both are console-editable figures, and both were + // previously sentences on the page: "Netto pro Monat" and "zzgl. + // einmaliger Einrichtung" — the second of which never named a + // number at all, so a visitor learned only that there was one. + 'vat' => [ + 'rate' => $tax, + 'label' => rtrim(rtrim(number_format($tax, 1, ',', '.'), '0'), ','), + ], + 'setup' => $setupNet === 0 ? null : [ + 'gross' => $this->money($this->gross($setupNet), Subscription::catalogueCurrency()), + 'net' => $this->money($setupNet, Subscription::catalogueCurrency()), + ], 'baseline' => $this->baseline($plans), 'comparison' => $this->comparison($plans, $modules), 'addons' => $this->addons($modules), @@ -481,7 +498,12 @@ class LandingController extends Controller // template renders that sensibly instead of an empty gap. 'audience' => (string) ($plan['audience'] ?? ''), 'note' => (string) ($plan['note'] ?? ''), - 'price' => $this->money((int) $plan['price_cents'], (string) $plan['currency']), + // GROSS, because a private customer must never be quoted a + // figure that grows at checkout. A business reads the net one + // underneath and gets the VAT back anyway; a consumer does not, + // and for them the net figure was simply the wrong number. + 'price' => $this->money($this->gross((int) $plan['price_cents']), (string) $plan['currency']), + 'price_net' => $this->money((int) $plan['price_cents'], (string) $plan['currency']), 'storage' => $this->storage((int) $plan['quota_gb']), 'traffic' => $this->storage((int) $plan['traffic_gb']), 'seats' => (int) $plan['seats'], @@ -533,6 +555,23 @@ class LandingController extends Controller * Whole units when the amount is whole — a price sheet reads "179", not * "179,00". */ + /** + * A net figure with the domestic VAT put back on. + * + * The same rate an invoice would use, read from the same place + * (App\Services\Billing\TaxTreatment does too) — a percentage written + * into the marketing page would be the second source that makes the sheet + * and the invoice disagree. + * + * Cross-border reverse charge is deliberately not modelled here: it depends + * on a VERIFIED VAT id, which a visitor to a public page does not have. The + * page quotes the domestic price and the invoice applies the treatment. + */ + private function gross(int $netCents): int + { + return (int) round($netCents * (1 + CompanyProfile::taxRate() / 100)); + } + private function money(int $cents, string $currency): string { $amount = $cents % 100 === 0 @@ -554,7 +593,10 @@ class LandingController extends Controller */ private function modulePrice(int $cents): string { - return $this->money($cents, Subscription::catalogueCurrency()); + // Gross, like every other figure on this page. A sheet that quotes the + // package with VAT and the module without it is a sheet where the two + // numbers cannot be added up. + return $this->money($this->gross($cents), Subscription::catalogueCurrency()); } private function storage(int $gb): string diff --git a/app/Livewire/Admin/Finance.php b/app/Livewire/Admin/Finance.php index ad96894..7ea2395 100644 --- a/app/Livewire/Admin/Finance.php +++ b/app/Livewire/Admin/Finance.php @@ -36,6 +36,15 @@ class Finance extends Component public float $taxRate = 20.0; + /** + * The one-off setup fee, in euro, as a person types it. + * + * Held in euro rather than cents because this is a form field, and asking + * an operator to enter 9900 for ninety-nine euro is how a fee ends up a + * hundred times too large. Converted on the way in and out. + */ + public string $setupFee = '0'; + /** * A freshly minted collection key, shown once and never stored. * @@ -63,6 +72,7 @@ class Finance extends Component $this->company = CompanyProfile::all(); $this->logoPath = (string) ($this->company['logo_path'] ?? ''); $this->taxRate = CompanyProfile::taxRate(); + $this->setupFee = number_format(CompanyProfile::setupFeeCents() / 100, 2, '.', ''); } public function saveCompany(): void @@ -91,6 +101,7 @@ class Finance extends Component // ever rendered from then on. 'logo' => 'nullable|image|mimes:png,webp|max:1024', 'taxRate' => 'required|numeric|min:0|max:100', + 'setupFee' => 'required|numeric|min:0|max:100000', ]); if ($this->logo !== null) { @@ -111,6 +122,10 @@ class Finance extends Component CompanyProfile::put($data['company']); Settings::set('company.tax_rate', (float) $data['taxRate']); + // round(), not a cast: (int) (99.95 * 100) is 9994 on a binary float, + // and a fee one cent short of what was typed is the kind of thing + // nobody finds until a customer does. + Settings::set('company.setup_fee_cents', (int) round(((float) $data['setupFee']) * 100)); $this->dispatch('notify', message: __('finance.company_saved')); } diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index e10d9c1..297a8f7 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -17,6 +17,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Livewire\Attributes\Layout; +use Livewire\Attributes\Url; use Livewire\Attributes\Validate; use Livewire\Component; use Spatie\Permission\Models\Role; @@ -27,10 +28,19 @@ use Symfony\Component\HttpFoundation\IpUtils; * staff management. All staff mutations are capability-gated server-side and * protect the last-Owner and self-role invariants transactionally. * - * Two-factor ENROLMENT lives on its own page, Admin\TwoFactorSetup — see - * RequireOperatorTwoFactor for why. Only the POLICY switch - * (saveTwoFactorPolicy(), below) stays here: only someone who already - * satisfies the policy should be the one changing it. + * Two-factor ENROLMENT is the `two-factor` tab, rendered by the unchanged + * Admin\TwoFactorSetup component. That component also still answers at its own + * route, because RequireOperatorTwoFactor sends an operator there who may not + * open anything else yet — this page included. One component, two ways in: the + * everyday one is a tab beside the other settings, the other is a gate. + * + * The page is TABBED rather than one column of eleven cards. It had grown to + * three headed sections and roughly two thousand pixels of scroll, and the tab + * a person wants is a property of what they came to do, not of how far they + * scroll. The choice lives in the query string (?tab=…), so a reload, a + * bookmark and the back button all land where the operator was — a tab state + * kept only in the component is a tab state that resets on every refresh, and + * this page refreshes itself while an update runs. */ #[Layout('layouts.admin')] class Settings extends Component @@ -38,6 +48,25 @@ class Settings extends Component use ChangesOwnPassword; use ResolvesOperator; + /** + * The tabs, in the order they are shown. + * + * The list IS the schema: it validates the query string, builds the tab + * bar, and decides which section renders. A fourth place to add a tab is a + * fourth place to forget one. + */ + public const TABS = ['installation', 'account', 'two-factor', 'team']; + + /** + * Which one is open, taken from and written back to the URL. + * + * `except` keeps ?tab=installation out of the address bar: the default + * needs no parameter, and a query string that appears the moment a page + * loads makes every link to it look different from the one in the menu. + */ + #[Url(except: 'installation')] + public string $tab = 'installation'; + /** This page changes the signed-in OPERATOR's password, never a portal one. */ protected function passwordAccountGuard(): string { @@ -68,10 +97,25 @@ class Settings extends Component public function mount(): void { + // A tab name out of the URL is a string a stranger typed. Anything + // unknown falls back to the first tab rather than rendering a settings + // page with no section on it at all. Before the early return, because + // the tab has to be valid whether or not an operator resolves. + if (! in_array($this->tab, self::TABS, true)) { + $this->tab = self::TABS[0]; + } + if (! $operator = $this->currentOperator()) { return; } + // ?tab=team without the capability would open a tab that renders + // nothing — the section is gated on its own, as it must be. Landing on + // the first tab says the same thing without the blank page. + if ($this->tab === 'team' && ! $operator->can('staff.manage')) { + $this->tab = self::TABS[0]; + } + $this->name = $operator->name; $this->email = $operator->email; $this->requireTwoFactor = AppSettings::bool('console.require_2fa', false); @@ -545,6 +589,12 @@ class Settings extends Component 'roles' => Operator::OPERATOR_ROLES, 'canManageStaff' => $operator?->can('staff.manage') ?? false, 'isOwner' => $operator?->hasRole('Owner') ?? false, + // Only the tabs this operator can actually open. A tab that raises + // a 403 is worse than one that is not there. + 'tabs' => array_values(array_filter( + self::TABS, + fn (string $tab) => $tab !== 'team' || ($operator?->can('staff.manage') ?? false), + )), ]); } } diff --git a/app/Livewire/Admin/TwoFactorSetup.php b/app/Livewire/Admin/TwoFactorSetup.php index e5a61bc..fa430f9 100644 --- a/app/Livewire/Admin/TwoFactorSetup.php +++ b/app/Livewire/Admin/TwoFactorSetup.php @@ -38,6 +38,17 @@ use Livewire\Component; #[Layout('layouts.admin')] class TwoFactorSetup extends Component { + /** + * Rendered inside the settings page rather than as a page of its own. + * + * Only the heading differs: as a tab it sits under the settings title, and + * a second

saying "Zwei-Faktor" directly beneath "Einstellungen" is + * two page titles on one page. Nothing about what the component DOES + * changes — the password gate, the enrolment and every server-side check + * are the same code on both routes in. + */ + public bool $embedded = false; + use ConfirmsPassword; use ResolvesOperator; diff --git a/app/Support/CompanyProfile.php b/app/Support/CompanyProfile.php index 710812a..1997262 100644 --- a/app/Support/CompanyProfile.php +++ b/app/Support/CompanyProfile.php @@ -111,4 +111,22 @@ final class CompanyProfile { return (float) Settings::get('company.tax_rate', (float) config('provisioning.tax.rate_percent', 20)); } + + /** + * What setting a customer up costs once, net, in cents. + * + * The price sheet has always said "zzgl. einmaliger Einrichtung" without + * ever naming a figure — a price that is announced and not stated is worse + * than no price at all, because the visitor now knows there is one and has + * to ask. Editable here for the same reason as the tax rate: it is a + * commercial number that lands on an invoice, and an operator must be able + * to change it without a deployment. + * + * Zero means there is no such fee, and the sentence disappears from the + * page entirely rather than announcing nothing. + */ + public static function setupFeeCents(): int + { + return (int) Settings::get('company.setup_fee_cents', 0); + } } diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index b9d7b4f..fb8aa82 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -54,7 +54,6 @@ final class Navigation ['admin.maintenance', 'alert-triangle', 'maintenance', null], ['admin.incidents', 'bell', 'incidents', null], ['admin.capacity', 'database', 'capacity', 'hosts.manage'], - ['admin.vpn', 'shield', 'vpn', null], ['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 @@ -64,6 +63,11 @@ final class Navigation ]], ['label' => __('admin.nav_group.system'), 'items' => [ ['admin.mail', 'mail', 'mail', 'mail.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], // 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 @@ -73,12 +77,14 @@ final class Navigation // 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']], + // 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], - // null capability, deliberately: the compulsory two-factor - // switch (Admin\Settings::saveTwoFactorPolicy()) applies to - // every operator, so enrolment has to be reachable by every - // operator too, not only the ones who can manage the site. - ['admin.two-factor-setup', 'shield-check', 'two_factor_setup', null], ]], ]; } diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index ac01dd3..f86ecc1 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -198,4 +198,14 @@ return [ 'account' => 'Mein Konto', 'team' => 'Team & Zugriff', ], + + // The tab bar. Kept apart from `group` — those were the headings of a + // scrolled page and read as such ("Installation" over a divider line); a + // tab is a thing you click, and it has room for fewer words. + 'tab' => [ + 'installation' => 'Installation', + 'account' => 'Mein Konto', + 'two_factor' => 'Zwei-Faktor', + 'team' => 'Team', + ], ]; diff --git a/lang/de/delivery.php b/lang/de/delivery.php index ede1f5f..99db804 100644 --- a/lang/de/delivery.php +++ b/lang/de/delivery.php @@ -7,8 +7,8 @@ // branch is a promise about buying and preparing a machine — that one is ours // to keep. return [ - 'immediate' => 'Wird sofort ausgeliefert', - 'scheduled' => 'Bereitstellung in 2–3 Werktagen', + 'immediate' => 'Sofort verfügbar', + 'scheduled' => 'In 2–3 Werktagen da', // The longer form, where there is room for a sentence: the customer's own // overview while their instance is waiting for a machine. diff --git a/lang/de/finance.php b/lang/de/finance.php index b628294..82f684d 100644 --- a/lang/de/finance.php +++ b/lang/de/finance.php @@ -34,6 +34,7 @@ return [ 'archive_path' => 'Archivpfad', 'logo' => 'Logo', 'tax_rate' => 'Umsatzsteuer (%)', + 'setup_fee' => 'Einrichtung, einmalig (netto)', 'payment_days' => 'Zahlungsziel (Tage)', 'payment_terms' => 'Zahlungsbedingungen', ], @@ -46,6 +47,7 @@ return [ 'archive_path' => 'Verzeichnis, in das eine Kopie jeder Rechnung geschrieben wird — z. B. der Einhängepunkt Ihrer NAS. Leer lassen, um nichts zu kopieren.', 'vat_id' => 'Pflichtangabe auf jeder Rechnung.', 'tax_rate' => 'Wird in jede Rechnung eingefroren. Eine Änderung wirkt nur auf neue Rechnungen.', + 'setup_fee' => 'Steht so auf der Preisliste der Website. 0 bedeutet: keine Einrichtungsgebühr, und der Satz entfällt dort ganz.', 'logo' => 'PNG oder WEBP, höchstens 1 MB. Wird in jede erzeugte Rechnung eingebettet.', ], diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index 427d5ea..4828386 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -197,4 +197,14 @@ return [ 'account' => 'My account', 'team' => 'Team & access', ], + + // The tab bar. Kept apart from `group` — those were the headings of a + // scrolled page and read as such; a tab is a thing you click, and it has + // room for fewer words. + 'tab' => [ + 'installation' => 'Installation', + 'account' => 'My account', + 'two_factor' => 'Two-factor', + 'team' => 'Team', + ], ]; diff --git a/lang/en/delivery.php b/lang/en/delivery.php index 925f42a..e8d3e5c 100644 --- a/lang/en/delivery.php +++ b/lang/en/delivery.php @@ -7,8 +7,8 @@ // branch is a promise about buying and preparing a machine — that one is ours // to keep. return [ - 'immediate' => 'Delivered right away', - 'scheduled' => 'Ready within 2–3 working days', + 'immediate' => 'Available right away', + 'scheduled' => 'Ready in 2–3 working days', // The longer form, where there is room for a sentence: the customer's own // overview while their instance is waiting for a machine. diff --git a/lang/en/finance.php b/lang/en/finance.php index 0cdbf81..65a9860 100644 --- a/lang/en/finance.php +++ b/lang/en/finance.php @@ -34,6 +34,7 @@ return [ 'archive_path' => 'Archive path', 'logo' => 'Logo', 'tax_rate' => 'VAT (%)', + 'setup_fee' => 'Setup, one-off (net)', 'payment_days' => 'Payment terms (days)', 'payment_terms' => 'Payment conditions', ], @@ -46,6 +47,7 @@ return [ 'archive_path' => 'Directory a copy of every invoice is written to — your NAS mount point, for instance. Leave empty to copy nothing.', 'vat_id' => 'Required on every invoice.', 'tax_rate' => 'Frozen into every invoice. A change affects new invoices only.', + 'setup_fee' => 'Shown as-is on the public price sheet. 0 means there is no setup fee, and the sentence disappears from it.', 'logo' => 'PNG or WEBP, 1 MB at most. Embedded into every invoice rendered.', ], diff --git a/resources/views/components/ui/delivery.blade.php b/resources/views/components/ui/delivery.blade.php index 25b0140..bc8779c 100644 --- a/resources/views/components/ui/delivery.blade.php +++ b/resources/views/components/ui/delivery.blade.php @@ -17,7 +17,13 @@ $immediate = $state === 'immediate'; @endphp -

class(['flex items-center gap-2 text-xs font-medium']) }}> +{{-- One line, guaranteed. In a four-column price sheet the wording had a + column 202px wide to live in and broke over two lines, which put the icon + beside the first half of a sentence and the rest under it — the shape R18 + exists to prevent. The wording is short enough to fit now, and + whitespace-nowrap makes that a property of the component rather than a + coincidence of the current translation. --}} +

class(['flex items-center gap-2 whitespace-nowrap text-xs font-medium']) }}> $immediate, 'text-muted' => ! $immediate]) /> $immediate, 'text-muted' => ! $immediate])> diff --git a/resources/views/landing.blade.php b/resources/views/landing.blade.php index 490b308..e2964a9 100644 --- a/resources/views/landing.blade.php +++ b/resources/views/landing.blade.php @@ -422,7 +422,10 @@ [true, 'Eigene isolierte Instanz, Standort EU, AV-Vertrag inklusive'], [true, 'Fixer Firmenpreis, unabhängig von der Kopfzahl'], [true, 'Betrieb, Sicherung und Überwachung nachweislich durch uns'], - [false, 'Einrichtung kostet einmalig — wir richten wirklich ein, statt nur freizuschalten'], + {{-- With the figure, and only while there is one. An + installation that charges no setup fee must not carry a + card admitting to a cost it does not have. --}} + ...($setup ? [[false, 'Einrichtung kostet einmalig '.$setup['gross'].' — wir richten wirklich ein, statt nur freizuschalten']] : []), ]], ] as $i => [$option, $subtitle, $ours, $points])

+{{-- Wider than the rest of the page, and only here. Four packages side by + side in 1120px left each card 202px of text — narrow enough that + "Bereitstellung in 2-3 Werktagen" broke over two lines and every card + looked crammed against its neighbours. The reading columns elsewhere are + narrow on purpose; a price sheet is four columns that must be comparable + at a glance, which is a different problem. --}} +

Preise

Ein fixer Preis pro Firma.

- Netto pro Monat. Keine Staffelung nach Benutzern, keine Nachverrechnung bei Zuwachs. + Alle Preise inklusive {{ $vat['label'] }} % Umsatzsteuer. Keine Staffelung nach Benutzern, + keine Nachverrechnung bei Zuwachs. +

+ {{-- Said once, here, rather than as a footnote per card: a consumer must + never be quoted a figure that grows at checkout, and a business + needs to know the net one is on the invoice. --}} +

+ Als Unternehmen ziehen Sie die Umsatzsteuer über die Rechnung wieder ab — den Nettobetrag + finden Sie unter jedem Preis.

@@ -487,7 +504,7 @@ {{-- Stretched, not items-start: the cards carry different numbers of features, and four "anfragen" buttons at four different heights is the first thing the eye picks up on a price sheet. --}} -
+
@foreach ($plans as $i => $plan)
{{ $plan['price'] }} /Monat

-

zzgl. einmaliger Einrichtung

+ {{-- The net figure under the gross one, not instead of it. + A private customer pays what is written large; a + business needs the other number for its books, and + printing only one of the two was wrong for one of the + two readers whichever we picked. --}} +

+ inkl. {{ $vat['label'] }} % MwSt. · netto {{ $plan['price_net'] }} +

+ {{-- Named, or not mentioned. "zzgl. einmaliger Einrichtung" + without a figure told a visitor only that there is a + cost — the worst of both. Zero on the Finance page + removes the line entirely. --}} + @if ($setup) +

+ zzgl. Einrichtung einmalig {{ $setup['gross'] }} +

+ @endif {{-- Read from the estate, not written down: where a host has room this rolls out on its own, and where none has, a @@ -644,7 +677,7 @@

- „optional“ heißt: in diesem Paket nicht enthalten, aber jederzeit zubuchbar — netto pro Monat, monatlich kündbar. + „optional“ heißt: in diesem Paket nicht enthalten, aber jederzeit zubuchbar — brutto pro Monat, monatlich kündbar. „—“ heißt: in diesem Paket nicht verfügbar.

diff --git a/resources/views/livewire/admin/finance.blade.php b/resources/views/livewire/admin/finance.blade.php index e03cfa9..5cf82df 100644 --- a/resources/views/livewire/admin/finance.blade.php +++ b/resources/views/livewire/admin/finance.blade.php @@ -41,6 +41,18 @@

{{ __('finance.hint.tax_rate') }}

@error('taxRate')

{{ $message }}

@enderror
+ {{-- The figure behind "zzgl. einmaliger Einrichtung". The price + sheet announced the fee for months without ever naming it, + which leaves a visitor knowing only that there is one. Zero + removes the sentence from the page rather than printing a + promise of nothing. --}} +
+ + +

{{ __('finance.hint.setup_fee') }}

+ @error('setupFee')

{{ $message }}

@enderror +
diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index df1c13d..b2f55d8 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -1,9 +1,35 @@ -
+

{{ __('admin_settings.title') }}

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

+ {{-- ── The tabs ────────────────────────────────────────────────────── + The page was three headed sections in one column, two thousand pixels + of scroll, with the operator's own password somewhere in the middle of + it. Which part somebody wants is a property of what they came to do, + not of how far they scroll. + + The choice is in the query string (see the #[Url] attribute), so a + reload, a bookmark and the back button all land where the operator + was. That matters here more than on most pages: the installation tab + refreshes itself while an update runs, and a tab state kept only in the + component would drop them back to the first tab mid-deployment. --}} +
+ @foreach ($tabs as $name) + + @endforeach +
+ {{-- ── Die Installation ───────────────────────────────────────────────── Grouped and given headings because the page was one undifferentiated stack: eight cards of the same weight in a column half the window wide, @@ -11,449 +37,453 @@ switches. What each card belongs to is now said out loud, and the short ones sit side by side instead of each taking a full row to hold three lines of text. --}} -
-
-

{{ __('admin_settings.group.installation') }}

- -
+ @if ($tab === 'installation') +
- {{-- 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. + {{-- 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. - wire:poll alone is not enough, because the thing being watched - restarts the thing doing the watching. Mid-run the requests fail, - and what answers afterwards is a NEW build being questioned by the - old page's JavaScript — so the card sat on "läuft" until somebody - reloaded. That gap is now covered by the full-screen overlay in - layouts/admin.blade.php, which binds the same watcher once for - every console page rather than here — this card no longer binds - it itself, so the two do not double-poll while this page is - open. wire:poll below is unrelated and stays: it is Livewire's - own refresh of this card's own details (log tail, elapsed time) - for as long as a Livewire connection exists. --}} - {{-- The poll stops while a run is in flight. The overlay's own - watcher is what follows a deployment — a plain fetch with no - component state behind it, which is why it survives the - restart. This one cannot, and every attempt during maintenance - mode was a Livewire request answering 503. --}} -
-
-
-
-

{{ __('admin_settings.update_title') }}

+ wire:poll alone is not enough, because the thing being watched + restarts the thing doing the watching. Mid-run the requests fail, + and what answers afterwards is a NEW build being questioned by the + old page's JavaScript — so the card sat on "läuft" until somebody + reloaded. That gap is now covered by the full-screen overlay in + layouts/admin.blade.php, which binds the same watcher once for + every console page rather than here — this card no longer binds + it itself, so the two do not double-poll while this page is + open. wire:poll below is unrelated and stays: it is Livewire's + own refresh of this card's own details (log tail, elapsed time) + for as long as a Livewire connection exists. --}} + {{-- The poll stops while a run is in flight. The overlay's own + watcher is what follows a deployment — a plain fetch with no + component state behind it, which is why it survives the + restart. This one cannot, and every attempt during maintenance + mode was a Livewire request answering 503. --}} +
+
+
+
+

{{ __('admin_settings.update_title') }}

+ @if ($update['running']) + + {{ __('admin_settings.update_running') }} + + @elseif ($update['checking']) + + {{ __('admin_settings.update_checking') }} + + @elseif ($update['available']) + + + {{-- The version, when it is known: "Version 1.1.0 + verfügbar" is a thing an operator can decide + about; "1 Aktualisierung verfügbar" is not. --}} + @if ($update['target_release']) + {{ __('admin_settings.update_available_release', ['version' => ltrim($update['target_release'], 'v')]) }} + @else + {{ __('admin_settings.update_available', ['n' => $update['behind']]) }} + @endif + + @elseif ($update['behind'] === 0) + + {{ __('admin_settings.update_current') }} + + @else + {{-- Never shown as "up to date": not knowing and being + current are different answers. --}} + + {{ __('admin_settings.update_unknown') }} + + @endif +
+ +
+
{{ __('admin_settings.update_version') }}:
+
{{ $update['version'] }}@if ($update['commit']) · {{ substr($update['commit'], 0, 7) }}@endif
+ @if ($update['source']) +
{{ __('admin_settings.update_source') }}:
+
{{ $update['source'] }}
+ @endif + @if ($update['deployed_at']) +
{{ __('admin_settings.update_deployed') }}:
+
{{ $update['deployed_at']->diffForHumans() }}
+ @endif + @if ($update['checked_at']) +
{{ __('admin_settings.update_checked') }}:
+
{{ $update['checked_at']->diffForHumans() }}
+ @endif +
+
+ + {{-- Bound, not @disabled(): the directive compiles to inline PHP + inside the component tag, and Blade then stops seeing a + component. + + Two separate actions, always offered together: looking and + applying are different intentions, and an operator who only + wants to know whether anything is new must not be routed + through a real deployment to find out. + + "Jetzt aktualisieren" now requires a released version to + install. It used to be offered unconditionally, because + `behind` was a reading taken up to a minute ago and a + console that had not noticed the last commit yet would + refuse to act on it. That reasoning belonged to counting + commits on a branch, where the answer changes several times + an hour. An update is a TAG now: it appears when somebody + releases one, not when somebody pushes — and pressing the + check button answers within a second, so a stale reading is + no longer something to work around. + + Disabled for the cases where it would go nowhere: nothing + released above the installed version, a run already in + flight, no agent, or a check already pending (so a second + click cannot collide with the first and be refused). --}} +
@if ($update['running']) - - {{ __('admin_settings.update_running') }} - - @elseif ($update['checking']) - - {{ __('admin_settings.update_checking') }} - - @elseif ($update['available']) - - - {{-- The version, when it is known: "Version 1.1.0 - verfügbar" is a thing an operator can decide - about; "1 Aktualisierung verfügbar" is not. --}} - @if ($update['target_release']) - {{ __('admin_settings.update_available_release', ['version' => ltrim($update['target_release'], 'v')]) }} - @else - {{ __('admin_settings.update_available', ['n' => $update['behind']]) }} - @endif - - @elseif ($update['behind'] === 0) - - {{ __('admin_settings.update_current') }} - + + + {{ __('admin_settings.update_running') }} + + @elseif (! $update['agent_seen']) + {{ __('admin_settings.update_unknown') }} @else - {{-- Never shown as "up to date": not knowing and being - current are different answers. --}} - - {{ __('admin_settings.update_unknown') }} - + + + {{ $update['checking'] ? __('admin_settings.update_checking') : __('admin_settings.update_check') }} + + + + {{ __('admin_settings.update_now') }} + @endif
- -
-
{{ __('admin_settings.update_version') }}:
-
{{ $update['version'] }}@if ($update['commit']) · {{ substr($update['commit'], 0, 7) }}@endif
- @if ($update['source']) -
{{ __('admin_settings.update_source') }}:
-
{{ $update['source'] }}
- @endif - @if ($update['deployed_at']) -
{{ __('admin_settings.update_deployed') }}:
-
{{ $update['deployed_at']->diffForHumans() }}
- @endif - @if ($update['checked_at']) -
{{ __('admin_settings.update_checked') }}:
-
{{ $update['checked_at']->diffForHumans() }}
- @endif -
- {{-- Bound, not @disabled(): the directive compiles to inline PHP - inside the component tag, and Blade then stops seeing a - component. + @if (! $update['agent_seen']) + {{ __('admin_settings.update_no_agent') }} + @elseif ($update['last_error']) + {{ $update['last_error'] }} + @elseif (! $update['available'] && ! $update['running'] && ! $update['checking']) + {{-- Why the button is grey. Without this the console looks + broken to whoever just pushed to main and expects to see + something: it is not broken, it is waiting for a release. --}} +

{{ __('admin_settings.update_only_releases') }}

+ @endif - Two separate actions, always offered together: looking and - applying are different intentions, and an operator who only - wants to know whether anything is new must not be routed - through a real deployment to find out. + {{-- Where it is. "Läuft gerade" alone is what sends an operator to + the shell: a queued update does nothing visible until the agent + picks it up, and while it runs the site is in maintenance mode + and this page is unreachable. Both look identical to "wedged" + without a time — hence the live countdown, replacing what used + to be a static "spätestens um 17:19" that read as a promise + nothing then visibly kept. - "Jetzt aktualisieren" now requires a released version to - install. It used to be offered unconditionally, because - `behind` was a reading taken up to a minute ago and a - console that had not noticed the last commit yet would - refuse to act on it. That reasoning belonged to counting - commits on a branch, where the answer changes several times - an hour. An update is a TAG now: it appears when somebody - releases one, not when somebody pushes — and pressing the - check button answers within a second, so a stale reading is - no longer something to work around. + There is no countdown any more, for either. It was wrong in + fact — the target was recomputed on every poll, and where the + interval the agent reported was shorter than the timer actually + installed on that host it landed in the past every time and + reset to a full minute, so it ran backwards a few seconds and + jumped, forever. And it was wrong in kind: a check starts + nothing, and an update now starts at once. A queued run says so + in a sentence; a queued check says nothing beyond its badge. --}} + {{-- And the step is NOT shown here. - Disabled for the cases where it would go nowhere: nothing - released above the installed version, a run already in - flight, no agent, or a check already pending (so a second - click cannot collide with the first and be refused). --}} -
- @if ($update['running']) - - - {{ __('admin_settings.update_running') }} - - @elseif (! $update['agent_seen']) - {{ __('admin_settings.update_unknown') }} - @else - - - {{ $update['checking'] ? __('admin_settings.update_checking') : __('admin_settings.update_check') }} - - - - {{ __('admin_settings.update_now') }} - - @endif + It used to be, in a small bordered block inside this card, and + the moment a run began an operator saw it appear and then, up + to three seconds later, saw the full-page overlay cover it and + say the same thing in a different size. Two windows for one + event. The overlay in layouts/admin carries the step, the + running time and the log tail — it is the one that survives the + restart, so it is the one that keeps them. --}} + + {{-- The window. Deliberately inside the update card rather than a + section of its own: it is the same decision as the button next + to it, only taken in advance. --}} +
+ + +
+
+ {{ __('admin_settings.auto_days') }} +
+ @foreach ([1, 2, 3, 4, 5, 6, 7] as $day) + + @endforeach +
+ @error('autoDays')

{{ $message }}

@enderror +
+ +
+
+ + + @error('autoFrom')

{{ $message }}

@enderror +
+
+ + + @error('autoTo')

{{ $message }}

@enderror +
+ {{ __('admin_settings.save') }} +
+ +

+ @if ($autoUpdate) + {{ __('admin_settings.auto_next', [ + 'days' => collect($autoDays)->sort()->map(fn ($d) => __('admin_settings.weekday.'.$d))->join(', '), + 'from' => $autoFrom, + 'to' => $autoTo, + ]) }} + @else + {{ __('admin_settings.auto_off') }} + @endif +

+
-
- @if (! $update['agent_seen']) - {{ __('admin_settings.update_no_agent') }} - @elseif ($update['last_error']) - {{ $update['last_error'] }} - @elseif (! $update['available'] && ! $update['running'] && ! $update['checking']) - {{-- Why the button is grey. Without this the console looks - broken to whoever just pushed to main and expects to see - something: it is not broken, it is waiting for a release. --}} -

{{ __('admin_settings.update_only_releases') }}

- @endif - - {{-- Where it is. "Läuft gerade" alone is what sends an operator to - the shell: a queued update does nothing visible until the agent - picks it up, and while it runs the site is in maintenance mode - and this page is unreachable. Both look identical to "wedged" - without a time — hence the live countdown, replacing what used - to be a static "spätestens um 17:19" that read as a promise - nothing then visibly kept. - - There is no countdown any more, for either. It was wrong in - fact — the target was recomputed on every poll, and where the - interval the agent reported was shorter than the timer actually - installed on that host it landed in the past every time and - reset to a full minute, so it ran backwards a few seconds and - jumped, forever. And it was wrong in kind: a check starts - nothing, and an update now starts at once. A queued run says so - in a sentence; a queued check says nothing beyond its badge. --}} - {{-- And the step is NOT shown here. - - It used to be, in a small bordered block inside this card, and - the moment a run began an operator saw it appear and then, up - to three seconds later, saw the full-page overlay cover it and - say the same thing in a different size. Two windows for one - event. The overlay in layouts/admin carries the step, the - running time and the log tail — it is the one that survives the - restart, so it is the one that keeps them. --}} - - {{-- The window. Deliberately inside the update card rather than a - section of its own: it is the same decision as the button next - to it, only taken in advance. --}} -
- - -
-
- {{ __('admin_settings.auto_days') }} -
- @foreach ([1, 2, 3, 4, 5, 6, 7] as $day) - - @endforeach -
- @error('autoDays')

{{ $message }}

@enderror -
- -
-
- - - @error('autoFrom')

{{ $message }}

@enderror -
-
- - - @error('autoTo')

{{ $message }}

@enderror -
- {{ __('admin_settings.save') }} -
- -

- @if ($autoUpdate) - {{ __('admin_settings.auto_next', [ - 'days' => collect($autoDays)->sort()->map(fn ($d) => __('admin_settings.weekday.'.$d))->join(', '), - 'from' => $autoFrom, - 'to' => $autoTo, - ]) }} - @else - {{ __('admin_settings.auto_off') }} + @if ($update['last_state'] !== null && ! $update['running']) +

+ {{ __('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

-
-
+ @endif - @if ($update['last_state'] !== null && ! $update['running']) -

- {{ __('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 -

- @endif - - {{-- Open while it runs: "läuft gerade" on its own tells an operator - nothing about whether it is progressing or wedged. --}} - @if ($updateLog) -
- {{ __('admin_settings.update_log') }} -
{{ $updateLog }}
-
- @endif -
- @endif - -
- {{-- Website visibility and two-factor policy stack in the left - column: both are short, and the console's allow-list beside - them is long. Three cards of one row each left a column of - white space down the middle of the page. --}} -
- {{-- My account --}} - @if ($canManageSite) -
-
-
-
-

{{ __('admin_settings.site_title') }}

- - - {{ $sitePublic ? __('admin_settings.site_public') : __('admin_settings.site_hidden') }} - -
-

- {{ $sitePublic ? __('admin_settings.site_public_body') : __('admin_settings.site_hidden_body') }} -

-

- {{ __('admin_settings.site_your_view', ['ip' => request()->ip()]) }} - - {{ $viewerOnVpn ? __('admin_settings.site_via_vpn') : __('admin_settings.site_not_vpn') }} - -

-
- - - {{ $sitePublic ? __('admin_settings.site_hide') : __('admin_settings.site_show') }} - -
+ {{-- Open while it runs: "läuft gerade" on its own tells an operator + nothing about whether it is progressing or wedged. --}} + @if ($updateLog) +
+ {{ __('admin_settings.update_log') }} +
{{ $updateLog }}
+
+ @endif
@endif - - {{-- Two-factor policy — voluntary by default, the Owner can make it - compulsory. The switch refuses to turn on while the operator flipping - it has no confirmed two-factor themselves: the page that would turn it - back off sits behind the switch. --}} - @if ($canManageSite) -
-

{{ __('admin_settings.two_factor_title') }}

-

{{ __('admin_settings.two_factor_body') }}

- -
- - - {{ __('admin_settings.save') }} - -
- @error('requireTwoFactor')

{{ $message }}

@enderror -
- @endif -
- - {{-- Who may reach this console --}} - @if ($canManageSite) -
-
-
-
-

{{ __('admin_settings.console_title') }}

- - - {{ $consoleRestricted ? __('admin_settings.console_locked_badge') : __('admin_settings.console_open_badge') }} - -
-

- {{ $consoleRestricted ? __('admin_settings.console_locked_body') : __('admin_settings.console_open_body') }} -

-

- {{ __('admin_settings.console_your_ip', ['ip' => $viewerIp]) }} -

-
- - - {{ $consoleRestricted ? __('admin_settings.console_unlock') : __('admin_settings.console_lock') }} - -
- -
-

{{ __('admin_settings.console_always') }}

- @foreach ($consoleVpnRanges as $range) -
- {{ $range }} - {{ __('admin_settings.console_vpn_note') }} -
- @endforeach - -

{{ __('admin_settings.console_extra') }}

- @forelse ($consoleIps as $ip) -
- {{ $ip }} - - {{ __('admin_settings.console_remove') }} - -
- @empty -

{{ __('admin_settings.console_none') }}

- @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. --}} -
-
-
- +
+ {{-- Website visibility and two-factor policy stack in the left + column: both are short, and the console's allow-list beside + them is long. Three cards of one row each left a column of + white space down the middle of the page. --}} +
+ {{-- My account --}} + @if ($canManageSite) +
+
+
+
+

{{ __('admin_settings.site_title') }}

+ + + {{ $sitePublic ? __('admin_settings.site_public') : __('admin_settings.site_hidden') }} +
- {{ __('admin_settings.console_add') }} +

+ {{ $sitePublic ? __('admin_settings.site_public_body') : __('admin_settings.site_hidden_body') }} +

+

+ {{ __('admin_settings.site_your_view', ['ip' => request()->ip()]) }} + + {{ $viewerOnVpn ? __('admin_settings.site_via_vpn') : __('admin_settings.site_not_vpn') }} + +

-

{{ __('admin_settings.console_ip_hint') }}

- + + + {{ $sitePublic ? __('admin_settings.site_hide') : __('admin_settings.site_show') }} + +
+ @endif + + {{-- Two-factor policy — voluntary by default, the Owner can make it + compulsory. The switch refuses to turn on while the operator flipping + it has no confirmed two-factor themselves: the page that would turn it + back off sits behind the switch. --}} + @if ($canManageSite) +
+

{{ __('admin_settings.two_factor_title') }}

+

{{ __('admin_settings.two_factor_body') }}

+ +
+ + + {{ __('admin_settings.save') }} + +
+ @error('requireTwoFactor')

{{ $message }}

@enderror
@endif -
+
+ {{-- Who may reach this console --}} + @if ($canManageSite) +
+
+
+
+

{{ __('admin_settings.console_title') }}

+ + + {{ $consoleRestricted ? __('admin_settings.console_locked_badge') : __('admin_settings.console_open_badge') }} + +
+

+ {{ $consoleRestricted ? __('admin_settings.console_locked_body') : __('admin_settings.console_open_body') }} +

+

+ {{ __('admin_settings.console_your_ip', ['ip' => $viewerIp]) }} +

+
+ + + {{ $consoleRestricted ? __('admin_settings.console_unlock') : __('admin_settings.console_lock') }} + +
-
+
+

{{ __('admin_settings.console_always') }}

+ @foreach ($consoleVpnRanges as $range) +
+ {{ $range }} + {{ __('admin_settings.console_vpn_note') }} +
+ @endforeach + +

{{ __('admin_settings.console_extra') }}

+ @forelse ($consoleIps as $ip) +
+ {{ $ip }} + + {{ __('admin_settings.console_remove') }} + +
+ @empty +

{{ __('admin_settings.console_none') }}

+ @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. --}} +
+
+
+ +
+ {{ __('admin_settings.console_add') }} +
+

{{ __('admin_settings.console_ip_hint') }}

+
+
+
+ @endif +
+ +
+ @endif {{-- ── Das eigene Konto ────────────────────────────────────────────── --}} -
-
-

{{ __('admin_settings.group.account') }}

- + @if ($tab === 'account') +
+ +
+
+

{{ __('admin_settings.account_title') }}

+
+ + +
+
+ {{ __('admin_settings.save') }} +
+
+ + {{-- 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. --}} +
+
+

{{ __('admin_settings.password_title') }}

+

{{ __('admin_settings.password_sub') }}

+
+ +
+ + + +
+ +
+ {{ __('admin_settings.password_save') }} +
+
+
+ + {{-- Every operator sees their own, with no capability required: this is + the account you are signed in as, and being unable to see where else + that is true is the gap the feature exists to close. --}} +
+ @livewire('admin.sessions') +
+
+ @endif + + {{-- ── Zwei-Faktor ────────────────────────────────────────────────────── + The unchanged Admin\TwoFactorSetup component, rendered here instead of + behind a navigation entry of its own. It is a setting, and it was the + one thing an operator had to leave the settings page to change about + their own access. + + It still answers at its own route as well, because + RequireOperatorTwoFactor sends an operator there who may not open + anything else yet — this page included. Same component either way, so + there is no second copy of the enrolment logic to keep in step. --}} + @if ($tab === 'two-factor') +
+ @livewire('admin.two-factor-setup', ['embedded' => true])
- -
-
-

{{ __('admin_settings.account_title') }}

-
- - -
-
- {{ __('admin_settings.save') }} -
-
- - {{-- 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. --}} -
-
-

{{ __('admin_settings.password_title') }}

-

{{ __('admin_settings.password_sub') }}

-
- -
- - - -
- -
- {{ __('admin_settings.password_save') }} -
-
-
- - - - {{-- Every operator sees their own, with no capability required: this is - the account you are signed in as, and being unable to see where else - that is true is the gap the feature exists to close. --}} -
- @livewire('admin.sessions') -
-
+ @endif {{-- ── Das Team ─────────────────────────────────────────────────────── --}} - @if ($canManageStaff) + @if ($tab === 'team' && $canManageStaff)
-
-

{{ __('admin_settings.group.team') }}

- -
diff --git a/resources/views/livewire/admin/two-factor-setup.blade.php b/resources/views/livewire/admin/two-factor-setup.blade.php index 2d99286..81695df 100644 --- a/resources/views/livewire/admin/two-factor-setup.blade.php +++ b/resources/views/livewire/admin/two-factor-setup.blade.php @@ -1,9 +1,18 @@ -
+
! $embedded])> {{-- The state belongs beside the title, not floating inside the card: it is - a property of the account, not of whichever step is on screen. --}} + a property of the account, not of whichever step is on screen. + + As a tab of the settings page the title steps down to an h2: the page + already has its own, and two

elements is two page titles. The + state badge stays either way — it is the first thing somebody opening + this comes to find out. --}}
-

{{ __('two_factor_setup.title') }}

+ @if ($embedded) +

{{ __('two_factor_setup.title') }}

+ @else +

{{ __('two_factor_setup.title') }}

+ @endif

{{ __('two_factor_setup.sub') }}

diff --git a/tests/Feature/Admin/SettingsTabsTest.php b/tests/Feature/Admin/SettingsTabsTest.php new file mode 100644 index 0000000..7450247 --- /dev/null +++ b/tests/Feature/Admin/SettingsTabsTest.php @@ -0,0 +1,106 @@ +test(Settings::class) + ->assertSet('tab', 'installation') + ->assertSee(__('admin_settings.update_title')); +}); + +it('remembers the tab across a reload, because it is in the URL', function () { + // The whole point of #[Url]. Without it a refresh — or the browser's back + // button — lands on the first tab every time. + Livewire::withQueryParams(['tab' => 'account']) + ->actingAs(operator('Owner'), 'operator') + ->test(Settings::class) + ->assertSet('tab', 'account') + ->assertSee(__('admin_settings.account_title')) + ->assertDontSee(__('admin_settings.update_title')); +}); + +it('falls back to the first tab when the URL names one that does not exist', function () { + // A query string is a string a stranger typed. Anything unknown must not + // render a settings page with no section on it at all. + Livewire::withQueryParams(['tab' => 'wat']) + ->actingAs(operator('Owner'), 'operator') + ->test(Settings::class) + ->assertSet('tab', 'installation') + ->assertSee(__('admin_settings.update_title')); +}); + +it('carries two-factor enrolment as a tab rather than a page of its own', function () { + Livewire::withQueryParams(['tab' => 'two-factor']) + ->actingAs(operator('Owner'), 'operator') + ->test(Settings::class) + ->assertSet('tab', 'two-factor') + ->assertSee(__('two_factor_setup.title')); +}); + +it('does not offer the team tab to somebody who cannot manage staff', function () { + // A tab that raises a 403 is worse than one that is not there — and an + // operator who reaches ?tab=team anyway lands on the first tab rather than + // on a page with nothing on it. + $support = operator('Support'); + + expect($support->can('staff.manage'))->toBeFalse(); + + Livewire::withQueryParams(['tab' => 'team']) + ->actingAs($support, 'operator') + ->test(Settings::class) + ->assertSet('tab', 'installation') + ->assertViewHas('tabs', fn (array $tabs) => ! in_array('team', $tabs, true)); +}); + +it('lists every tab it can render, and renders every tab it lists', function () { + // The const IS the schema: it validates the query string, builds the bar + // and decides what renders. A tab in the list with no label is a button + // that says "admin_settings.tab.x". + foreach (Settings::TABS as $tab) { + $key = 'admin_settings.tab.'.str_replace('-', '_', $tab); + + expect(__($key))->not->toBe($key); + } +}); + +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. + $groups = collect(Navigation::console()); + + $routesIn = fn (string $label) => $groups + ->firstWhere('label', __($label))['items'] ?? []; + + $system = collect($routesIn('admin.nav_group.system'))->pluck(0); + $operations = collect($routesIn('admin.nav_group.operations'))->pluck(0); + + expect($system)->toContain('admin.vpn') + ->and($operations)->not->toContain('admin.vpn') + ->and($groups->pluck('items')->flatten(1)->pluck(0)) + ->not->toContain('admin.two-factor-setup'); +}); + +it('keeps the standalone enrolment route, because the gate sends people there', function () { + // RequireOperatorTwoFactor redirects an operator who has not enrolled yet, + // and that operator may not open anything else — the settings page + // included. Folding the page away entirely would have locked them out. + expect(route('admin.two-factor-setup'))->toBeString(); + + $this->actingAs(operator('Owner'), 'operator') + ->get(route('admin.two-factor-setup')) + ->assertOk() + ->assertSee(__('two_factor_setup.title')); +}); diff --git a/tests/Feature/GrossPricingTest.php b/tests/Feature/GrossPricingTest.php new file mode 100644 index 0000000..573eba7 --- /dev/null +++ b/tests/Feature/GrossPricingTest.php @@ -0,0 +1,84 @@ +get('/')->assertOk()->getContent(); + + // start is 49 € net in the catalogue → 58,80 € at 20 %. + expect($content)->toContain('58,80'."\u{00A0}".'€') + ->and($content)->toContain('49'."\u{00A0}".'€') + ->and($content)->toContain('inkl. 20 % MwSt.'); +}); + +it('follows the rate the operator set, not one written into the page', function () { + // The rate is console-editable (Finance page) and the same one an invoice + // uses. A percentage written into the template is the second source that + // makes the sheet and the invoice disagree. + Settings::set('company.tax_rate', 10.0); + + $content = $this->get('/')->assertOk()->getContent(); + + expect($content)->toContain('53,90'."\u{00A0}".'€') // 49 € + 10 % + ->and($content)->toContain('inkl. 10 % MwSt.') + ->and($content)->not->toContain('58,80'."\u{00A0}".'€'); +}); + +it('drops the fractional part of a rate that has none', function () { + // "20 %" — not "20,0 %". The label is read by a person, not parsed. + Settings::set('company.tax_rate', 20.0); + + expect($this->get('/')->getContent())->not->toContain('20,0 %'); +}); + +it('names what the setup costs instead of only announcing that it costs', function () { + // The sheet said "zzgl. einmaliger Einrichtung" for months without naming a + // figure, which leaves a visitor knowing only that there is one. + Settings::set('company.setup_fee_cents', 9900); + + expect($this->get('/')->assertOk()->getContent()) + ->toContain('zzgl. Einrichtung einmalig') + ->toContain('118,80'."\u{00A0}".'€'); // 99 € + 20 % +}); + +it('says nothing about a setup fee that is not charged', function () { + // Zero is not "0 €" on a price sheet — it is a sentence that should not be + // there at all. + Settings::set('company.setup_fee_cents', 0); + + expect($this->get('/')->assertOk()->getContent()) + ->not->toContain('zzgl. Einrichtung einmalig'); +}); + +it('reads the fee back in euro after an operator types it in euro', function () { + // The form is in euro and the store is in cents. Asking an operator to type + // 9900 for ninety-nine euro is how a fee ends up a hundred times too large. + Settings::set('company.setup_fee_cents', 9995); + + expect(CompanyProfile::setupFeeCents())->toBe(9995) + ->and(number_format(CompanyProfile::setupFeeCents() / 100, 2, '.', ''))->toBe('99.95'); +}); + +it('does not admit to a setup cost the installation does not charge', function () { + // The comparison card listed "Einrichtung kostet einmalig" as the honest + // downside of the offer. With no fee configured that is a downside we do + // not have, stated on our own page. + Settings::set('company.setup_fee_cents', 0); + + expect($this->get('/')->assertOk()->getContent()) + ->not->toContain('Einrichtung kostet einmalig'); + + Settings::set('company.setup_fee_cents', 9900); + + expect($this->get('/')->assertOk()->getContent()) + ->toContain('Einrichtung kostet einmalig 118,80'."\u{00A0}".'€'); +}); diff --git a/tests/Feature/LandingPriceSheetTest.php b/tests/Feature/LandingPriceSheetTest.php index 0f013cb..511ef0a 100644 --- a/tests/Feature/LandingPriceSheetTest.php +++ b/tests/Feature/LandingPriceSheetTest.php @@ -28,6 +28,24 @@ use Illuminate\Support\Str; * rund um die Uhr" is a promise above the table and a card bullet beside it, * and the point of the rebuild is that it is no longer a row inside it. */ +/** + * A net catalogue figure as the page prints it: gross, in the page's format. + * + * Computed here rather than written down, for the same reason the page does not + * write it down — the rate is console-editable, and a "9 €" in a test is a test + * that passes on the day somebody changes the VAT rate and the page goes wrong. + */ +function priceSheetGross(int $netCents): string +{ + $cents = (int) round($netCents * (1 + App\Support\CompanyProfile::taxRate() / 100)); + + $amount = $cents % 100 === 0 + ? number_format($cents / 100, 0, ',', '.') + : number_format($cents / 100, 2, ',', '.'); + + return $amount."\u{00A0}€"; +} + function priceSheetTable(string $html): string { $section = substr($html, (int) strpos($html, 'id="preise"')); @@ -173,7 +191,7 @@ it('offers what a plan lacks at the price the module catalogue would charge', fu // The third cell state, and the reason it exists: team does not carry an // own domain, but we sell one — a dash there was the page telling a visitor // no on a sale we would happily make. - $this->get('/')->assertOk()->assertSee("optional · 9\u{00A0}€", false); + $this->get('/')->assertOk()->assertSee('optional · '.priceSheetGross(900), false); // And the figure follows the catalogue. Written into the template it would // be right until the first time somebody changed the price and read the @@ -182,8 +200,8 @@ it('offers what a plan lacks at the price the module catalogue would charge', fu $this->get('/') ->assertOk() - ->assertSee("optional · 19\u{00A0}€", false) - ->assertDontSee("optional · 9\u{00A0}€", false); + ->assertSee('optional · '.priceSheetGross(1900), false) + ->assertDontSee('optional · '.priceSheetGross(900), false); }); it('states the own-domain rule package by package: impossible, optional, included', function () { @@ -199,7 +217,7 @@ it('states the own-domain rule package by package: impossible, optional, include ->and($cells[0])->toContain('—') ->and($cells[0])->not->toContain('optional') ->and($cells[0])->not->toContain('€') - ->and($cells[1])->toContain("optional · 9\u{00A0}€") + ->and($cells[1])->toContain('optional · '.priceSheetGross(900)) ->and($cells[2])->toContain('inklusive') ->and($cells[3])->toContain('inklusive'); }); @@ -248,7 +266,9 @@ it('names the modules it sells, with the prices the catalogue charges for them', // The complaint that started this: the shop sold five modules and the // public page mentioned none of them. $storage = (int) config('provisioning.storage_addon.gb'); - $money = fn (int $cents) => number_format($cents / 100, 0, ',', '.')."\u{00A0}€"; + // Gross, because that is what the sheet quotes — a private customer must + // never be shown a figure that grows at checkout. + $money = fn (int $cents) => priceSheetGross($cents); $page = $this->get('/')->assertOk();