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. --}}
+
+ {{-- 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)
+
+ @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.
+ {{-- 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. --}}
+
+ {{-- ── 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
+
+
+ {{-- 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
- {{-- 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. --}}
+
- @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.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. --}}
-
+ {{-- 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') }}
-
-
- @error('requireTwoFactor')
{{ $message }}
@enderror
-
- @endif
-
-
- {{-- Who may reach this console --}}
- @if ($canManageSite)
-
- @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. --}}
-