Quote what a person pays, and tab the settings page
tests / pest (push) Failing after 7m50s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details

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 <noreply@anthropic.com>
feature/betriebsmodus v1.3.28
nexxo 2026-07-29 19:18:10 +02:00
parent 5b63fdb86c
commit 7265799881
21 changed files with 910 additions and 444 deletions

View File

@ -1 +1 @@
1.3.27
1.3.28

View File

@ -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

View File

@ -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'));
}

View File

@ -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),
)),
]);
}
}

View File

@ -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 <h1> 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;

View File

@ -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);
}
}

View File

@ -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],
]],
];
}

View File

@ -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',
],
];

View File

@ -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 23 Werktagen',
'immediate' => 'Sofort verfügbar',
'scheduled' => 'In 23 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.

View File

@ -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.',
],

View File

@ -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',
],
];

View File

@ -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 23 working days',
'immediate' => 'Available right away',
'scheduled' => 'Ready in 23 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.

View File

@ -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.',
],

View File

@ -17,7 +17,13 @@
$immediate = $state === 'immediate';
@endphp
<p {{ $attributes->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. --}}
<p {{ $attributes->class(['flex items-center gap-2 whitespace-nowrap text-xs font-medium']) }}>
<x-ui.icon :name="$immediate ? 'check' : 'calendar'"
@class(['size-4 shrink-0', 'text-success' => $immediate, 'text-muted' => ! $immediate]) />
<span @class(['text-success' => $immediate, 'text-muted' => ! $immediate])>

View File

@ -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])
<div @class([
@ -456,14 +459,28 @@
Cards, with the matrix folded away underneath. The matrix was the first
thing a visitor met on the old page: twelve rows of ticks before a single
price was legible. --}}
<section id="pricing" class="mx-auto max-w-[1120px] px-5 py-24 sm:px-6 lg:py-32">
{{-- 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. --}}
<section id="pricing" class="mx-auto max-w-[1240px] px-5 py-24 sm:px-6 lg:py-32">
<div class="rv mx-auto max-w-[46ch] text-center">
<p class="lbl !text-accent-text">Preise</p>
<h2 class="mt-3 text-[clamp(2rem,4vw,3.1rem)] font-bold leading-[1.06] tracking-[-0.035em] text-ink">
Ein fixer Preis pro Firma.
</h2>
<p class="mt-5 text-md leading-relaxed text-muted">
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.
</p>
{{-- 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. --}}
<p class="mt-2 text-sm text-muted">
Als Unternehmen ziehen Sie die Umsatzsteuer über die Rechnung wieder ab den Nettobetrag
finden Sie unter jedem Preis.
</p>
</div>
@ -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. --}}
<div class="mt-14 grid gap-4 sm:grid-cols-2 {{ $planColumns }}">
<div class="mt-14 grid gap-5 sm:grid-cols-2 lg:gap-6 {{ $planColumns }}">
@foreach ($plans as $i => $plan)
<div @class([
'rv flex flex-col rounded-xl border p-7',
@ -512,7 +529,23 @@
<span class="text-[2.4rem] font-bold leading-none tabular-nums tracking-[-0.04em] text-ink">{{ $plan['price'] }}</span>
<span class="text-sm text-muted">/Monat</span>
</p>
<p class="mt-1.5 text-xs text-muted">zzgl. einmaliger Einrichtung</p>
{{-- 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. --}}
<p class="mt-1.5 text-xs text-muted">
inkl. {{ $vat['label'] }} % MwSt. · netto {{ $plan['price_net'] }}
</p>
{{-- 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)
<p class="mt-1 text-xs text-muted">
zzgl. Einrichtung einmalig {{ $setup['gross'] }}
</p>
@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 @@
</table>
</div>
<p class="border-t border-line px-6 py-4 text-xs leading-relaxed text-muted">
„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.
</p>
</details>

View File

@ -41,6 +41,18 @@
<p class="mt-1 text-xs text-muted">{{ __('finance.hint.tax_rate') }}</p>
@error('taxRate')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
{{-- 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. --}}
<div>
<label class="text-sm font-medium text-body" for="setup-fee">{{ __('finance.field.setup_fee') }}</label>
<input id="setup-fee" type="number" step="0.01" min="0" wire:model="setupFee"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 font-mono text-sm text-ink" />
<p class="mt-1 text-xs text-muted">{{ __('finance.hint.setup_fee') }}</p>
@error('setupFee')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
</div>
<div class="grid gap-4 border-t border-line pt-5 sm:grid-cols-3">

View File

@ -1,9 +1,35 @@
<div class="mx-auto max-w-[1120px] space-y-10">
<div class="mx-auto max-w-[1120px] space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('admin_settings.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.subtitle') }}</p>
</div>
{{-- ── 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. --}}
<div class="flex flex-wrap items-center gap-x-1 gap-y-2 border-b border-line animate-rise [animation-delay:40ms]" role="tablist">
@foreach ($tabs as $name)
<button type="button" role="tab" wire:click="$set('tab', '{{ $name }}')"
aria-selected="{{ $tab === $name ? 'true' : 'false' }}"
@class([
'flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors -mb-px',
'border-accent-active text-ink' => $tab === $name,
'border-transparent text-muted hover:text-ink' => $tab !== $name,
])>
<x-ui.icon :name="['installation' => 'server', 'account' => 'users', 'two-factor' => 'shield-check', 'team' => 'users'][$name] ?? 'settings'"
class="size-4" />{{ __('admin_settings.tab.'.str_replace('-', '_', $name)) }}
</button>
@endforeach
</div>
{{-- ── 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,11 +37,8 @@
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. --}}
@if ($tab === 'installation')
<section class="space-y-5">
<div class="flex items-center gap-4">
<h2 class="lbl">{{ __('admin_settings.group.installation') }}</h2>
<span class="h-px flex-1 bg-line"></span>
</div>
{{-- Version & update --}}
@if ($canManageSite)
@ -303,7 +326,6 @@
</div>
@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
@ -392,15 +414,12 @@
@endif
</div>
</section>
@endif
{{-- ── Das eigene Konto ────────────────────────────────────────────── --}}
@if ($tab === 'account')
<section class="space-y-5">
<div class="flex items-center gap-4">
<h2 class="lbl">{{ __('admin_settings.group.account') }}</h2>
<span class="h-px flex-1 bg-line"></span>
</div>
<div class="grid gap-5 lg:grid-cols-2 lg:items-start">
<form wire:submit="saveAccount" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
@ -437,8 +456,6 @@
</form>
</div>
{{-- 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. --}}
@ -446,14 +463,27 @@
@livewire('admin.sessions')
</div>
</section>
@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')
<div class="animate-rise">
@livewire('admin.two-factor-setup', ['embedded' => true])
</div>
@endif
{{-- ── Das Team ─────────────────────────────────────────────────────── --}}
@if ($canManageStaff)
@if ($tab === 'team' && $canManageStaff)
<section class="space-y-5">
<div class="flex items-center gap-4">
<h2 class="lbl">{{ __('admin_settings.group.team') }}</h2>
<span class="h-px flex-1 bg-line"></span>
</div>
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
<div>

View File

@ -1,9 +1,18 @@
<div class="mx-auto max-w-3xl space-y-6">
<div @class(['space-y-6', 'mx-auto max-w-3xl' => ! $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 <h1> elements is two page titles. The
state badge stays either way it is the first thing somebody opening
this comes to find out. --}}
<div class="flex flex-wrap items-start justify-between gap-4 animate-rise">
<div>
@if ($embedded)
<h2 class="text-lg font-bold tracking-tight text-ink">{{ __('two_factor_setup.title') }}</h2>
@else
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('two_factor_setup.title') }}</h1>
@endif
<p class="mt-1 max-w-xl text-sm text-muted">{{ __('two_factor_setup.sub') }}</p>
</div>

View File

@ -0,0 +1,106 @@
<?php
use App\Livewire\Admin\Settings;
use App\Support\Navigation;
use Livewire\Livewire;
/**
* The settings page is tabbed, and the tab survives a reload.
*
* It had grown to three headed sections in one column with the operator's own
* password somewhere in the middle. Which part somebody wants is a property of
* what they came to do, not of how far they scroll and the installation tab
* refreshes itself while an update runs, so a tab kept only in the component
* would drop them back to the first one mid-deployment.
*/
it('opens the installation tab by default and keeps it out of the address bar', function () {
Livewire::actingAs(operator('Owner'), 'operator')
->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'));
});

View File

@ -0,0 +1,84 @@
<?php
use App\Support\CompanyProfile;
use App\Support\Settings;
/**
* The public sheet quotes what a person pays, not what a business books.
*
* A private customer has no way to add 20 % in their head and no right to be
* surprised by it at checkout; a business gets the VAT back either way and
* needs the net figure on the invoice, not on the poster. So the big number is
* gross and the net one stands under it.
*/
it('quotes packages including VAT, with the net figure underneath', function () {
$content = $this->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}".'€');
});

View File

@ -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();