## Summary - add a first-class finding exception domain with request, approval, rejection, renewal, and revocation lifecycle support - add tenant-scoped exception register, finding governance surfaces, and a canonical workspace approval queue in Filament - add audit, badge, evidence, and review-pack integrations plus focused Pest coverage for workflow, authorization, and governance validity ## Validation - vendor/bin/sail bin pint --dirty --format agent - CI=1 vendor/bin/sail artisan test --compact - manual integrated-browser smoke test for the request-exception happy path, tenant register visibility, and canonical queue visibility ## Notes - Filament implementation remains on v5 with Livewire v4-compatible surfaces - canonical queue lives in the admin panel; provider registration stays in bootstrap/providers.php - finding exceptions stay out of global search in this rollout Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #184
209 lines
8.1 KiB
PHP
209 lines
8.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Resources\FindingExceptionResource\Pages;
|
|
|
|
use App\Filament\Resources\FindingExceptionResource;
|
|
use App\Filament\Resources\FindingResource;
|
|
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('open_finding')
|
|
->label('Open finding')
|
|
->icon('heroicon-o-arrow-top-right-on-square')
|
|
->color('gray')
|
|
->url(function (): ?string {
|
|
$record = $this->getRecord();
|
|
|
|
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
|
|
return null;
|
|
}
|
|
|
|
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
|
}),
|
|
Action::make('renew_exception')
|
|
->label('Renew exception')
|
|
->icon('heroicon-o-arrow-path')
|
|
->color('warning')
|
|
->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);
|
|
}
|
|
}
|