## Summary - Fixes misleading “queued / running in background” message when Review Pack generation request reuses an existing ready pack (fingerprint dedupe). - Improves resilience of Filament/Livewire interactions by ensuring the Livewire intercept shim applies after Livewire initializes. - Aligns Review Pack operation notifications with Ops-UX patterns (queued + completed notifications) and removes the old ReviewPackStatusNotification. ## Key Changes - Review Pack generate action now: - Shows queued toast only when a new pack is actually created/queued. - Shows a “Review pack already available” success notification with a link when dedupe returns an existing pack. ## Tests - `vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackGenerationTest.php` - `vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackResourceTest.php` - `vendor/bin/sail artisan test --compact tests/Feature/LivewireInterceptShimTest.php` ## Notes - No global search behavior changes for ReviewPacks (still excluded). - Destructive actions remain confirmation-gated (`->requiresConfirmation()`). Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #133
353 lines
15 KiB
PHP
353 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources;
|
|
|
|
use App\Filament\Resources\ReviewPackResource\Pages;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\ReviewPackService;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\Rbac\UiEnforcement;
|
|
use App\Support\ReviewPackStatus;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
use BackedEnum;
|
|
use Filament\Actions;
|
|
use Filament\Facades\Filament;
|
|
use Filament\Forms\Components\Toggle;
|
|
use Filament\Infolists\Components\TextEntry;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Resource;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Tables;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Number;
|
|
use UnitEnum;
|
|
|
|
class ReviewPackResource extends Resource
|
|
{
|
|
protected static ?string $model = ReviewPack::class;
|
|
|
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
|
|
|
protected static bool $isGloballySearchable = false;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-arrow-down';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
|
|
|
protected static ?string $navigationLabel = 'Review Packs';
|
|
|
|
protected static ?int $navigationSort = 50;
|
|
|
|
public static function canViewAny(): bool
|
|
{
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
if (! $user->canAccessTenant($tenant)) {
|
|
return false;
|
|
}
|
|
|
|
return $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
|
|
}
|
|
|
|
public static function canView(Model $record): bool
|
|
{
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
if (! $user->canAccessTenant($tenant)) {
|
|
return false;
|
|
}
|
|
|
|
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
|
return false;
|
|
}
|
|
|
|
if ($record instanceof ReviewPack) {
|
|
return (int) $record->tenant_id === (int) $tenant->getKey();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
|
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two primary row actions (Download, Expire); no secondary menu needed.')
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
|
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
|
|
}
|
|
|
|
public static function form(Schema $schema): Schema
|
|
{
|
|
return $schema;
|
|
}
|
|
|
|
public static function infolist(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->schema([
|
|
Section::make('Status')
|
|
->schema([
|
|
TextEntry::make('status')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ReviewPackStatus))
|
|
->color(BadgeRenderer::color(BadgeDomain::ReviewPackStatus))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus)),
|
|
TextEntry::make('tenant.name')->label('Tenant'),
|
|
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
|
TextEntry::make('expires_at')->dateTime()->placeholder('—'),
|
|
TextEntry::make('file_size')
|
|
->label('File size')
|
|
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—'),
|
|
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—'),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
|
|
Section::make('Summary')
|
|
->schema([
|
|
TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'),
|
|
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
|
|
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
|
|
TextEntry::make('summary.data_freshness.permission_posture')
|
|
->label('Permission posture freshness')
|
|
->placeholder('—'),
|
|
TextEntry::make('summary.data_freshness.entra_admin_roles')
|
|
->label('Entra admin roles freshness')
|
|
->placeholder('—'),
|
|
TextEntry::make('summary.data_freshness.findings')
|
|
->label('Findings freshness')
|
|
->placeholder('—'),
|
|
TextEntry::make('summary.data_freshness.hardening')
|
|
->label('Hardening freshness')
|
|
->placeholder('—'),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
|
|
Section::make('Options')
|
|
->schema([
|
|
TextEntry::make('options.include_pii')
|
|
->label('Include PII')
|
|
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
|
|
TextEntry::make('options.include_operations')
|
|
->label('Include operations')
|
|
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
|
|
Section::make('Metadata')
|
|
->schema([
|
|
TextEntry::make('initiator.name')->label('Initiated by')->placeholder('—'),
|
|
TextEntry::make('operationRun.id')
|
|
->label('Operation run')
|
|
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
|
|
? route('admin.operations.view', ['run' => (int) $record->operation_run_id])
|
|
: null)
|
|
->openUrlInNewTab()
|
|
->placeholder('—'),
|
|
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),
|
|
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—'),
|
|
TextEntry::make('created_at')->label('Created')->dateTime(),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
]);
|
|
}
|
|
|
|
public static function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->defaultSort('created_at', 'desc')
|
|
->recordUrl(fn (ReviewPack $record): string => static::getUrl('view', ['record' => $record]))
|
|
->columns([
|
|
Tables\Columns\TextColumn::make('status')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ReviewPackStatus))
|
|
->color(BadgeRenderer::color(BadgeDomain::ReviewPackStatus))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
|
|
->sortable(),
|
|
Tables\Columns\TextColumn::make('tenant.name')
|
|
->label('Tenant')
|
|
->searchable(),
|
|
Tables\Columns\TextColumn::make('generated_at')
|
|
->dateTime()
|
|
->sortable()
|
|
->placeholder('—'),
|
|
Tables\Columns\TextColumn::make('expires_at')
|
|
->dateTime()
|
|
->sortable()
|
|
->placeholder('—'),
|
|
Tables\Columns\TextColumn::make('file_size')
|
|
->label('Size')
|
|
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
|
|
->sortable(),
|
|
Tables\Columns\TextColumn::make('created_at')
|
|
->label('Created')
|
|
->since()
|
|
->toggleable(isToggledHiddenByDefault: true),
|
|
])
|
|
->filters([
|
|
Tables\Filters\SelectFilter::make('status')
|
|
->options(collect(ReviewPackStatus::cases())
|
|
->mapWithKeys(fn (ReviewPackStatus $s): array => [$s->value => ucfirst($s->value)])
|
|
->all()),
|
|
])
|
|
->actions([
|
|
Actions\Action::make('download')
|
|
->label('Download')
|
|
->icon('heroicon-o-arrow-down-tray')
|
|
->color('success')
|
|
->visible(fn (ReviewPack $record): bool => $record->status === ReviewPackStatus::Ready->value)
|
|
->url(function (ReviewPack $record): string {
|
|
return app(ReviewPackService::class)->generateDownloadUrl($record);
|
|
})
|
|
->openUrlInNewTab(),
|
|
UiEnforcement::forAction(
|
|
Actions\Action::make('expire')
|
|
->label('Expire')
|
|
->icon('heroicon-o-clock')
|
|
->color('danger')
|
|
->visible(fn (ReviewPack $record): bool => $record->status === ReviewPackStatus::Ready->value)
|
|
->requiresConfirmation()
|
|
->modalDescription('This will mark the pack as expired and delete the file. This cannot be undone.')
|
|
->action(function (ReviewPack $record): void {
|
|
if ($record->file_path && $record->file_disk) {
|
|
\Illuminate\Support\Facades\Storage::disk($record->file_disk)->delete($record->file_path);
|
|
}
|
|
|
|
$record->update(['status' => ReviewPackStatus::Expired->value]);
|
|
|
|
Notification::make()
|
|
->success()
|
|
->title('Review pack expired')
|
|
->send();
|
|
})
|
|
)
|
|
->preserveVisibility()
|
|
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
|
->apply(),
|
|
])
|
|
->emptyStateHeading('No review packs yet')
|
|
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
|
|
->emptyStateIcon('heroicon-o-document-arrow-down')
|
|
->emptyStateActions([
|
|
UiEnforcement::forAction(
|
|
Actions\Action::make('generate_first')
|
|
->label('Generate first pack')
|
|
->icon('heroicon-o-plus')
|
|
->action(function (array $data): void {
|
|
static::executeGeneration($data);
|
|
})
|
|
->form([
|
|
Section::make('Pack options')
|
|
->schema([
|
|
Toggle::make('include_pii')
|
|
->label('Include PII')
|
|
->helperText('Include personally identifiable information in the export.')
|
|
->default(config('tenantpilot.review_pack.include_pii_default', true)),
|
|
Toggle::make('include_operations')
|
|
->label('Include operations')
|
|
->helperText('Include recent operation history in the export.')
|
|
->default(config('tenantpilot.review_pack.include_operations_default', true)),
|
|
]),
|
|
])
|
|
)
|
|
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
|
->apply(),
|
|
]);
|
|
}
|
|
|
|
public static function getEloquentQuery(): Builder
|
|
{
|
|
$tenant = Filament::getTenant();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
|
}
|
|
|
|
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
|
|
}
|
|
|
|
public static function getPages(): array
|
|
{
|
|
return [
|
|
'index' => Pages\ListReviewPacks::route('/'),
|
|
'view' => Pages\ViewReviewPack::route('/{record}'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public static function executeGeneration(array $data): void
|
|
{
|
|
$tenant = Filament::getTenant();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
Notification::make()->danger()->title('Unable to generate pack — missing context.')->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$service = app(ReviewPackService::class);
|
|
|
|
if ($service->checkActiveRun($tenant)) {
|
|
Notification::make()->warning()->title('A review pack is already being generated.')->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$options = [
|
|
'include_pii' => (bool) ($data['include_pii'] ?? true),
|
|
'include_operations' => (bool) ($data['include_operations'] ?? true),
|
|
];
|
|
|
|
$reviewPack = $service->generate($tenant, $user, $options);
|
|
|
|
if (! $reviewPack->wasRecentlyCreated) {
|
|
Notification::make()
|
|
->success()
|
|
->title('Review pack already available')
|
|
->body('A matching review pack is already ready. No new run was started.')
|
|
->actions([
|
|
Actions\Action::make('view_pack')
|
|
->label('View pack')
|
|
->url(static::getUrl('view', ['record' => $reviewPack], tenant: $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
|
|
}
|
|
}
|