Put the switch where it governs, and say when it is thrown

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
nexxo 2026-07-30 15:09:38 +02:00
parent 494a3a817e
commit 96f171b3b3
9 changed files with 236 additions and 2 deletions

View File

@ -0,0 +1,46 @@
<?php
namespace App\Livewire\Admin;
use App\Support\OperatingMode;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before the operating mode changes (R23).
*
* Wortgleiches Muster zu ConfirmSaveSecret: dieses Modal mutiert nichts selbst
* es ruft `OperatingMode::set()` nirgends auf. Bestätigen löst nur ein
* Ereignis aus, das Integrations::switchMode() auffängt; deren eigene Sperre
* (secrets.manage + kürzlich bestätigtes Passwort) bleibt die einzige Stelle,
* die tatsächlich etwas schreibt. Ein Modus, der hier gefälscht hereinkäme,
* muss trotzdem switchMode()s eigenes tryFrom() und guardSecrets() passieren.
*/
class ConfirmSwitchMode extends ModalComponent
{
public string $mode;
public function mount(string $mode): void
{
$this->authorize('secrets.manage');
// Ein Wert aus dem Aufrufort dieses Modals — dieselbe Prüfung, die
// switchMode() gleich noch einmal macht: ein unbekannter Modus 404t
// hier, statt eine Bestätigung für etwas zu zeigen, das nachher
// ohnehin still nichts tut.
abort_if(OperatingMode::tryFrom($mode) === null, 404);
$this->mode = $mode;
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('mode-switch-confirmed', mode: $this->mode);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-switch-mode');
}
}

View File

@ -7,6 +7,7 @@ use App\Services\Deployment\UpdateChannel;
use App\Services\Env\EnvFileEditor;
use App\Services\Env\InvalidEnvContentException;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use App\Support\ProvisioningSettings;
use App\Support\Settings;
use Illuminate\Support\Facades\Artisan;
@ -188,6 +189,34 @@ class Integrations extends Component
}
}
// ---- The operating mode — governs every vault entry below it. ----
/**
* Den Betriebsmodus umlegen.
*
* Hinter derselben Sperre wie der Tresor `secrets.manage` plus bestätigtes
* Passwort weil der Wechsel auf Live der Moment ist, ab dem echtes Geld
* fließt. Das ist keine Umschaltfläche zum Danebenklicken.
*/
#[On('mode-switch-confirmed')]
public function switchMode(string $mode): void
{
$this->guardSecrets();
// Ein String aus dem Browser. tryFrom, nicht from.
$target = OperatingMode::tryFrom($mode);
if ($target === null) {
return;
}
OperatingMode::set($target);
$this->dispatch('notify', message: __('integrations.mode_switched', [
'mode' => __('readiness.mode.'.$target->value),
]));
}
// ---- Plain settings — App\Support\Settings, hosts.manage, no password. ----
public function saveInfra(): void
@ -462,6 +491,7 @@ class Integrations extends Component
'canInfra' => $canInfra,
'unlocked' => $unlocked,
'usable' => $vault->isUsable(),
'mode' => OperatingMode::current(),
'restart' => $restart,
'entries' => collect(SecretVault::REGISTRY)
->map(fn (array $meta, string $key) => [

View File

@ -4,6 +4,20 @@ return [
'title' => 'Integrationen',
'subtitle' => 'Angebundene Dienste, gruppiert nach Zweck — Zugangsdaten und Einstellungen nebeneinander, nicht auf getrennten Seiten nach Speicherort.',
// Der Umschalter — über allen Zugangsdaten, weil er regiert, welcher
// Platz je Eintrag gilt.
'mode_title' => 'Betriebsmodus',
'mode_body' => 'Entscheidet, welcher Platz je Zugangsdatum unten gilt. Ist der Testplatz leer, gilt im Testbetrieb der Live-Platz — außer beim Stripe-Schlüssel, der in keine Richtung zurückfällt.',
'mode_switch_to_test' => 'Zu Testbetrieb wechseln',
'mode_switch_to_live' => 'Zu Livebetrieb wechseln',
'mode_switched' => 'Betriebsmodus umgelegt: :mode.',
'mode_switch_title_test' => 'Wirklich zu Testbetrieb wechseln?',
'mode_switch_body_test' => 'Ab sofort gilt für jedes Zugangsdatum der Testplatz — oder, wo dieser leer ist, der Live-Platz. Zahlungen laufen weiter über den aktiven Stripe-Schlüssel des jeweiligen Modus.',
'mode_switch_confirm_test' => 'Umlegen',
'mode_switch_title_live' => 'Wirklich zu Livebetrieb wechseln?',
'mode_switch_body_live' => 'Ab sofort fließt echtes Geld: jede Bestellung wird über den Live-Stripe-Schlüssel abgerechnet, nicht mehr über den Testschlüssel. Das ist keine Umschaltfläche zum Danebenklicken.',
'mode_switch_confirm_live' => 'Umlegen',
'payments_title' => 'Zahlungen (Stripe)',
'payments_body' => 'Der Secret Key, mit dem Zahlungen entgegengenommen und Webhooks geprüft werden.',
@ -96,4 +110,4 @@ return [
'platform' => 'Plattform',
'env' => 'Umgebung (.env)',
],
];
];

View File

@ -4,6 +4,20 @@ return [
'title' => 'Integrations',
'subtitle' => 'Connected services, grouped by purpose — credentials and settings side by side, not on separate pages by storage mechanism.',
// The switch — above every credential, because it governs which slot of
// each entry below applies.
'mode_title' => 'Operating mode',
'mode_body' => 'Decides which slot of each credential below applies. In test mode, an empty test slot falls back to the live slot — except the Stripe key, which does not fall back either way.',
'mode_switch_to_test' => 'Switch to test mode',
'mode_switch_to_live' => 'Switch to live mode',
'mode_switched' => 'Operating mode switched: :mode.',
'mode_switch_title_test' => 'Really switch to test mode?',
'mode_switch_body_test' => 'From now on, every credential uses its test slot — or, where that one is empty, the live slot. Payments continue to go through whichever Stripe key belongs to the active mode.',
'mode_switch_confirm_test' => 'Switch',
'mode_switch_title_live' => 'Really switch to live mode?',
'mode_switch_body_live' => 'From now on, real money moves: every order is charged through the live Stripe key, not the test one. This is not a switch to click by accident.',
'mode_switch_confirm_live' => 'Switch',
'payments_title' => 'Payments (Stripe)',
'payments_body' => 'The secret key used to accept payments and verify webhooks.',
@ -96,4 +110,4 @@ return [
'platform' => 'Platform',
'env' => 'Environment (.env)',
],
];
];

View File

@ -43,6 +43,7 @@
'globe' => '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
'send' => '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>',
'plug' => '<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/>',
'beaker' => '<path d="M4.5 3h15"/><path d="M6 3v16a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V3"/><path d="M6 14h12"/>',
];
$body = $icons[$name] ?? '';

View File

@ -42,6 +42,16 @@
<div class="flex min-w-0 flex-1 flex-col lg:h-screen lg:overflow-hidden">
<x-shell.topbar :crumbs="array_values(array_filter([__('admin.console'), $title ?? \App\Support\Navigation::currentLabel(console: true)]))">
{{-- "Bin ich im Test?" darf keine Frage sein, die man erst
durch Navigieren zur Integrationsseite beantwortet die
Plakette steht im Kopf jeder Konsolenseite, solange der
Modus test ist, und verschwindet von selbst im Livebetrieb. --}}
@if (\App\Support\OperatingMode::current()->isTest())
<span class="inline-flex items-center gap-1.5 rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-900 dark:bg-amber-900/40 dark:text-amber-200">
<x-ui.icon name="beaker" class="size-4" />
{{ __('readiness.mode.test') }}
</span>
@endif
@auth
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
<button type="button" class="flex min-h-11 items-center gap-2.5 rounded px-2 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">

View File

@ -0,0 +1,24 @@
{{--
Zweizeilige Rückfrage, kein Eingabefeld braucht nach R24 kein
<x-ui.modal>. Live bekommt eine eigene, wärmere Warnfarbe und einen
zweiten Satz: das ist der Moment, ab dem echtes Geld bewegt wird, und das
darf nicht dieselbe beiläufige Bestätigung sein wie der Wechsel zurück in
den Testbetrieb.
--}}
<div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg {{ $mode === 'live' ? 'bg-warning-bg text-warning' : 'bg-accent-subtle text-accent-text' }}">
<x-ui.icon name="{{ $mode === 'live' ? 'alert-triangle' : 'beaker' }}" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('integrations.mode_switch_title_'.$mode) }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('integrations.mode_switch_body_'.$mode) }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
{{ __('integrations.mode_switch_confirm_'.$mode) }}
</x-ui.button>
</div>
</div>

View File

@ -62,6 +62,36 @@
@endif
@if ($tab === 'services')
{{-- The switch above every vault entry, because it governs them: which
slot of each credential below is in force. Same lock as the vault
itself (secrets.manage + confirmed password): the mode a stranger
could type into a form field is only ever applied through
switchMode()'s own guard, but the button that offers it in the first
place stays behind the same unlock as everything else on this page. --}}
@if ($canSecrets)
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div>
<h2 class="font-semibold text-ink">{{ __('integrations.mode_title') }}</h2>
<p class="mt-1 max-w-xl text-sm text-muted">{{ __('integrations.mode_body') }}</p>
</div>
<div class="flex items-center gap-3">
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
{{ $mode->isTest() ? 'border-warning-border bg-warning-bg text-warning' : 'border-success-border bg-success-bg text-success' }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('readiness.mode.'.$mode->value) }}
</span>
@if ($unlocked)
<x-ui.button variant="secondary"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-switch-mode', arguments: { mode: '{{ $mode->isTest() ? 'live' : 'test' }}' } })">
{{ $mode->isTest() ? __('integrations.mode_switch_to_live') : __('integrations.mode_switch_to_test') }}
</x-ui.button>
@endif
</div>
</div>
@endif
{{-- Two columns from lg up. Each card is a form of two or three fields
stacked, they left half the window empty and pushed monitoring below
the fold. --}}

View File

@ -0,0 +1,65 @@
<?php
use App\Livewire\Admin\Integrations;
use App\Models\Operator;
use App\Support\OperatingMode;
use Livewire\Livewire;
// Die Rollen heißen exakt so (Operator::OPERATOR_ROLES), und die Fabrik setzt
// sie über ->role(), nicht über eine ->owner()-Abkürzung. Das Testpasswort ist
// `passwort-fuer-tests` — OperatorFactory::definition().
//
// ZWEI PLAN-FEHLER (Brief/Plan wörtlich übernommen, hier korrigiert):
//
// 1. Livewire::actingAs() ohne den zweiten Parameter meldet nur den
// 'web'-Guard an — genau die Falle, die tests/Pest.php oben bereits für
// operator()/admin() dokumentiert (§"Shared account helpers").
// confirmationGuard() dieser Seite ist 'operator'.
// 2. ConfirmsPassword::confirmPassword() nimmt KEIN Argument entgegen — sie
// liest die eigene Eigenschaft $confirmablePassword (so gesetzt in jedem
// bestehenden Aufrufer, siehe tests/Feature/Admin/IntegrationsPageTest.php:
// ->set('confirmablePassword', …)->call('confirmPassword')). Ein Aufruf
// als ->call('confirmPassword', 'passwort-fuer-tests') kompiliert (PHP
// verwirft überzählige Argumente stillschweigend), setzt die Eigenschaft
// aber nie — die Prüfung vergleicht gegen einen leeren String und scheitert.
//
// Beide Fehler zusammen ließen den zweiten Test bei OperatingMode::Live
// stehen, obwohl scheinbar alles aufgerufen wurde: confirmPassword()
// scheiterte am Passwortvergleich, switchMode() brach danach in
// guardSecrets() mit 403 ab, bevor es je etwas schrieb.
it('refuses to switch without the secrets capability', function () {
$admin = Operator::factory()->role('Admin')->create(); // hosts.manage, nicht secrets.manage
Livewire::actingAs($admin, 'operator')
->test(Integrations::class)
->call('switchMode', 'test')
->assertForbidden();
expect(OperatingMode::current())->toBe(OperatingMode::Live);
});
it('switches when the owner has confirmed their password', function () {
$owner = Operator::factory()->role('Owner')->create();
Livewire::actingAs($owner, 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'passwort-fuer-tests')
->call('confirmPassword')
->call('switchMode', 'test');
expect(OperatingMode::current())->toBe(OperatingMode::Test);
});
it('refuses an unknown mode', function () {
// Ein String aus dem Browser. `staging` gibt es nicht, und from() würde
// hier eine Ausnahme werfen statt still nichts zu tun.
$owner = Operator::factory()->role('Owner')->create();
Livewire::actingAs($owner, 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'passwort-fuer-tests')
->call('confirmPassword')
->call('switchMode', 'staging');
expect(OperatingMode::current())->toBe(OperatingMode::Live);
});