Move the zone by migration, and put every path in English
── The zone, for the fourth time ────────────────────────────────────────────
"Change it in the console" was the wrong answer three times running. An
installation with nothing to migrate should simply arrive on the new zone, so
a migration does it — guarded by exactly the condition clupilot:check-zone
reports on: no instance in service, therefore nothing carrying the old name in
DNS, in a certificate or in trusted_domains. With even one instance it does
nothing and logs why, because re-addressing a running Nextcloud is a migration
with an outage and a migration is not the place to start one. It leaves an
installation already on another zone alone: the setting exists so somebody
else can run this on their own domain.
That fixes both reports at once — the security page and the price sheet read
the same setting, which is the entire reason neither of them has the domain
typed into it.
── R13, applied to all of them this time ────────────────────────────────────
"Ich schreibe das nun zum dritten Mal" — fair. The rule was cited, I changed
the one route that had been pointed at, and left every other German path
standing. A fragment is part of a URL too.
#produkt → #product /legal/impressum → /legal/imprint
#ablauf → #process /legal/datenschutz → /legal/privacy
#preise → #pricing /legal/agb → /legal/terms
#fragen → #faq (old paths redirect, permanently — they have been
#kontakt → #contact in the footer and in mail for weeks)
The headings stay German. "Impressum" is the legal term an Austrian company
has to use; the address is not part of the copy.
A test now walks every registered route and every fragment the site links to,
so the next German path fails the suite instead of being reported a fourth
time.
Three existing tests set config('provisioning.dns.zone') and read it back
through the accessor. That worked while the setting was empty and stopped the
moment the migration filled it — they set the setting now, and the one whose
premise is "nothing saved yet" establishes that premise itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
parent
6c3cdffc4e
commit
5f74a5f370
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Instance;
|
||||
use App\Support\ProvisioningSettings;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Puts customer instances on their own registrable domain.
|
||||
*
|
||||
* The zone has been a setting all along, and saying "change it in the console"
|
||||
* has now been said four times without the thing actually changing — which is
|
||||
* a fair complaint. An installation that has nothing to migrate should simply
|
||||
* arrive on the new zone.
|
||||
*
|
||||
* Guarded by exactly the condition `clupilot:check-zone` reports on: with no
|
||||
* instance in service there is nothing carrying the old name — no DNS record,
|
||||
* no certificate, no trusted_domains entry — so the change is free. With even
|
||||
* one, this does nothing and says so: re-addressing a running Nextcloud is a
|
||||
* migration with an outage, and a migration is not the place to start one.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private const OLD = 'clupilot.com';
|
||||
|
||||
private const NEW = 'clupilot.cloud';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$current = ProvisioningSettings::dnsZone();
|
||||
|
||||
// Somebody else's installation, or one already moved. Not ours to
|
||||
// touch — the whole point of the zone being a setting is that another
|
||||
// operator may run this on their own domain.
|
||||
if ($current !== self::OLD) {
|
||||
return;
|
||||
}
|
||||
|
||||
$inService = Instance::query()
|
||||
->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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -42,14 +42,14 @@
|
|||
<a href="{{ route('home') }}"><x-ui.brand size="md" /></a>
|
||||
|
||||
<nav class="hidden items-center gap-1 text-sm text-muted lg:flex">
|
||||
@foreach (['#produkt' => 'Produkt', '#ablauf' => 'Ablauf', '#sicherheit' => 'Sicherheit', '#preise' => 'Preise', '#fragen' => 'Fragen'] as $href => $label)
|
||||
@foreach (['#product' => 'Produkt', '#process' => 'Ablauf', '#security' => 'Sicherheit', '#pricing' => 'Preise', '#faq' => 'Fragen'] as $href => $label)
|
||||
<a href="{{ route('home') }}{{ $href }}" class="rounded px-3 py-1.5 transition-colors hover:bg-surface-hover hover:text-ink">{{ $label }}</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<a href="{{ route('login') }}" class="hidden rounded px-3 py-1.5 text-sm text-muted transition-colors hover:bg-surface-hover hover:text-ink sm:block">Anmelden</a>
|
||||
<x-ui.button href="{{ route('home') }}#preise" variant="ink" size="sm">Preise</x-ui.button>
|
||||
<x-ui.button href="{{ route('home') }}#pricing" variant="ink" size="sm">Preise</x-ui.button>
|
||||
<button type="button" class="grid size-9 place-items-center rounded text-muted transition-colors hover:bg-surface-hover hover:text-ink lg:hidden" @click="open = !open" :aria-expanded="open" aria-label="Menü">
|
||||
<x-ui.icon name="menu" class="size-5" x-show="!open" />
|
||||
<x-ui.icon name="x" class="size-5" x-show="open" x-cloak />
|
||||
|
|
@ -58,7 +58,7 @@
|
|||
</div>
|
||||
|
||||
<nav x-show="open" x-cloak @click="open = false" class="border-t border-line px-4 pb-2 lg:hidden">
|
||||
@foreach (['#produkt' => 'Produkt', '#ablauf' => 'Ablauf', '#sicherheit' => 'Sicherheit', '#preise' => 'Preise', '#fragen' => 'Fragen', route('login') => 'Anmelden'] as $href => $label)
|
||||
@foreach (['#product' => 'Produkt', '#process' => 'Ablauf', '#security' => 'Sicherheit', '#pricing' => 'Preise', '#faq' => 'Fragen', route('login') => 'Anmelden'] as $href => $label)
|
||||
<a href="{{ Str::startsWith($href, '#') ? route('home').$href : $href }}" class="flex min-h-11 items-center border-b border-line text-sm text-body last:border-0">{{ $label }}</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
|
@ -86,10 +86,10 @@
|
|||
<div>
|
||||
<p class="lbl">Produkt</p>
|
||||
<div class="mt-4 flex flex-col gap-2.5 text-sm text-muted">
|
||||
<a href="{{ route('home') }}#produkt" class="transition-colors hover:text-ink">Was enthalten ist</a>
|
||||
<a href="{{ route('home') }}#ablauf" class="transition-colors hover:text-ink">Ablauf</a>
|
||||
<a href="{{ route('home') }}#sicherheit" class="transition-colors hover:text-ink">Sicherheit</a>
|
||||
<a href="{{ route('home') }}#preise" class="transition-colors hover:text-ink">Preise</a>
|
||||
<a href="{{ route('home') }}#product" class="transition-colors hover:text-ink">Was enthalten ist</a>
|
||||
<a href="{{ route('home') }}#process" class="transition-colors hover:text-ink">Ablauf</a>
|
||||
<a href="{{ route('home') }}#security" class="transition-colors hover:text-ink">Sicherheit</a>
|
||||
<a href="{{ route('home') }}#pricing" class="transition-colors hover:text-ink">Preise</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
<div class="pointer-events-none absolute -top-56 left-1/2 size-[720px] -translate-x-1/2 rounded-pill bg-accent opacity-[0.09] blur-3xl" aria-hidden="true"></div>
|
||||
|
||||
<div class="relative mx-auto max-w-[1120px] px-5 pt-36 text-center sm:px-6 lg:pt-44">
|
||||
<a href="#sicherheit" class="lbl inline-flex items-center gap-2 rounded-pill border border-line bg-surface px-3.5 py-2 shadow-xs transition-colors hover:border-accent-border">
|
||||
<a href="#security" class="lbl inline-flex items-center gap-2 rounded-pill border border-line bg-surface px-3.5 py-2 shadow-xs transition-colors hover:border-accent-border">
|
||||
<span class="relative flex size-1.5">
|
||||
<span class="absolute inline-flex size-full animate-ping rounded-pill bg-success-bright opacity-60"></span>
|
||||
<span class="relative inline-flex size-1.5 rounded-pill bg-success-bright"></span>
|
||||
|
|
@ -42,8 +42,8 @@
|
|||
</p>
|
||||
|
||||
<div class="mt-9 flex flex-wrap justify-center gap-3">
|
||||
<x-ui.button href="#preise" variant="ink" size="lg">Preise ansehen</x-ui.button>
|
||||
<x-ui.button href="#kontakt" variant="secondary" size="lg">Erstgespräch vereinbaren</x-ui.button>
|
||||
<x-ui.button href="#pricing" variant="ink" size="lg">Preise ansehen</x-ui.button>
|
||||
<x-ui.button href="#contact" variant="secondary" size="lg">Erstgespräch vereinbaren</x-ui.button>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-muted">Fixer Preis pro Firma · keine Abrechnung pro Kopf</p>
|
||||
</div>
|
||||
|
|
@ -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. --}}
|
||||
<section id="produkt" class="mx-auto max-w-[1120px] px-5 py-24 sm:px-6 lg:py-32">
|
||||
<section id="product" class="mx-auto max-w-[1120px] px-5 py-24 sm:px-6 lg:py-32">
|
||||
<div class="rv max-w-[46ch]">
|
||||
<p class="lbl !text-accent-text">Was enthalten ist</p>
|
||||
<h2 class="mt-3 text-[clamp(2rem,4vw,3.1rem)] font-bold leading-[1.06] tracking-[-0.035em] text-ink">
|
||||
|
|
@ -260,7 +260,7 @@
|
|||
</section>
|
||||
|
||||
{{-- ════ The dark hinge — how it arrives ════════════════════════════════ --}}
|
||||
<section id="ablauf" class="bg-plate py-24 text-plate-text lg:py-32">
|
||||
<section id="process" class="bg-plate py-24 text-plate-text lg:py-32">
|
||||
<div class="mx-auto max-w-[1120px] px-5 sm:px-6">
|
||||
{{-- 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. --}}
|
||||
<section id="sicherheit" class="mx-auto max-w-[1120px] px-5 py-24 sm:px-6 lg:py-32">
|
||||
<section id="security" class="mx-auto max-w-[1120px] px-5 py-24 sm:px-6 lg:py-32">
|
||||
<div class="rv max-w-[46ch]">
|
||||
<p class="lbl !text-accent-text">Sicherheit & Nachweis</p>
|
||||
<h2 class="mt-3 text-[clamp(2rem,4vw,3.1rem)] font-bold leading-[1.06] tracking-[-0.035em] text-ink">
|
||||
|
|
@ -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. --}}
|
||||
<section id="preise" class="mx-auto max-w-[1120px] px-5 py-24 sm:px-6 lg:py-32">
|
||||
<section id="pricing" class="mx-auto max-w-[1120px] 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">
|
||||
|
|
@ -475,7 +475,7 @@
|
|||
<p class="mx-auto mt-3 max-w-[52ch] text-sm leading-relaxed text-muted">
|
||||
Die Paketübersicht steht gerade nicht zur Verfügung. Wir nennen Ihnen die aktuellen Konditionen gerne direkt.
|
||||
</p>
|
||||
<x-ui.button href="#kontakt" variant="ink" size="lg" class="mt-7">Preise anfragen</x-ui.button>
|
||||
<x-ui.button href="#contact" variant="ink" size="lg" class="mt-7">Preise anfragen</x-ui.button>
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
|
|
@ -540,7 +540,7 @@
|
|||
@endif
|
||||
</ul>
|
||||
|
||||
<x-ui.button href="#kontakt" :variant="$plan['recommended'] ? 'ink' : 'secondary'" class="mt-7 w-full">
|
||||
<x-ui.button href="#contact" :variant="$plan['recommended'] ? 'ink' : 'secondary'" class="mt-7 w-full">
|
||||
{{ $plan['name'] }} anfragen
|
||||
</x-ui.button>
|
||||
</div>
|
||||
|
|
@ -669,7 +669,7 @@
|
|||
</section>
|
||||
|
||||
{{-- ════ Questions ══════════════════════════════════════════════════════ --}}
|
||||
<section id="fragen" class="border-t border-line bg-surface-2 py-24 lg:py-32">
|
||||
<section id="faq" class="border-t border-line bg-surface-2 py-24 lg:py-32">
|
||||
<div class="mx-auto grid max-w-[1120px] gap-12 px-5 sm:px-6 lg:grid-cols-[.75fr_1.25fr] lg:gap-20">
|
||||
<div class="rv lg:sticky lg:top-28 lg:self-start">
|
||||
<p class="lbl !text-accent-text">Häufige Fragen</p>
|
||||
|
|
@ -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. --}}
|
||||
<section id="kontakt" class="bg-accent-active">
|
||||
<section id="contact" class="bg-accent-active">
|
||||
<div class="mx-auto max-w-[1120px] px-5 py-24 text-center sm:px-6 lg:py-32">
|
||||
{{-- The old headline asked who you call when a folder is missing on a
|
||||
Monday morning. It promised the wrong thing: what happens inside a
|
||||
|
|
@ -736,7 +736,7 @@
|
|||
<a href="mailto:office@clupilot.com" class="inline-flex min-h-[46px] items-center justify-center gap-2 rounded-[10px] bg-white px-[22px] text-[15px] font-semibold leading-none text-accent-active transition hover:bg-accent-subtle">
|
||||
<x-ui.icon name="mail" class="size-4" />Erstgespräch anfragen
|
||||
</a>
|
||||
<a href="#preise" class="inline-flex min-h-[46px] items-center justify-center rounded-[10px] border border-white/40 px-[22px] text-[15px] font-semibold leading-none text-white transition hover:bg-white/10">
|
||||
<a href="#pricing" class="inline-flex min-h-[46px] items-center justify-center rounded-[10px] border border-white/40 px-[22px] text-[15px] font-semibold leading-none text-white transition hover:bg-white/10">
|
||||
Preise ansehen
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."'");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue