Put the switch where it governs, and say when it is thrown
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/betriebsmodus
parent
494a3a817e
commit
96f171b3b3
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => [
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
],
|
||||
];
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
],
|
||||
];
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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] ?? '';
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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. --}}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue