## Summary - implement Spec 192 across the targeted Filament record, detail, and edit pages with explicit action-surface inventory and guard coverage - add the focused Spec 192 browser smoke, feature tests, and spec artifacts under `specs/192-record-header-discipline` - improve unhandled promise rejection diagnostics by correlating 419s to the underlying Livewire request URL - disable panel-wide database notification polling on the admin, tenant, and system panels and cover the mitigation with focused tests ## Validation - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DatabaseNotificationsPollingTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DatabaseNotificationsPollingTest.php tests/Feature/Filament/UnhandledRejectionLoggerAssetTest.php tests/Feature/Filament/FilamentNotificationsAssetsTest.php tests/Feature/Workspaces/ManagedTenantsLivewireUpdateTest.php tests/Feature/Filament/AdminSmokeTest.php` - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - manual integrated-browser verification of the Spec 192 surfaces and the notification-polling mitigation ## Notes - Livewire v4 / Filament v5 compliance remains unchanged. - Provider registration stays in `bootstrap/providers.php`. - No Global Search behavior was expanded. - No destructive action confirmation semantics were relaxed. - The full test suite was not run in this PR. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #226
195 lines
7.5 KiB
PHP
195 lines
7.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Resources\FindingExceptionResource\Pages;
|
|
|
|
use App\Filament\Resources\FindingExceptionResource;
|
|
use App\Models\FindingException;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Findings\FindingExceptionService;
|
|
use App\Support\Auth\Capabilities;
|
|
use Filament\Actions\Action;
|
|
use Filament\Forms\Components\DateTimePicker;
|
|
use Filament\Forms\Components\Repeater;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Pages\ViewRecord;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use InvalidArgumentException;
|
|
|
|
class ViewFindingException extends ViewRecord
|
|
{
|
|
protected static string $resource = FindingExceptionResource::class;
|
|
|
|
protected function resolveRecord(int|string $key): Model
|
|
{
|
|
return FindingExceptionResource::resolveScopedRecordOrFail($key);
|
|
}
|
|
|
|
protected function getHeaderActions(): array
|
|
{
|
|
return [
|
|
Action::make('renew_exception')
|
|
->label('Renew exception')
|
|
->icon('heroicon-o-arrow-path')
|
|
->color('primary')
|
|
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed())
|
|
->fillForm(fn (): array => [
|
|
'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null,
|
|
])
|
|
->requiresConfirmation()
|
|
->form([
|
|
Select::make('owner_user_id')
|
|
->label('Owner')
|
|
->required()
|
|
->options(fn (): array => FindingExceptionResource::canViewAny() ? $this->tenantMemberOptions() : [])
|
|
->searchable(),
|
|
Textarea::make('request_reason')
|
|
->label('Renewal reason')
|
|
->rows(4)
|
|
->required()
|
|
->maxLength(2000),
|
|
DateTimePicker::make('review_due_at')
|
|
->label('Review due at')
|
|
->required()
|
|
->seconds(false),
|
|
DateTimePicker::make('expires_at')
|
|
->label('Requested expiry')
|
|
->seconds(false),
|
|
Repeater::make('evidence_references')
|
|
->label('Evidence references')
|
|
->schema([
|
|
TextInput::make('label')
|
|
->label('Label')
|
|
->required()
|
|
->maxLength(255),
|
|
TextInput::make('source_type')
|
|
->label('Source type')
|
|
->required()
|
|
->maxLength(255),
|
|
TextInput::make('source_id')
|
|
->label('Source ID')
|
|
->maxLength(255),
|
|
TextInput::make('source_fingerprint')
|
|
->label('Fingerprint')
|
|
->maxLength(255),
|
|
DateTimePicker::make('measured_at')
|
|
->label('Measured at')
|
|
->seconds(false),
|
|
])
|
|
->defaultItems(0)
|
|
->collapsed(),
|
|
])
|
|
->action(function (array $data, FindingExceptionService $service): void {
|
|
$record = $this->getRecord();
|
|
$user = auth()->user();
|
|
|
|
if (! $record instanceof FindingException || ! $user instanceof User) {
|
|
abort(404);
|
|
}
|
|
|
|
try {
|
|
$service->renew($record, $user, $data);
|
|
} catch (InvalidArgumentException $exception) {
|
|
Notification::make()
|
|
->title('Renewal request failed')
|
|
->body($exception->getMessage())
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Renewal request submitted')
|
|
->success()
|
|
->send();
|
|
|
|
$this->refreshFormData(['status', 'current_validity_state', 'review_due_at']);
|
|
}),
|
|
Action::make('revoke_exception')
|
|
->label('Revoke exception')
|
|
->icon('heroicon-o-no-symbol')
|
|
->color('danger')
|
|
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRevoked())
|
|
->requiresConfirmation()
|
|
->form([
|
|
Textarea::make('revocation_reason')
|
|
->label('Revocation reason')
|
|
->rows(4)
|
|
->required()
|
|
->maxLength(2000),
|
|
])
|
|
->action(function (array $data, FindingExceptionService $service): void {
|
|
$record = $this->getRecord();
|
|
$user = auth()->user();
|
|
|
|
if (! $record instanceof FindingException || ! $user instanceof User) {
|
|
abort(404);
|
|
}
|
|
|
|
try {
|
|
$service->revoke($record, $user, $data);
|
|
} catch (InvalidArgumentException $exception) {
|
|
Notification::make()
|
|
->title('Exception revocation failed')
|
|
->body($exception->getMessage())
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Exception revoked')
|
|
->success()
|
|
->send();
|
|
|
|
$this->refreshFormData(['status', 'current_validity_state', 'revocation_reason', 'revoked_at']);
|
|
}),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function tenantMemberOptions(): array
|
|
{
|
|
$record = $this->getRecord();
|
|
|
|
if (! $record instanceof FindingException) {
|
|
return [];
|
|
}
|
|
|
|
$tenant = $record->tenant;
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return [];
|
|
}
|
|
|
|
return \App\Models\TenantMembership::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
|
|
->orderBy('users.name')
|
|
->pluck('users.name', 'users.id')
|
|
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
|
->all();
|
|
}
|
|
|
|
private function canManageRecord(): bool
|
|
{
|
|
$record = $this->getRecord();
|
|
$user = auth()->user();
|
|
|
|
return $record instanceof FindingException
|
|
&& $record->tenant instanceof Tenant
|
|
&& $user instanceof User
|
|
&& $user->canAccessTenant($record->tenant)
|
|
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
|
}
|
|
}
|