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.'); } 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 $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), ]; $service->generate($tenant, $user, $options); Notification::make()->success()->title('Review pack generation started.')->send(); } }