CluPilotCloud/app/Livewire/Admin/Secrets.php

147 lines
4.9 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Services\Secrets\SecretVault;
use App\Services\Stripe\StripeCheck;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Throwable;
/**
* Credentials, changeable from the console instead of over SSH.
*
* Two gates, not one. The capability decides who may open the page at all; the
* password decides whether this SESSION may see or change anything. The second
* exists because the realistic threat is not a stranger — it is an unlocked
* machine, and a signed-in session is exactly what that gives away.
*
* Both are enforced on every action, server-side. A Livewire action is
* reachable by anyone who can post to /livewire/update, and the buttons not
* being on screen has never stopped anybody.
*
* The value being entered lives in a public property only while it is being
* typed, and is cleared the moment it is stored — a Livewire property travels
* to the browser and back in the component snapshot.
*/
#[Layout('layouts.admin')]
class Secrets extends Component
{
use ConfirmsPassword;
/**
* The new value being entered, keyed by a DOTLESS form key.
*
* Livewire reads a dot in a property path as nesting, so binding to
* `entered.stripe.secret` writes `entered['stripe']['secret']` and the
* value never arrives where the save looks for it. The registry keys keep
* their dots; the form does not.
*/
public array $entered = [];
/** Result of the last connection test, for display only. */
public ?array $check = null;
public function mount(): void
{
$this->authorize('secrets.manage');
}
public function save(string $key): void
{
$this->guard();
$field = self::field($key);
$value = trim((string) ($this->entered[$field] ?? ''));
if ($value === '') {
$this->addError('entered.'.$field, __('secrets.empty'));
return;
}
try {
app(SecretVault::class)->put($key, $value, auth()->user());
} catch (Throwable $e) {
$this->addError('entered.'.$field, $e->getMessage());
return;
}
// Out of the component state as soon as it is stored.
$this->entered[$field] = '';
$this->check = null;
$this->dispatch('notify', message: __('secrets.saved'));
}
public function forget(string $key): void
{
$this->guard();
app(SecretVault::class)->forget($key);
$this->check = null;
$this->dispatch('notify', message: __('secrets.removed'));
}
/**
* Try the key that is in force — or the one being typed, before storing it.
*
* Checking the candidate first is the point: a key that is saved and wrong
* fails later, somewhere else, usually in front of a customer.
*/
public function test(string $key): void
{
$this->guard();
// The checker named by THIS entry, not a fixed one. When the area held a
// single Stripe key a hard-coded StripeCheck was the same thing; with
// four entries it would have reported on Stripe while the operator was
// looking at the DNS token.
$checker = SecretVault::REGISTRY[$key]['check'] ?? null;
abort_if($checker === null, 404);
$candidate = trim((string) ($this->entered[self::field($key)] ?? '')) ?: null;
$this->check = app($checker)->run($candidate);
}
/** The dotless form key for a registry key. */
public static function field(string $key): string
{
return str_replace('.', '_', $key);
}
/** Capability AND a recent password, on every single action. */
private function guard(): void
{
$this->authorize('secrets.manage');
abort_unless($this->passwordRecentlyConfirmed(), 403);
}
public function render()
{
$vault = app(SecretVault::class);
$unlocked = $this->passwordRecentlyConfirmed();
return view('livewire.admin.secrets', [
'unlocked' => $unlocked,
'usable' => $vault->isUsable(),
'entries' => collect(SecretVault::REGISTRY)
->map(fn (array $meta, string $key) => [
'key' => $key,
'field' => self::field($key),
'label' => __($meta['label']),
// Only where a checker exists. A test button that cannot
// actually test is a promise the page does not keep.
'testable' => isset($meta['check']),
'source' => $vault->source($key),
// Only ever an outline, and only once unlocked.
'outline' => $unlocked ? $vault->outline($key) : null,
'updated_at' => $unlocked ? $vault->updatedAt($key) : null,
])
->values()
->all(),
]);
}
}