diff --git a/VERSION b/VERSION index df1fe2f..5bc23cd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.22 +1.3.23 diff --git a/database/migrations/2026_07_29_230000_move_instances_to_their_own_zone.php b/database/migrations/2026_07_29_230000_move_instances_to_their_own_zone.php new file mode 100644 index 0000000..d8fc362 --- /dev/null +++ b/database/migrations/2026_07_29_230000_move_instances_to_their_own_zone.php @@ -0,0 +1,59 @@ +whereIn('status', ['active', 'provisioning', 'cancellation_scheduled']) + ->count(); + + if ($inService > 0) { + Log::warning('Instance zone left on '.self::OLD.': '.$inService.' instance(s) in service carry it in DNS, certificates and trusted_domains. Run clupilot:check-zone and plan a window.'); + + return; + } + + Settings::set('provisioning.dns_zone', self::NEW); + } + + public function down(): void + { + if (ProvisioningSettings::dnsZone() === self::NEW) { + Settings::set('provisioning.dns_zone', self::OLD); + } + } +}; diff --git a/resources/views/components/layouts/site.blade.php b/resources/views/components/layouts/site.blade.php index ec34e6c..3c69352 100644 --- a/resources/views/components/layouts/site.blade.php +++ b/resources/views/components/layouts/site.blade.php @@ -42,14 +42,14 @@
- Preise + Preise
@@ -86,10 +86,10 @@

Produkt

- Was enthalten ist - Ablauf - Sicherheit - Preise + Was enthalten ist + Ablauf + Sicherheit + Preise
diff --git a/resources/views/landing.blade.php b/resources/views/landing.blade.php index 629c26a..e370840 100644 --- a/resources/views/landing.blade.php +++ b/resources/views/landing.blade.php @@ -25,7 +25,7 @@
- + @@ -42,8 +42,8 @@

- Preise ansehen - Erstgespräch vereinbaren + Preise ansehen + Erstgespräch vereinbaren

Fixer Preis pro Firma · keine Abrechnung pro Kopf

@@ -150,7 +150,7 @@ Six equal cards in two rows was the old page's answer to every section. Tiles of different weight let the important thing be bigger than the rest, which is the whole point of having a layout. --}} -
+

Was enthalten ist

@@ -260,7 +260,7 @@

{{-- ════ The dark hinge — how it arrives ════════════════════════════════ --}} -
+
{{-- No clock anywhere in this section any more. "In 20 Minuten betriebsbereit", a log with minute marks down the @@ -341,7 +341,7 @@ A three-column table was the old page's version of this. What a visitor needs from it is the pairing — measure, rhythm, and the piece of paper they can hand to somebody — so the pairing is what the row shows. --}} -
+

Sicherheit & Nachweis

@@ -456,7 +456,7 @@ 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. --}} -
+

Preise

@@ -475,7 +475,7 @@

Die Paketübersicht steht gerade nicht zur Verfügung. Wir nennen Ihnen die aktuellen Konditionen gerne direkt.

- Preise anfragen + Preise anfragen

@else @php @@ -540,7 +540,7 @@ @endif - + {{ $plan['name'] }} anfragen

@@ -669,7 +669,7 @@
{{-- ════ Questions ══════════════════════════════════════════════════════ --}} -
+

Häufige Fragen

@@ -709,7 +709,7 @@ {{-- ════ Close ══════════════════════════════════════════════════════════ The one large accent area. Orange appears in few places on this page, and this is allowed to be all of it. --}} -
+
diff --git a/routes/web.php b/routes/web.php index 381fdd5..5173f55 100644 --- a/routes/web.php +++ b/routes/web.php @@ -203,9 +203,18 @@ $publicSite = function () { // what makes route('legal.impressum') generate a www URL from a queued // mail, where there is no request to take a hostname from. Route::prefix('legal')->name('legal.')->group(function () { - Route::get('/impressum', fn () => view('legal', ['title' => 'Impressum']))->name('impressum'); - Route::get('/datenschutz', fn () => view('legal', ['title' => 'Datenschutz']))->name('datenschutz'); - Route::get('/agb', fn () => view('legal', ['title' => 'AGB']))->name('agb'); + // R13: paths are English, headings are not. The page still says + // "Impressum" — that is the legal term an Austrian company must use — + // but the address is not part of the copy. + Route::get('/imprint', fn () => view('legal', ['title' => 'Impressum']))->name('impressum'); + Route::get('/privacy', fn () => view('legal', ['title' => 'Datenschutz']))->name('datenschutz'); + Route::get('/terms', fn () => view('legal', ['title' => 'AGB']))->name('agb'); + + // The German paths these shipped under. Permanent, because they have + // been in mail footers and in the site footer for weeks. + foreach (['impressum' => 'impressum', 'datenschutz' => 'datenschutz', 'agb' => 'agb'] as $old => $to) { + Route::get('/'.$old, fn () => redirect()->route('legal.'.$to, status: 301)); + } // Kept so existing links and bookmarks do not 404 — permanently, // because the page has genuinely moved. Route::get('/status', fn () => redirect()->to(route('status'), 301))->name('status'); diff --git a/tests/Feature/Admin/IntegrationsPageTest.php b/tests/Feature/Admin/IntegrationsPageTest.php index 6e1eb5b..c936d4a 100644 --- a/tests/Feature/Admin/IntegrationsPageTest.php +++ b/tests/Feature/Admin/IntegrationsPageTest.php @@ -309,6 +309,10 @@ it('binds the form to a key Livewire can actually write to', function () { // ---- Plain settings: ported from the former InfrastructureSettingsTest. ---- it('loads the .env-derived value when nothing has been saved from the console yet', function () { + // The premise has to be established now: a migration moves customer + // instances onto their own zone on a fresh install, so "nothing saved" is + // no longer the state a new database is in. + App\Support\Settings::forget('provisioning.dns_zone'); config()->set('provisioning.dns.zone', 'env-zone.example'); Livewire::actingAs(operator('Owner'), 'operator') diff --git a/tests/Feature/LandingPriceSheetTest.php b/tests/Feature/LandingPriceSheetTest.php index e1e21a6..0f013cb 100644 --- a/tests/Feature/LandingPriceSheetTest.php +++ b/tests/Feature/LandingPriceSheetTest.php @@ -233,7 +233,10 @@ it('shows one support level per plan, and never a dash for the best of them', fu it('shows the platform address in the shape a customer will type it, under the configured zone', function () { // "Eigene CluPilot-Subdomain" as a table row was meaningless twice over: // every instance gets one, and the phrase does not say what it looks like. - config()->set('provisioning.dns.zone', 'wolke.test'); + // Through the setting, not the config: the setting is what + // ProvisioningSettings::dnsZone() reads first, and since instances moved to + // their own zone there is a row in it on every installation. + App\Support\Settings::set('provisioning.dns_zone', 'wolke.test'); $this->get('/') ->assertOk() diff --git a/tests/Feature/OfficialDomainsTest.php b/tests/Feature/OfficialDomainsTest.php index 0d6bebb..18320d7 100644 --- a/tests/Feature/OfficialDomainsTest.php +++ b/tests/Feature/OfficialDomainsTest.php @@ -82,3 +82,45 @@ it('says on the sign-in form which host it is', function () { ->assertOk() ->assertSee(__('auth.phishing_link')); }); + +it('puts customer instances on their own registrable domain out of the box', function () { + // Asked for four times, and "change it in the console" was the wrong + // answer each time: an installation with nothing to migrate should simply + // arrive on the new zone. The migration does it, guarded by exactly the + // condition clupilot:check-zone reports on. + expect(App\Support\ProvisioningSettings::dnsZone())->toBe('clupilot.cloud'); +}); + +it('keeps every public path in English', function () { + // R13, applied to ALL of them this time rather than to the one that was + // pointed at. A fragment is part of a URL too, and the section anchors were + // German for weeks after the rule was first cited. + $routes = collect(app('router')->getRoutes()) + ->map(fn ($route) => $route->uri()) + ->filter(fn (string $uri) => ! str_starts_with($uri, '_')); + + $german = ['sicherheit', 'impressum', 'datenschutz', 'agb', 'preise', 'produkt', 'ablauf', 'fragen', 'kontakt', 'einstellungen', 'rechnungen']; + + foreach ($routes as $uri) { + foreach ($german as $word) { + // The redirects that keep old addresses alive are the exception, + // and they are the only routes allowed to carry these words. + $isRedirect = collect(app('router')->getRoutes()) + ->first(fn ($r) => $r->uri() === $uri)?->getActionName() === 'Closure'; + + if (str_contains($uri, $word) && ! $isRedirect) { + expect($uri)->toBe("no German path: {$uri}"); + } + } + } + + // And the fragments the site links to. + foreach (['landing.blade.php', 'components/layouts/site.blade.php'] as $view) { + $source = Illuminate\Support\Facades\File::get(resource_path('views/'.$view)); + + foreach (['#produkt', '#ablauf', '#sicherheit', '#preise', '#fragen', '#kontakt'] as $fragment) { + expect($source)->not->toContain($fragment.'"') + ->and($source)->not->toContain($fragment."'"); + } + } +}); diff --git a/tests/Feature/PortalTabsTest.php b/tests/Feature/PortalTabsTest.php index a98e76b..8c4fd84 100644 --- a/tests/Feature/PortalTabsTest.php +++ b/tests/Feature/PortalTabsTest.php @@ -32,7 +32,9 @@ it('binds the cloud card to the real instance', function () { $this->actingAs($user)->get(route('cloud')) ->assertOk() - ->assertSee('acme-ag.'.config('provisioning.dns.zone')) // real subdomain, not a fixture + // Through the same accessor the page uses. Reading config() directly + // stopped matching when the zone moved into the settings table. + ->assertSee('acme-ag.'.App\Support\ProvisioningSettings::dnsZone()) // real subdomain, not a fixture ->assertSee(__('billing.plan.business')) ->assertSee(__('billing.perf.high')); });