From 7265799881df6df8f1d2967c41b3d50966b2c299 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 19:18:10 +0200 Subject: [PATCH] Quote what a person pays, and tab the settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prices. The sheet quoted net, which is the wrong number for one of its two readers: a private customer has no way to add 20 % in their head and no business being surprised by it at checkout. The gross figure is now the big one, with the net underneath for a company's books, and the same rule applies to the module prices further down — a sheet that quotes the package with VAT and the module without it is a sheet whose numbers cannot be added up. The rate is the one on the Finance page, the same one an invoice uses; a percentage written into the template would be the second source that makes the two disagree. "zzgl. einmaliger Einrichtung" had run for months without ever naming a figure, which leaves a visitor knowing only that there is one. It is a field on the Finance page now and prints with the price. Zero removes the sentence — and the comparison card that listed the fee as the honest downside of the offer — rather than admitting to a cost that is not charged. Layout. Four packages in 1120px left each card 202px of text, which is what broke "Bereitstellung in 2-3 Werktagen" over two lines and made the cards look pressed against each other. The pricing section is wider than the rest of the page (only it: the reading columns elsewhere are narrow on purpose, a price sheet is four columns that must be comparable at a glance), the gap between cards is larger, and the delivery line is short enough to fit with whitespace-nowrap to keep it that way. Navigation. VPN moves from Betrieb to System: the tunnel is not a task of the day, it is how the console reaches the estate at all. Settings. The page had grown to three headed sections in one column with the operator's own password somewhere in the middle. It is tabbed now, and two-factor enrolment is one of the tabs instead of a menu entry of its own — it is a setting, and it was the one thing an operator had to leave this page to change about their own access. Same component, no second copy of the enrolment logic; it still answers at its own route because RequireOperatorTwoFactor sends an operator there who may not open anything else yet, this page included. The open tab lives in the query string, so a reload, a bookmark and the back button all land where the operator was. That matters here more than elsewhere: the installation tab refreshes itself while an update runs, and a tab kept only in the component would drop them back to the first one mid-deployment. Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- app/Http/Controllers/LandingController.php | 46 +- app/Livewire/Admin/Finance.php | 15 + app/Livewire/Admin/Settings.php | 58 +- app/Livewire/Admin/TwoFactorSetup.php | 11 + app/Support/CompanyProfile.php | 18 + app/Support/Navigation.php | 18 +- lang/de/admin_settings.php | 10 + lang/de/delivery.php | 4 +- lang/de/finance.php | 2 + lang/en/admin_settings.php | 10 + lang/en/delivery.php | 4 +- lang/en/finance.php | 2 + .../views/components/ui/delivery.blade.php | 8 +- resources/views/landing.blade.php | 45 +- .../views/livewire/admin/finance.blade.php | 12 + .../views/livewire/admin/settings.blade.php | 854 +++++++++--------- .../livewire/admin/two-factor-setup.blade.php | 15 +- tests/Feature/Admin/SettingsTabsTest.php | 106 +++ tests/Feature/GrossPricingTest.php | 84 ++ tests/Feature/LandingPriceSheetTest.php | 30 +- 21 files changed, 910 insertions(+), 444 deletions(-) create mode 100644 tests/Feature/Admin/SettingsTabsTest.php create mode 100644 tests/Feature/GrossPricingTest.php 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();