172 lines
5.6 KiB
PHP
172 lines
5.6 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Admin;
|
|
|
|
use App\Livewire\Concerns\ConfirmsPassword;
|
|
use App\Services\Secrets\SecretVault;
|
|
use App\Services\Stripe\StripeCheck;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\On;
|
|
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;
|
|
|
|
protected function confirmationGuard(): string
|
|
{
|
|
return 'operator';
|
|
}
|
|
|
|
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::guard('operator')->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'));
|
|
}
|
|
|
|
/**
|
|
* The save button opens ConfirmSaveSecret instead of calling save()
|
|
* directly (R23) — that modal cannot see `entered` itself, so its confirm
|
|
* button dispatches back here rather than mutating anything on its own.
|
|
*/
|
|
#[On('secret-save-confirmed')]
|
|
public function onSaveConfirmed(string $key): void
|
|
{
|
|
$this->save($key);
|
|
}
|
|
|
|
public function forget(string $key): void
|
|
{
|
|
$this->guard();
|
|
|
|
app(SecretVault::class)->forget($key);
|
|
$this->check = null;
|
|
$this->dispatch('notify', message: __('secrets.removed'));
|
|
}
|
|
|
|
/** See onSaveConfirmed() — same reasoning, for ConfirmForgetSecret. */
|
|
#[On('secret-forget-confirmed')]
|
|
public function onForgetConfirmed(string $key): void
|
|
{
|
|
$this->forget($key);
|
|
}
|
|
|
|
/**
|
|
* 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(),
|
|
]);
|
|
}
|
|
}
|