Pruefergebnis als Modal statt am Seitenende

main
nexxo 2026-07-31 21:36:43 +02:00
parent f91726d078
commit 6a6a672c11
5 changed files with 145 additions and 35 deletions

View File

@ -0,0 +1,49 @@
<?php
namespace App\Livewire\Admin;
use App\Services\Secrets\SecretVault;
use LivewireUI\Modal\ModalComponent;
/**
* Die Antwort einer Verbindungsprüfung, als Modal.
*
* Vorher stand sie am Seitenende, hinter allen Karten und hinter dem
* Speichern-Knopf. Der Testknopf sitzt oben in der Karte; wer ihn drückte, sah
* gar nichts, weil die Antwort ausserhalb des Bildschirms erschien. Gemeldet
* wurde das zweimal erst als „der Knopf tut nichts", dann als „runterscrollen
* tut niemand, wenn man es nicht weiss".
*
* Das Ergebnis wird als Argument hereingereicht, nicht hier neu erhoben. Zwei
* Gründe: die Prüfung läuft gegen den EINGETIPPTEN, noch nicht gespeicherten
* Wert, den dieses Modal nicht sieht und DnsTokenCheck schreibt bei jedem
* Lauf einen Probeeintrag in die echte Zone. Ein Modal, das beim Öffnen prüft,
* legte bei jedem Blick einen neuen an.
*
* Durch den Browser gereicht zu werden ist hier unbedenklich: das Ergebnis
* trägt Kontoname, Betriebsmodus und die Webhook-Liste keinen Schlüssel.
*/
class CheckResult extends ModalComponent
{
public string $entryKey = '';
/** @var array<string, mixed> */
public array $result = [];
/** @param array<string, mixed> $result */
public function mount(string $entryKey, array $result): void
{
$this->authorize('secrets.manage');
abort_if(! array_key_exists($entryKey, SecretVault::REGISTRY), 404);
$this->entryKey = $entryKey;
$this->result = $result;
}
public function render()
{
return view('livewire.admin.check-result', [
'label' => __(SecretVault::REGISTRY[$this->entryKey]['label']),
]);
}
}

View File

@ -447,6 +447,15 @@ class Integrations extends Component
$candidate = trim((string) ($this->entered[self::field($key)] ?? '')) ?: null;
$this->check = app($checker)->run($candidate);
$this->checkedKey = $key;
// Als Modal, nicht nur an der Karte. Zweimal gemeldet: erst „der Knopf
// tut nichts" (die Antwort stand am Seitenende), dann
// „runterscrollen tut niemand, wenn man es nicht weiss". Eine Antwort,
// die man suchen muss, ist für den Betreiber keine.
$this->dispatch('openModal', component: 'admin.check-result', arguments: [
'entryKey' => $key,
'result' => $this->check,
]);
}
/** The dotless form key for a registry key (a dot means nesting to Livewire). */

View File

@ -131,8 +131,5 @@
@endif
</div>
@if ($entry['check'] !== null)
<x-admin.check-result :check="$entry['check']" :key="$entry['key']" />
@endif
@endif
</div>

View File

@ -0,0 +1,14 @@
{{--
R24: die Webhook-Liste kann lang werden, also gehört sie in einen Rumpf,
der scrollt, während Kopf und Fuss stehen bleiben. Deshalb <x-ui.modal>
und nicht der einfache Kasten der Rückfrage-Modals.
--}}
<x-ui.modal :title="__('secrets.check_title')" :subtitle="$label">
<x-admin.check-result :check="$result" :key="$entryKey" />
<x-slot:footer>
<x-ui.button variant="primary" x-on:click="Livewire.dispatch('closeModal')">
{{ __('common.close') }}
</x-ui.button>
</x-slot:footer>
</x-ui.modal>

View File

@ -1,12 +1,21 @@
<?php
use App\Livewire\Admin\CheckResult;
use App\Livewire\Admin\Integrations;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
/**
* Gemeldet: „Verbindung testen" gibt keine Rückmeldung.
* Zweimal gemeldet, und beide Male war es dieselbe Ursache: die Antwort der
* Verbindungsprüfung stand am SEITENENDE, hinter allen Karten und hinter dem
* Speichern-Knopf. Der Knopf sitzt oben in der Karte.
*
* „der Verbindung-testen-Knopf macht irgendwie nichts"
* „runterscrollen tut niemand, wenn man es nicht weiss"
*
* Eine Antwort, die man suchen muss, ist für den Betreiber keine. Jetzt geht
* ein Modal auf.
*/
beforeEach(function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
@ -14,27 +23,66 @@ beforeEach(function () {
Http::preventStrayRequests();
});
function integrationsUnlockedForCheck()
{
return Livewire::actingAs(operator('Owner'), 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'password')
->call('confirmPassword');
}
it('produces a result at all when the button is pressed', function () {
Http::fake(['api.stripe.com/v1/account' => Http::response([
'id' => 'acct_1', 'settings' => ['dashboard' => ['display_name' => 'CluPilot']],
])]);
$page = Livewire::actingAs(operator('Owner'), 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->call('test', 'stripe.secret');
// Erst: kommt überhaupt ein Ergebnis an? Wenn ja, ist es keine kaputte
// Prüfung, sondern eine Anzeige, die niemand findet.
expect($page->get('check'))->not->toBeNull();
expect(integrationsUnlockedForCheck()->call('test', 'stripe.secret')->get('check'))->not->toBeNull();
});
it('does not fall over when the DNS token is the one tested', function () {
// Der Ergebnisblock las `$check['account']`, `$check['live']`,
// `$check['webhooks']` — alles nur bei Stripe vorhanden. Ein
// DnsTokenCheck-Ergebnis hat davon nichts, und `writable` ist obendrein
// ein ERFOLG, laeuft also in genau diesen Zweig.
it('opens the answer in a modal instead of leaving it at the foot of the page', function () {
Http::fake(['api.stripe.com/v1/account' => Http::response([
'id' => 'acct_1', 'settings' => ['dashboard' => ['display_name' => 'CluPilot']],
])]);
integrationsUnlockedForCheck()
->call('test', 'stripe.secret')
->assertDispatched('openModal', function (string $event, array $params) {
return $params['component'] === 'admin.check-result'
&& $params['arguments']['entryKey'] === 'stripe.secret'
&& is_array($params['arguments']['result']);
});
});
it('shows the Stripe answer in that modal', function () {
$result = [
'ok' => true, 'account' => 'acct_1', 'business' => 'CluPilot',
'live' => false, 'restricted' => false, 'webhooks' => [],
];
Livewire::actingAs(operator('Owner'), 'operator')
->test(CheckResult::class, ['entryKey' => 'stripe.secret', 'result' => $result])
->assertOk()
->assertSee(__('secrets.check_webhooks_none'));
});
it('shows a DNS answer in that modal without reaching for Stripe fields', function () {
// Der Ergebnisblock las `account`, `live`, `webhooks` — alles nur bei
// Stripe vorhanden. Seit der DNS-Token einen eigenen Testknopf hat, kommt
// hier auch ein DnsTokenCheck-Ergebnis an, und dessen Erfolg heisst
// `writable`, läuft also in genau denselben Zweig.
$result = ['ok' => true, 'reason' => 'writable', 'probe_removed' => true];
Livewire::actingAs(operator('Owner'), 'operator')
->test(CheckResult::class, ['entryKey' => 'dns.token', 'result' => $result])
->assertOk()
->assertSee(__('secrets.check_writable'));
});
it('runs the DNS check against the value that was typed, not the stored one', function () {
// Der Grund, warum das Ergebnis hereingereicht und nicht im Modal erhoben
// wird: geprüft wird der EINGETIPPTE, noch nicht gespeicherte Token. Ein
// Modal, das selbst prüfte, sähe ihn nicht — und DnsTokenCheck legte bei
// jedem Öffnen einen neuen Probeeintrag in der echten Zone an.
Settings::set('provisioning.dns_zone', 'probe.example');
Http::fake([
'api.hetzner.cloud/v1/zones' => Http::response(['zones' => [['id' => 42, 'name' => 'probe.example']]]),
@ -42,23 +90,16 @@ it('does not fall over when the DNS token is the one tested', function () {
'api.hetzner.cloud/v1/zones/probe.example/rrsets/*' => Http::response([], 201),
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(Integrations::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->set('entered.dns_token', 'irgendein-token')
->call('test', 'dns.token')
->assertOk()
->assertSee(__('secrets.check_writable'));
integrationsUnlockedForCheck()
->set('entered.dns_token', 'frisch-eingetippt')
->call('test', 'dns.token');
Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer frisch-eingetippt'));
});
it('shows the result beside the key it was run for, not at the end of the page', function () {
// Der eigentliche Fund: das Ergebnis stand ganz unten, hinter allen
// Karten und hinter dem Speichern-Knopf. Der Knopf sitzt oben in der
// Stripe-Karte — wer ihn drückt, sieht die Antwort nicht, weil sie
// ausserhalb des Bildschirms erscheint. Für den Betreiber ist das
// ununterscheidbar von „der Knopf tut nichts".
$markup = file_get_contents(base_path('resources/views/components/admin/secret-field.blade.php'));
expect($markup)->toContain('$entry[\'check\']');
it('does not open the modal for somebody without the capability', function () {
// Ein Modal ist OHNE die Route-Middleware der Seite erreichbar.
Livewire::actingAs(operator('Support'), 'operator')
->test(CheckResult::class, ['entryKey' => 'stripe.secret', 'result' => ['ok' => true]])
->assertForbidden();
});