diff --git a/app/Filament/Resources/AlertDeliveryResource.php b/app/Filament/Resources/AlertDeliveryResource.php index 066e59f1..de289651 100644 --- a/app/Filament/Resources/AlertDeliveryResource.php +++ b/app/Filament/Resources/AlertDeliveryResource.php @@ -22,7 +22,6 @@ use App\Support\Workspaces\WorkspaceContext; use BackedEnum; use Filament\Actions\Action; -use Filament\Actions\ViewAction; use Filament\Facades\Filament; use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\ViewEntry; @@ -322,9 +321,7 @@ public static function table(Table $table): Table }), FilterPresets::dateRange('created_at', 'Created', 'created_at'), ]) - ->actions([ - ViewAction::make()->label('View'), - ]) + ->actions([]) ->bulkActions([]) ->emptyStateHeading('No alert deliveries') ->emptyStateDescription('Deliveries appear automatically when alert rules fire.') diff --git a/app/Filament/Resources/AlertDestinationResource.php b/app/Filament/Resources/AlertDestinationResource.php index b9059d95..80206d7b 100644 --- a/app/Filament/Resources/AlertDestinationResource.php +++ b/app/Filament/Resources/AlertDestinationResource.php @@ -18,8 +18,6 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Actions\BulkActionGroup; -use Filament\Actions\EditAction; use Filament\Facades\Filament; use Filament\Forms\Components\Select; use Filament\Forms\Components\TagsInput; @@ -191,9 +189,6 @@ public static function table(Table $table): Table ->since(), ]) ->actions([ - EditAction::make() - ->label('Edit') - ->visible(fn (AlertDestination $record): bool => static::canEdit($record)), ActionGroup::make([ Action::make('toggle_enabled') ->label(fn (AlertDestination $record): string => $record->is_enabled ? 'Disable' : 'Enable') @@ -253,9 +248,6 @@ public static function table(Table $table): Table }), ])->label('More'), ]) - ->bulkActions([ - BulkActionGroup::make([])->label('More'), - ]) ->emptyStateActions([ \Filament\Actions\CreateAction::make() ->label('Create target') diff --git a/app/Filament/Resources/AlertRuleResource.php b/app/Filament/Resources/AlertRuleResource.php index b22971b2..f17ab090 100644 --- a/app/Filament/Resources/AlertRuleResource.php +++ b/app/Filament/Resources/AlertRuleResource.php @@ -20,8 +20,6 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Actions\BulkActionGroup; -use Filament\Actions\EditAction; use Filament\Facades\Filament; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; @@ -248,9 +246,6 @@ public static function table(Table $table): Table ->color(fn (bool $state): string => $state ? 'success' : 'gray'), ]) ->actions([ - EditAction::make() - ->label('Edit') - ->visible(fn (AlertRule $record): bool => static::canEdit($record)), ActionGroup::make([ Action::make('toggle_enabled') ->label(fn (AlertRule $record): string => $record->is_enabled ? 'Disable' : 'Enable') @@ -311,9 +306,6 @@ public static function table(Table $table): Table }), ])->label('More'), ]) - ->bulkActions([ - BulkActionGroup::make([])->label('More'), - ]) ->emptyStateHeading('No alert rules') ->emptyStateDescription('Create a rule to route notifications when monitored events fire.') ->emptyStateIcon('heroicon-o-bell'); diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index d8460c98..7bc5b285 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -39,7 +39,6 @@ use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; -use Filament\Actions\EditAction; use Filament\Facades\Filament; use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\Select; @@ -571,16 +570,12 @@ public static function table(Table $table): Table ->preserveVisibility() ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN) ->apply(), - UiEnforcement::forAction( - EditAction::make() - ) - ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE) - ->apply(), UiEnforcement::forAction( Action::make('archive') ->label('Archive') ->icon('heroicon-o-archive-box-x-mark') ->color('danger') + ->requiresConfirmation() ->visible(fn (BackupSchedule $record): bool => ! $record->trashed()) ->action(function (BackupSchedule $record, AuditLogger $auditLogger): void { $record = static::resolveProtectedScheduleRecordOrFail($record); @@ -666,6 +661,7 @@ public static function table(Table $table): Table ->label('Force delete') ->icon('heroicon-o-trash') ->color('danger') + ->requiresConfirmation() ->visible(fn (BackupSchedule $record): bool => $record->trashed()) ->action(function (BackupSchedule $record, AuditLogger $auditLogger): void { $record = static::resolveProtectedScheduleRecordOrFail($record); diff --git a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php index 5217becb..44dbec94 100644 --- a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php +++ b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php @@ -13,7 +13,6 @@ use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use Closure; -use Filament\Actions; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; @@ -25,25 +24,12 @@ class BackupScheduleOperationRunsRelationManager extends RelationManager protected static ?string $title = 'Executions'; - /** - * @param array $arguments - * @param array $context - */ - public function mountAction(string $name, array $arguments = [], array $context = []): mixed - { - if (($context['table'] ?? false) === true && $name === 'view' && filled($context['recordKey'] ?? null)) { - $this->resolveOwnerScopedOperationRun($context['recordKey']); - } - - return parent::mountAction($name, $arguments, $context); - } - public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) ->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.') - ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) - ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Single primary row action is exposed, so no row menu is needed.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary because this relation manager has no secondary row actions.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.') ->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.'); } @@ -54,6 +40,12 @@ public function table(Table $table): Table ->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())) ->defaultSort('created_at', 'desc') ->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager()) + ->recordUrl(function (OperationRun $record): string { + $record = $this->resolveOwnerScopedOperationRun($record); + $tenant = Tenant::currentOrFail(); + + return OperationRunLinks::view($record, $tenant); + }) ->columns([ Tables\Columns\TextColumn::make('created_at') ->label('Enqueued') @@ -96,18 +88,7 @@ public function table(Table $table): Table ]) ->filters([]) ->headerActions([]) - ->actions([ - Actions\Action::make('view') - ->label('View') - ->icon('heroicon-o-eye') - ->url(function (OperationRun $record): string { - $record = $this->resolveOwnerScopedOperationRun($record); - $tenant = Tenant::currentOrFail(); - - return OperationRunLinks::view($record, $tenant); - }) - ->openUrlInNewTab(true), - ]) + ->actions([]) ->bulkActions([]) ->emptyStateHeading('No schedule runs yet') ->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.'); diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index 6ba9c74f..256bd6e9 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -6,6 +6,7 @@ use App\Filament\Resources\PolicyVersionResource; use App\Jobs\RemovePoliciesFromBackupSetJob; use App\Models\BackupItem; +use App\Models\BackupSet; use App\Models\Tenant; use App\Models\User; use App\Services\OperationRunService; @@ -21,6 +22,10 @@ use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\UiEnforcement; +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 Filament\Actions; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; @@ -64,6 +69,16 @@ public function mountAction(string $name, array $arguments = [], array $context return parent::mountAction($name, $arguments, $context); } + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Refresh and Add Policies actions are available in the relation header.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Remove remains grouped under More.') + ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk remove remains grouped under More.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state exposes the Add Policies CTA.'); + } + public function table(Table $table): Table { $refreshTable = Actions\Action::make('refreshTable') @@ -257,6 +272,7 @@ public function table(Table $table): Table ->persistFiltersInSession() ->persistSearchInSession() ->persistSortInSession() + ->recordUrl(fn (BackupItem $record): ?string => $this->backupItemInspectUrl($record)) ->columns([ Tables\Columns\TextColumn::make('policy.display_name') ->label('Item') @@ -358,23 +374,6 @@ public function table(Table $table): Table ]) ->actions([ Actions\ActionGroup::make([ - Actions\ViewAction::make() - ->label(fn (BackupItem $record): string => $record->policy_version_id ? 'View version' : 'View policy') - ->url(function (BackupItem $record): ?string { - $tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current(); - - if ($record->policy_version_id) { - return PolicyVersionResource::getUrl('view', ['record' => $record->policy_version_id], tenant: $tenant); - } - - if (! $record->policy_id) { - return null; - } - - return PolicyResource::getUrl('view', ['record' => $record->policy_id], tenant: $tenant); - }) - ->hidden(fn (BackupItem $record) => ! $record->policy_version_id && ! $record->policy_id) - ->openUrlInNewTab(true), $removeItem, ]) ->label('More') @@ -449,7 +448,39 @@ private static function applyRestoreModeFilter(Builder $query, mixed $value): Bu return $query->whereIn('policy_type', $types); } - private function resolveOwnerScopedBackupItemId(\App\Models\BackupSet $backupSet, mixed $record): int + private function backupItemInspectUrl(BackupItem $record): ?string + { + $backupSet = $this->getOwnerRecord(); + $resolvedId = $this->resolveOwnerScopedBackupItemId($backupSet, $record); + + $resolvedRecord = $backupSet->items() + ->with(['policy', 'policyVersion', 'policyVersion.policy']) + ->where('tenant_id', (int) $backupSet->tenant_id) + ->whereKey($resolvedId) + ->first(); + + if (! $resolvedRecord instanceof BackupItem) { + abort(404); + } + + $tenant = $backupSet->tenant ?? Tenant::current(); + + if (! $tenant instanceof Tenant) { + return null; + } + + if ($resolvedRecord->policy_version_id) { + return PolicyVersionResource::getUrl('view', ['record' => $resolvedRecord->policy_version_id], tenant: $tenant); + } + + if (! $resolvedRecord->policy_id) { + return null; + } + + return PolicyResource::getUrl('view', ['record' => $resolvedRecord->policy_id], tenant: $tenant); + } + + private function resolveOwnerScopedBackupItemId(BackupSet $backupSet, mixed $record): int { $recordId = $this->normalizeBackupItemKey($record); @@ -472,7 +503,7 @@ private function resolveOwnerScopedBackupItemId(\App\Models\BackupSet $backupSet /** * @return array */ - private function resolveOwnerScopedBackupItemIdsFromKeys(\App\Models\BackupSet $backupSet, array $recordKeys): array + private function resolveOwnerScopedBackupItemIdsFromKeys(BackupSet $backupSet, array $recordKeys): array { $requestedIds = collect($recordKeys) ->map(fn (mixed $record): int => $this->normalizeBackupItemKey($record)) diff --git a/app/Filament/Resources/BaselineProfileResource.php b/app/Filament/Resources/BaselineProfileResource.php index 8ef586df..6b4e2844 100644 --- a/app/Filament/Resources/BaselineProfileResource.php +++ b/app/Filament/Resources/BaselineProfileResource.php @@ -36,7 +36,6 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Actions\BulkActionGroup; use Filament\Facades\Filament; use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Select; @@ -131,8 +130,8 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) ->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Create baseline profile (capability-gated).') - ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) - ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions (edit, archive) under "More".') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while edit and archive remain grouped under "More".') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.') ->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.'); @@ -340,6 +339,7 @@ public static function table(Table $table): Table return $table ->defaultSort('name') ->paginated(\App\Support\Filament\TablePaginationProfiles::resource()) + ->recordUrl(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record])) ->columns([ TextColumn::make('name') ->searchable() @@ -412,10 +412,6 @@ public static function table(Table $table): Table ->options(FilterOptionCatalog::baselineProfileStatuses()), ]) ->actions([ - Action::make('view') - ->label('View') - ->url(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record])) - ->icon('heroicon-o-eye'), ActionGroup::make([ Action::make('edit') ->label('Edit') @@ -425,9 +421,7 @@ public static function table(Table $table): Table self::archiveTableAction($workspace), ])->label('More'), ]) - ->bulkActions([ - BulkActionGroup::make([])->label('More'), - ]) + ->bulkActions([]) ->emptyStateHeading('No baseline profiles') ->emptyStateDescription('Create a baseline profile to define what "good" looks like for your tenants.') ->emptyStateActions([ diff --git a/app/Filament/Resources/EvidenceSnapshotResource.php b/app/Filament/Resources/EvidenceSnapshotResource.php index e7765d4d..81bac1ef 100644 --- a/app/Filament/Resources/EvidenceSnapshotResource.php +++ b/app/Filament/Resources/EvidenceSnapshotResource.php @@ -112,7 +112,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration ->satisfy(ActionSurfaceSlot::ListHeader, 'Create snapshot is available from the list header.') ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.') - ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Evidence snapshots keep only primary View and Expire row actions.') + ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Expire snapshot remains grouped under More.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.') ->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence and Expire snapshot actions.'); } @@ -257,33 +257,32 @@ public static function table(Table $table): Table ->options(BadgeCatalog::options(BadgeDomain::EvidenceCompleteness, EvidenceCompletenessState::values())), ]) ->actions([ - Actions\Action::make('view_snapshot') - ->label('View snapshot') - ->url(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record])), - UiEnforcement::forTableAction( - Actions\Action::make('expire') - ->label('Expire snapshot') - ->color('danger') - ->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record)) - ->requiresConfirmation() - ->action(function (EvidenceSnapshot $record): void { - $user = auth()->user(); + Actions\ActionGroup::make([ + UiEnforcement::forTableAction( + Actions\Action::make('expire') + ->label('Expire snapshot') + ->color('danger') + ->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record)) + ->requiresConfirmation() + ->action(function (EvidenceSnapshot $record): void { + $user = auth()->user(); - if (! $user instanceof User) { - abort(403); - } + if (! $user instanceof User) { + abort(403); + } - app(EvidenceSnapshotService::class)->expire($record, $user); - static::truthEnvelope($record->refresh(), fresh: true); + app(EvidenceSnapshotService::class)->expire($record, $user); + static::truthEnvelope($record->refresh(), fresh: true); - Notification::make()->success()->title('Snapshot expired')->send(); - }), - fn (EvidenceSnapshot $record): EvidenceSnapshot => $record, - ) - ->preserveVisibility() - ->requireCapability(Capabilities::EVIDENCE_MANAGE) - ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) - ->apply(), + Notification::make()->success()->title('Snapshot expired')->send(); + }), + fn (EvidenceSnapshot $record): EvidenceSnapshot => $record, + ) + ->preserveVisibility() + ->requireCapability(Capabilities::EVIDENCE_MANAGE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(), + ])->label('More'), ]) ->bulkActions([]) ->emptyStateHeading('No evidence snapshots yet') diff --git a/app/Filament/Resources/OperationRunResource.php b/app/Filament/Resources/OperationRunResource.php index 71fb2579..770912ca 100644 --- a/app/Filament/Resources/OperationRunResource.php +++ b/app/Filament/Resources/OperationRunResource.php @@ -38,7 +38,6 @@ use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern; use App\Support\Workspaces\WorkspaceContext; use BackedEnum; -use Filament\Actions; use Filament\Facades\Filament; use Filament\Infolists\Components\ViewEntry; use Filament\Resources\Resource; @@ -77,7 +76,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration ActionSurfaceSlot::ListHeader, 'Run-log list intentionally has no list-header actions; navigation actions are provided by Monitoring shell pages.', ) - ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->exempt( ActionSurfaceSlot::ListBulkMoreGroup, 'Operation runs are immutable records; bulk export is deferred and tracked outside this retrofit.', @@ -128,6 +127,7 @@ public static function table(Table $table): Table ->persistFiltersInSession() ->persistSearchInSession() ->persistSortInSession() + ->recordUrl(fn (OperationRun $record): string => OperationRunLinks::tenantlessView($record)) ->columns([ Tables\Columns\TextColumn::make('status') ->badge() @@ -242,11 +242,7 @@ public static function table(Table $table): Table 'until' => now()->toDateString(), ]), ]) - ->actions([ - Actions\ViewAction::make() - ->label('View run') - ->url(fn (OperationRun $record): string => OperationRunLinks::tenantlessView($record)), - ]) + ->actions([]) ->bulkActions([]) ->emptyStateHeading('No operation runs found') ->emptyStateDescription('Queued, running, and completed operations will appear here when work is triggered in this scope.') diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index 8d7337cd..a84c7074 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -99,7 +99,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) ->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.') - ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state CTA.') @@ -365,6 +365,7 @@ public static function table(Table $table): Table ->persistFiltersInSession() ->persistSearchInSession() ->persistSortInSession() + ->recordUrl(fn (Policy $record): string => static::getUrl('view', ['record' => $record])) ->columns([ Tables\Columns\TextColumn::make('display_name') ->label('Policy') @@ -490,7 +491,6 @@ public static function table(Table $table): Table ->all()), ]) ->actions([ - Actions\ViewAction::make(), ActionGroup::make([ UiEnforcement::forTableAction( Actions\Action::make('ignore') diff --git a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php index bbec6ee7..fc6504eb 100644 --- a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php +++ b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\PolicyResource\RelationManagers; use App\Filament\Concerns\ResolvesPanelTenantContext; +use App\Filament\Resources\PolicyVersionResource; use App\Filament\Resources\RestoreRunResource; use App\Models\Policy; use App\Models\PolicyVersion; @@ -49,8 +50,8 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) ->exempt(ActionSurfaceSlot::ListHeader, 'Versions sub-list intentionally has no header actions.') - ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) - ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two row actions are present, so no secondary row menu is needed.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while restore remains the only inline row shortcut.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for version restore safety.') ->exempt(ActionSurfaceSlot::ListEmptyState, 'No inline empty-state action is exposed in this embedded relation manager.'); } @@ -181,13 +182,11 @@ public function table(Table $table): Table ]) ->defaultSort('version_number', 'desc') ->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager()) + ->recordUrl(fn (PolicyVersion $record): string => PolicyVersionResource::getUrl('view', ['record' => $record])) ->filters([]) ->headerActions([]) ->actions([ $restoreToIntune, - Actions\ViewAction::make() - ->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record])) - ->openUrlInNewTab(false), ]) ->bulkActions([]) ->emptyStateHeading('No versions captured') diff --git a/app/Filament/Resources/ProviderConnectionResource.php b/app/Filament/Resources/ProviderConnectionResource.php index fa42a96f..b434ed28 100644 --- a/app/Filament/Resources/ProviderConnectionResource.php +++ b/app/Filament/Resources/ProviderConnectionResource.php @@ -82,7 +82,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.') ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) - ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".') + ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Edit and provider operations are grouped under "More" while clickable-row view remains primary.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Provider connections intentionally omit bulk actions.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines empty-state guidance and CTA.') ->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header mutations in this resource.'); diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 0b784776..741e6b28 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -37,6 +37,10 @@ use App\Support\Rbac\UiEnforcement; use App\Support\RestoreRunIdempotency; use App\Support\RestoreRunStatus; +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\Actions\ActionGroup; @@ -103,6 +107,17 @@ public static function canCreate(): bool && $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE); } + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Create restore run is available from the list header whenever records already exist.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while rerun and archive lifecycle actions stay grouped under More.') + ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk restore-run maintenance actions are grouped under More.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state exposes the New restore run CTA.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header actions.'); + } + public static function form(Schema $schema): Schema { return $schema @@ -862,8 +877,8 @@ public static function table(Table $table): Table FilterPresets::dateRange('started_at', 'Started', 'started_at'), FilterPresets::archived(), ]) + ->recordUrl(fn (RestoreRun $record): string => static::getUrl('view', ['record' => $record])) ->actions([ - Actions\ViewAction::make(), ActionGroup::make([ static::rerunActionWithGate(), UiEnforcement::forTableAction( @@ -975,7 +990,9 @@ public static function table(Table $table): Table ->requireCapability(Capabilities::TENANT_DELETE) ->preserveVisibility() ->apply(), - ])->icon('heroicon-o-ellipsis-vertical'), + ]) + ->label('More') + ->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ BulkActionGroup::make([ @@ -1232,7 +1249,7 @@ public static function table(Table $table): Table ) ->requireCapability(Capabilities::TENANT_DELETE) ->apply(), - ]), + ])->label('More'), ]) ->emptyStateHeading('No restore runs') ->emptyStateDescription('Start a restoration from a backup set.') diff --git a/app/Filament/Resources/ReviewPackResource.php b/app/Filament/Resources/ReviewPackResource.php index a446df3e..c06f461e 100644 --- a/app/Filament/Resources/ReviewPackResource.php +++ b/app/Filament/Resources/ReviewPackResource.php @@ -99,9 +99,9 @@ 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::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->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::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download and Expire remain direct row shortcuts.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.') ->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.'); } diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 6497e7a4..1e82df61 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -146,10 +146,10 @@ public static function canDeleteAny(): bool public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) - ->withListRowPrimaryActionLimit(2) + ->withListRowPrimaryActionLimit(1) ->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.') - ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) - ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most two row actions stay primary; lifecycle-adjacent and contextual secondary actions move under "More".') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most one non-inspect row action stays primary; lifecycle-adjacent and destructive actions move under "More".') ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.') ->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.'); @@ -245,6 +245,7 @@ public static function table(Table $table): Table ->persistFiltersInSession() ->persistSearchInSession() ->persistSortInSession() + ->recordUrl(fn (Tenant $record): string => static::getUrl('view', ['record' => $record])) ->columns([ Tables\Columns\TextColumn::make('name') ->searchable() @@ -317,49 +318,11 @@ public static function table(Table $table): Table ]), ]) ->actions([ - Actions\Action::make('view') - ->label('View') - ->icon('heroicon-o-eye') - ->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])), Actions\Action::make('related_onboarding') ->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'Resume onboarding') ->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path') ->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding')) ->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'), - UiEnforcement::forAction( - Actions\Action::make('restore') - ->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore') - ->color('success') - ->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left') - ->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored') - ->requiresConfirmation() - ->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant') - ->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.') - ->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'restore') - ->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void { - static::restoreTenant($record, $auditLogger); - }) - ) - ->preserveVisibility() - ->requireCapability(Capabilities::TENANT_DELETE) - ->apply(), - UiEnforcement::forAction( - Actions\Action::make('archive') - ->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive') - ->color('danger') - ->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark') - ->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived') - ->requiresConfirmation() - ->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant') - ->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.') - ->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'archive') - ->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void { - static::archiveTenant($record, $auditLogger); - }) - ) - ->preserveVisibility() - ->requireCapability(Capabilities::TENANT_DELETE) - ->apply(), ActionGroup::make([ Actions\Action::make('related_onboarding_overflow') ->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding') @@ -367,6 +330,40 @@ public static function table(Table $table): Table ->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding')) ->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor && static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'), + UiEnforcement::forAction( + Actions\Action::make('restore') + ->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore') + ->color('success') + ->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left') + ->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored') + ->requiresConfirmation() + ->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant') + ->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.') + ->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'restore') + ->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void { + static::restoreTenant($record, $auditLogger); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_DELETE) + ->apply(), + UiEnforcement::forAction( + Actions\Action::make('archive') + ->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive') + ->color('danger') + ->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark') + ->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived') + ->requiresConfirmation() + ->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant') + ->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.') + ->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'archive') + ->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void { + static::archiveTenant($record, $auditLogger); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_DELETE) + ->apply(), UiEnforcement::forAction( Actions\Action::make('syncTenant') ->label('Sync') diff --git a/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php b/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php index e3eb325d..f85d2446 100644 --- a/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php +++ b/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php @@ -2,12 +2,17 @@ namespace App\Filament\Resources\TenantResource\RelationManagers; +use App\Filament\Resources\TenantResource\Pages\ManageTenantMemberships; use App\Models\Tenant; use App\Models\TenantMembership; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Auth\TenantMembershipManager; use App\Support\Auth\Capabilities; use App\Support\Rbac\UiEnforcement; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use Filament\Actions\Action; use Filament\Forms; use Filament\Notifications\Notification; @@ -15,11 +20,48 @@ use Filament\Tables; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; class TenantMembershipsRelationManager extends RelationManager { protected static string $relationship = 'memberships'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Add member action is available in the relation header.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'Tenant membership rows are managed inline and have no separate inspect destination.') + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Change role and remove stay direct for focused inline membership management.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk membership mutations are intentionally omitted.') + ->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed; add member remains available in the header.'); + } + + public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool + { + if (! $ownerRecord instanceof Tenant) { + return false; + } + + if ($pageClass !== ManageTenantMemberships::class) { + return false; + } + + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + if (! $user->canAccessTenant($ownerRecord)) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $ownerRecord, Capabilities::TENANT_MEMBERSHIP_VIEW); + } + public function table(Table $table): Table { return $table diff --git a/app/Filament/Resources/TenantReviewResource.php b/app/Filament/Resources/TenantReviewResource.php index 2351fcf0..481c02b3 100644 --- a/app/Filament/Resources/TenantReviewResource.php +++ b/app/Filament/Resources/TenantReviewResource.php @@ -120,7 +120,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.') - ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Primary row actions stay limited to View review and Export executive pack.') + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Export executive pack remains the only inline row shortcut.') ->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.'); } @@ -311,9 +311,6 @@ public static function table(Table $table): Table \App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'), ]) ->actions([ - Actions\Action::make('view_review') - ->label('View review') - ->url(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant)), UiEnforcement::forTableAction( Actions\Action::make('export_executive_pack') ->label('Export executive pack') diff --git a/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php b/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php index 82f3cf50..de961e14 100644 --- a/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php +++ b/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php @@ -9,7 +9,11 @@ use App\Support\Auth\Capabilities; use App\Support\Auth\WorkspaceRole; use App\Support\Rbac\WorkspaceUiEnforcement; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use Filament\Actions\Action; +use Filament\Actions\ActionGroup; use Filament\Forms; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; @@ -21,6 +25,16 @@ class WorkspaceMembershipsRelationManager extends RelationManager { protected static string $relationship = 'memberships'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Add member action is available in the relation header.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace membership rows are managed inline and have no separate inspect destination.') + ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Change role stays inline while destructive removal is grouped under More.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk membership mutations are intentionally omitted.') + ->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed; add member remains available in the header.'); + } + public function table(Table $table): Table { return $table @@ -177,46 +191,47 @@ public function table(Table $table): Table ->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE) ->tooltip('You do not have permission to manage workspace memberships.') ->apply(), + ActionGroup::make([ + WorkspaceUiEnforcement::forTableAction( + Action::make('remove') + ->label(__('Remove')) + ->color('danger') + ->icon('heroicon-o-x-mark') + ->requiresConfirmation() + ->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void { + $workspace = $this->getOwnerRecord(); - WorkspaceUiEnforcement::forTableAction( - Action::make('remove') - ->label(__('Remove')) - ->color('danger') - ->icon('heroicon-o-x-mark') - ->requiresConfirmation() - ->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void { - $workspace = $this->getOwnerRecord(); + if (! $workspace instanceof Workspace) { + abort(404); + } - if (! $workspace instanceof Workspace) { - abort(404); - } + $actor = auth()->user(); + if (! $actor instanceof User) { + abort(403); + } - $actor = auth()->user(); - if (! $actor instanceof User) { - abort(403); - } + try { + $manager->removeMember(workspace: $workspace, actor: $actor, membership: $record); + } catch (\Throwable $throwable) { + Notification::make() + ->title(__('Failed to remove member')) + ->body($throwable->getMessage()) + ->danger() + ->send(); - try { - $manager->removeMember(workspace: $workspace, actor: $actor, membership: $record); - } catch (\Throwable $throwable) { - Notification::make() - ->title(__('Failed to remove member')) - ->body($throwable->getMessage()) - ->danger() - ->send(); + return; + } - return; - } - - Notification::make()->title(__('Member removed'))->success()->send(); - $this->resetTable(); - }), - fn () => $this->getOwnerRecord(), - ) - ->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE) - ->tooltip('You do not have permission to manage workspace memberships.') - ->destructive() - ->apply(), + Notification::make()->title(__('Member removed'))->success()->send(); + $this->resetTable(); + }), + fn () => $this->getOwnerRecord(), + ) + ->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE) + ->tooltip('You do not have permission to manage workspace memberships.') + ->destructive() + ->apply(), + ])->label('More'), ]) ->bulkActions([]) ->emptyStateHeading(__('No workspace members')) diff --git a/app/Filament/Resources/Workspaces/WorkspaceResource.php b/app/Filament/Resources/Workspaces/WorkspaceResource.php index a11f6099..6f066bea 100644 --- a/app/Filament/Resources/Workspaces/WorkspaceResource.php +++ b/app/Filament/Resources/Workspaces/WorkspaceResource.php @@ -96,8 +96,8 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) ->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.') - ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) - ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace list intentionally uses only primary View/Edit row actions.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace list intentionally uses only row-click inspection plus a primary Edit action.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Workspace list intentionally omits bulk actions.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines a capability-gated empty-state create CTA.') ->satisfy(ActionSurfaceSlot::DetailHeader, 'Workspace view page exposes a capability-gated edit action.'); @@ -151,6 +151,7 @@ public static function table(Table $table): Table return $table ->defaultSort('name') ->paginated(\App\Support\Filament\TablePaginationProfiles::resource()) + ->recordUrl(fn (Workspace $record): string => static::getUrl('view', ['record' => $record])) ->columns([ Tables\Columns\TextColumn::make('name') ->searchable() @@ -160,7 +161,6 @@ public static function table(Table $table): Table ->sortable(), ]) ->actions([ - Actions\ViewAction::make(), WorkspaceUiEnforcement::forTableAction( Actions\EditAction::make(), fn (): ?Workspace => null, diff --git a/app/Filament/System/Pages/Directory/Tenants.php b/app/Filament/System/Pages/Directory/Tenants.php index 9b2414eb..18e9e6ee 100644 --- a/app/Filament/System/Pages/Directory/Tenants.php +++ b/app/Filament/System/Pages/Directory/Tenants.php @@ -10,6 +10,10 @@ use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\System\SystemDirectoryLinks; +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 Filament\Pages\Page; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Concerns\InteractsWithTable; @@ -31,6 +35,16 @@ class Tenants extends Page implements HasTable protected string $view = 'filament.system.pages.directory.tenants'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) + ->exempt(ActionSurfaceSlot::ListHeader, 'System tenant directory stays scan-first and does not expose page header actions.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Tenant directory rows navigate directly to the detail page and have no secondary actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'System tenant directory does not expose bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains that tenants appear here after onboarding and inventory sync.'); + } + public static function canAccess(): bool { $user = auth('platform')->user(); diff --git a/app/Filament/System/Pages/Directory/Workspaces.php b/app/Filament/System/Pages/Directory/Workspaces.php index c2bd720c..9b2bbcbf 100644 --- a/app/Filament/System/Pages/Directory/Workspaces.php +++ b/app/Filament/System/Pages/Directory/Workspaces.php @@ -14,6 +14,10 @@ use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\System\SystemDirectoryLinks; +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 Filament\Pages\Page; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Concerns\InteractsWithTable; @@ -35,6 +39,16 @@ class Workspaces extends Page implements HasTable protected string $view = 'filament.system.pages.directory.workspaces'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) + ->exempt(ActionSurfaceSlot::ListHeader, 'System workspace directory stays scan-first and does not expose page header actions.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace directory rows navigate directly to the detail page and have no secondary actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'System workspace directory does not expose bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains that workspaces appear here once the platform inventory is seeded.'); + } + public static function canAccess(): bool { $user = auth('platform')->user(); diff --git a/app/Filament/System/Pages/Ops/Failures.php b/app/Filament/System/Pages/Ops/Failures.php index 17e351ec..e110ab6e 100644 --- a/app/Filament/System/Pages/Ops/Failures.php +++ b/app/Filament/System/Pages/Ops/Failures.php @@ -15,6 +15,10 @@ use App\Support\OperationRunStatus; use App\Support\OpsUx\OperationUxPresenter; use App\Support\System\SystemOperationRunLinks; +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 Filament\Actions\Action; use Filament\Forms\Components\Textarea; use Filament\Notifications\Notification; @@ -39,6 +43,16 @@ class Failures extends Page implements HasTable protected string $view = 'filament.system.pages.ops.failures'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog) + ->exempt(ActionSurfaceSlot::ListHeader, 'System failures stay scan-first and rely on row triage rather than page header actions.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Failed-run triage stays per run and intentionally omits bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when there are no failed runs to triage.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.'); + } + public static function getNavigationBadge(): ?string { $count = OperationRun::query() diff --git a/app/Filament/System/Pages/Ops/Runs.php b/app/Filament/System/Pages/Ops/Runs.php index 09da7644..8420da19 100644 --- a/app/Filament/System/Pages/Ops/Runs.php +++ b/app/Filament/System/Pages/Ops/Runs.php @@ -13,6 +13,10 @@ use App\Support\OperationCatalog; use App\Support\OpsUx\OperationUxPresenter; use App\Support\System\SystemOperationRunLinks; +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 Filament\Actions\Action; use Filament\Forms\Components\Textarea; use Filament\Notifications\Notification; @@ -37,6 +41,16 @@ class Runs extends Page implements HasTable protected string $view = 'filament.system.pages.ops.runs'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog) + ->exempt(ActionSurfaceSlot::ListHeader, 'System ops runs rely on inline row triage and do not expose page header actions.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'System ops triage stays per run and intentionally omits bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no system runs have been queued yet.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.'); + } + public static function canAccess(): bool { $user = auth('platform')->user(); diff --git a/app/Filament/System/Pages/Ops/Stuck.php b/app/Filament/System/Pages/Ops/Stuck.php index 4bff90b9..5864ea22 100644 --- a/app/Filament/System/Pages/Ops/Stuck.php +++ b/app/Filament/System/Pages/Ops/Stuck.php @@ -15,6 +15,10 @@ use App\Support\OpsUx\OperationUxPresenter; use App\Support\System\SystemOperationRunLinks; use App\Support\SystemConsole\StuckRunClassifier; +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 Filament\Actions\Action; use Filament\Forms\Components\Textarea; use Filament\Notifications\Notification; @@ -39,6 +43,16 @@ class Stuck extends Page implements HasTable protected string $view = 'filament.system.pages.ops.stuck'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog) + ->exempt(ActionSurfaceSlot::ListHeader, 'System stuck-run triage relies on row actions and does not expose page header actions.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Stuck-run triage stays per run and intentionally omits bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no queued or running runs cross the stuck thresholds.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.'); + } + public static function getNavigationBadge(): ?string { $count = app(StuckRunClassifier::class) diff --git a/app/Filament/System/Pages/Security/AccessLogs.php b/app/Filament/System/Pages/Security/AccessLogs.php index 379fdad2..9a2bb3df 100644 --- a/app/Filament/System/Pages/Security/AccessLogs.php +++ b/app/Filament/System/Pages/Security/AccessLogs.php @@ -7,6 +7,9 @@ use App\Models\AuditLog; use App\Models\PlatformUser; use App\Support\Auth\PlatformCapabilities; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use Filament\Pages\Page; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Concerns\InteractsWithTable; @@ -28,6 +31,16 @@ class AccessLogs extends Page implements HasTable protected string $view = 'filament.system.pages.security.access-logs'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog) + ->exempt(ActionSurfaceSlot::ListHeader, 'Access logs remain scan-first and do not expose page header actions.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'Access logs intentionally keep auth and break-glass events inline without a separate inspect view.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Access logs are immutable and intentionally omit bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no platform auth or break-glass events match the current log scope.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'The access log page does not open per-record detail headers; review stays inline in the table.'); + } + public static function canAccess(): bool { $user = auth('platform')->user(); diff --git a/app/Services/Tenants/TenantActionPolicySurface.php b/app/Services/Tenants/TenantActionPolicySurface.php index 3a4f4ecd..a7cd7ee0 100644 --- a/app/Services/Tenants/TenantActionPolicySurface.php +++ b/app/Services/Tenants/TenantActionPolicySurface.php @@ -153,7 +153,7 @@ private function viewAction(): TenantActionDescriptor family: TenantActionFamily::Neutral, label: 'View', icon: 'heroicon-o-eye', - group: 'primary', + group: 'inspect', ); } diff --git a/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php b/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php index b45d2834..a2268701 100644 --- a/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php +++ b/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php @@ -28,9 +28,6 @@ public static function baseline(): self 'App\\Filament\\Pages\\TenantDashboard' => 'Dashboard retrofit deferred; widget and summary surfaces are excluded from this contract.', 'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests and remains exempt in spec 082.', 'App\\Filament\\Pages\\Workspaces\\ManagedTenantsLanding' => 'Managed-tenant landing retrofit deferred to workspace feature track.', - 'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.', - 'App\\Filament\\Resources\\TenantResource\\RelationManagers\\TenantMembershipsRelationManager' => 'Tenant memberships relation manager retrofit deferred to RBAC membership track.', - 'App\\Filament\\Resources\\Workspaces\\RelationManagers\\WorkspaceMembershipsRelationManager' => 'Workspace memberships relation manager retrofit deferred to workspace RBAC track.', ], TenantOwnedModelFamilies::actionSurfaceBaselineExemptions())); } diff --git a/app/Support/WorkspaceIsolation/TenantOwnedModelFamilies.php b/app/Support/WorkspaceIsolation/TenantOwnedModelFamilies.php index e62706f1..c00edb91 100644 --- a/app/Support/WorkspaceIsolation/TenantOwnedModelFamilies.php +++ b/app/Support/WorkspaceIsolation/TenantOwnedModelFamilies.php @@ -90,8 +90,8 @@ public static function firstSlice(): array 'resource' => RestoreRunResource::class, 'tenant_relationship' => 'tenant', 'search_posture' => 'not_applicable', - 'action_surface' => 'baseline_exemption', - 'action_surface_reason' => 'Restore run resource retrofit is deferred to the restore track and remains explicitly exempt in the action-surface baseline.', + 'action_surface' => 'declared', + 'action_surface_reason' => 'RestoreRunResource declares its action surface contract directly.', 'notes' => 'Restore runs are not part of global search.', ], 'Finding' => [ diff --git a/tests/Browser/TenantMembershipsPageTest.php b/tests/Browser/TenantMembershipsPageTest.php new file mode 100644 index 00000000..e1f46b57 --- /dev/null +++ b/tests/Browser/TenantMembershipsPageTest.php @@ -0,0 +1,52 @@ +browser()->timeout(15_000); + +it('renders tenant memberships only on the dedicated memberships page after scroll hydration', function (): void { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + + $member = User::factory()->create([ + 'email' => 'browser-tenant-member@example.test', + ]); + $member->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'readonly'], + ]); + + $this->actingAs($owner)->withSession([ + WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, + ]); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + $viewPage = visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin')); + + $viewPage + ->assertNoJavaScriptErrors() + ->assertSee((string) $tenant->name) + ->assertScript("document.body.innerText.includes('Add member')", false) + ->assertScript("document.body.innerText.includes('browser-tenant-member@example.test')", false); + + $membershipsPage = visit(TenantResource::getUrl('memberships', ['record' => $tenant->getRouteKey()], panel: 'admin')); + + $membershipsPage + ->assertNoJavaScriptErrors() + ->assertSee('Tenant memberships'); + + $membershipsPage->script(<<<'JS' +window.scrollTo(0, document.body.scrollHeight); +JS); + + $membershipsPage + ->waitForText('Add member') + ->assertNoJavaScriptErrors() + ->assertSee('Memberships') + ->assertSee('Add member') + ->assertSee('browser-tenant-member@example.test') + ->assertSee('Change role') + ->assertSee('Remove'); +}); diff --git a/tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php b/tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php index a21ff0f8..602ba414 100644 --- a/tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php +++ b/tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php @@ -74,6 +74,47 @@ function getBackupScheduleEmptyStateAction(Testable $component, string $name): ? })->toThrow(AuthorizationException::class); }); +it('requires confirmation for destructive backup schedule lifecycle actions', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $activeSchedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Lifecycle confirmation active', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $archivedSchedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Lifecycle confirmation archived', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '02:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + $archivedSchedule->delete(); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->assertTableActionExists('archive', fn (Action $action): bool => $action->isConfirmationRequired(), $activeSchedule); + + Livewire::test(ListBackupSchedules::class) + ->filterTable(TrashedFilter::class, false) + ->assertTableActionExists('forceDelete', fn (Action $action): bool => $action->isConfirmationRequired(), BackupSchedule::withTrashed()->findOrFail($archivedSchedule->id)); +}); + it('disables backup schedule create in the empty state for members without manage capability', function (): void { [$user, $tenant] = createUserWithTenant(role: 'readonly'); diff --git a/tests/Feature/BackupScheduling/BackupScheduleOperationRunsRelationManagerTest.php b/tests/Feature/BackupScheduling/BackupScheduleOperationRunsRelationManagerTest.php index d89462e8..0ca79620 100644 --- a/tests/Feature/BackupScheduling/BackupScheduleOperationRunsRelationManagerTest.php +++ b/tests/Feature/BackupScheduling/BackupScheduleOperationRunsRelationManagerTest.php @@ -76,6 +76,8 @@ function makeBackupScheduleForTenant(\App\Models\Tenant $tenant, string $name): 'pageClass' => EditBackupSchedule::class, ]); - expect(fn () => $component->instance()->mountTableAction('view', (string) $foreignRun->getKey())) + $table = $component->instance()->getTable(); + + expect(fn () => $table->getRecordUrl($foreignRun)) ->toThrow(NotFoundHttpException::class); }); diff --git a/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php b/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php index b6a09d8c..554aa608 100644 --- a/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php +++ b/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php @@ -364,7 +364,17 @@ function seedEvidenceDomain(Tenant $tenant): void ->values() ->all(); - expect($primaryRowActionNames)->toBe(['view_snapshot', 'expire']) + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($primaryRowActionNames)->toBe([]) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing(['expire']) ->and($table->getBulkActions())->toBeEmpty() ->and($table->getRecordUrl($snapshot))->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot])); }); diff --git a/tests/Feature/Guards/ActionSurfaceContractTest.php b/tests/Feature/Guards/ActionSurfaceContractTest.php index b6e3fc72..d46c3735 100644 --- a/tests/Feature/Guards/ActionSurfaceContractTest.php +++ b/tests/Feature/Guards/ActionSurfaceContractTest.php @@ -2,9 +2,26 @@ declare(strict_types=1); +use App\Filament\Pages\InventoryCoverage; +use App\Filament\Pages\Monitoring\Alerts; +use App\Filament\Pages\Monitoring\Operations; +use App\Filament\Pages\NoAccess; +use App\Filament\Pages\Operations\TenantlessOperationRunViewer; +use App\Filament\Pages\TenantDiagnostics; +use App\Filament\Pages\TenantRequiredPermissions; use App\Filament\Resources\AlertDeliveryResource; +use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries; +use App\Filament\Resources\AlertDestinationResource; +use App\Filament\Resources\AlertDestinationResource\Pages\ListAlertDestinations; +use App\Filament\Resources\AlertRuleResource; +use App\Filament\Resources\AlertRuleResource\Pages\ListAlertRules; use App\Filament\Resources\BackupScheduleResource; +use App\Filament\Resources\BackupScheduleResource\Pages\EditBackupSchedule; +use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules; +use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager; use App\Filament\Resources\BackupSetResource; +use App\Filament\Resources\BackupSetResource\Pages\EditBackupSet; +use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager; use App\Filament\Resources\BaselineProfileResource; use App\Filament\Resources\BaselineProfileResource\Pages\ListBaselineProfiles; use App\Filament\Resources\BaselineSnapshotResource; @@ -13,28 +30,77 @@ use App\Filament\Resources\EvidenceSnapshotResource\Pages\ListEvidenceSnapshots; use App\Filament\Resources\FindingExceptionResource; use App\Filament\Resources\FindingExceptionResource\Pages\ListFindingExceptions; +use App\Filament\Resources\FindingResource; +use App\Filament\Resources\FindingResource\Pages\ListFindings; use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems; use App\Filament\Resources\OperationRunResource; use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyResource\Pages\ListPolicies; +use App\Filament\Resources\PolicyResource\Pages\ViewPolicy; use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager; +use App\Filament\Resources\PolicyVersionResource; +use App\Filament\Resources\ProviderConnectionResource; +use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections; +use App\Filament\Resources\RestoreRunResource; +use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns; +use App\Filament\Resources\ReviewPackResource; +use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks; +use App\Filament\Resources\TenantResource; +use App\Filament\Resources\TenantResource\Pages\ManageTenantMemberships; +use App\Filament\Resources\TenantResource\Pages\ViewTenant; +use App\Filament\Resources\TenantResource\RelationManagers\TenantMembershipsRelationManager; +use App\Filament\Resources\TenantReviewResource; +use App\Filament\Resources\TenantReviewResource\Pages\ListTenantReviews; +use App\Filament\Resources\Workspaces\Pages\ListWorkspaces; +use App\Filament\Resources\Workspaces\Pages\ViewWorkspace; +use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager; use App\Filament\Resources\Workspaces\WorkspaceResource; +use App\Filament\System\Pages\Directory\Tenants as SystemDirectoryTenantsPage; +use App\Filament\System\Pages\Directory\Workspaces as SystemDirectoryWorkspacesPage; +use App\Filament\System\Pages\Ops\Failures as SystemFailuresPage; +use App\Filament\System\Pages\Ops\Runs as SystemRunsPage; +use App\Filament\System\Pages\Ops\Stuck as SystemStuckPage; +use App\Filament\System\Pages\Security\AccessLogs as SystemAccessLogsPage; use App\Jobs\SyncPoliciesJob; +use App\Models\AlertDelivery; +use App\Models\AlertDestination; +use App\Models\AlertRule; +use App\Models\AuditLog; +use App\Models\BackupItem; +use App\Models\BackupSchedule; +use App\Models\BackupSet; use App\Models\BaselineProfile; use App\Models\EvidenceSnapshot; +use App\Models\Finding; use App\Models\InventoryItem; use App\Models\OperationRun; +use App\Models\PlatformUser; +use App\Models\Policy; +use App\Models\PolicyVersion; +use App\Models\ProviderConnection; +use App\Models\RestoreRun; +use App\Models\ReviewPack; use App\Models\Tenant; +use App\Models\TenantMembership; +use App\Models\User; +use App\Models\Workspace; +use App\Models\WorkspaceMembership; +use App\Support\Auth\PlatformCapabilities; use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\OperationRunLinks; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; +use App\Support\System\SystemDirectoryLinks; +use App\Support\System\SystemOperationRunLinks; use App\Support\Ui\ActionSurface\ActionSurfaceExemptions; use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition; use App\Support\Ui\ActionSurface\ActionSurfaceValidator; use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies; +use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\ActionGroup; use Filament\Actions\BulkActionGroup; use Filament\Facades\Filament; @@ -44,6 +110,24 @@ uses(RefreshDatabase::class); +function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser +{ + Filament::setCurrentPanel('system'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + $platformUser = PlatformUser::factory()->create([ + 'capabilities' => array_values(array_unique(array_merge([ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + ], $capabilities))), + 'is_active' => true, + ]); + + test()->actingAs($platformUser, 'platform'); + + return $platformUser; +} + it('passes the action surface contract guard for current repository state', function (): void { $result = ActionSurfaceValidator::withBaselineExemptions()->validate(); @@ -142,8 +226,8 @@ ->values() ->all(); - expect($primaryRowActionNames)->toContain('view'); - expect($primaryRowActionNames)->not->toContain('archive'); + expect($primaryRowActionNames)->toBe([]) + ->and($table->getRecordUrl($profile))->toBe(BaselineProfileResource::getUrl('view', ['record' => $profile])); $primaryRowActionCount = count($primaryRowActionNames); expect($primaryRowActionCount)->toBeLessThanOrEqual(2); @@ -155,30 +239,430 @@ ->all(); expect($moreActionNames)->toContain('archive'); + expect($table->getBulkActions())->toBeEmpty(); +}); + +it('keeps backup schedules on clickable-row edit without duplicate Edit actions in More', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => 'Nightly backup', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(ListBackupSchedules::class) + ->assertCanSeeTableRecords([$schedule]); + + $table = $livewire->instance()->getTable(); + $rowActions = $table->getActions(); + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + + expect($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More'); + + $primaryRowActionNames = collect($rowActions) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($primaryRowActionNames)->toBe([]) + ->and($table->getRecordUrl($schedule))->toBe(BackupScheduleResource::getUrl('edit', ['record' => $schedule])); + + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($moreActionNames)->toContain('runNow', 'retry', 'archive') + ->and($moreActionNames)->not->toContain('edit'); + + $bulkActions = $table->getBulkActions(); + $bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup); + + expect($bulkGroup)->toBeInstanceOf(BulkActionGroup::class) + ->and($bulkGroup?->getLabel())->toBe('More'); + + $bulkActionNames = collect($bulkGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($bulkActionNames)->toEqualCanonicalizing(['bulk_run_now', 'bulk_retry']); +}); + +it('uses clickable rows without extra row actions on backup schedule executions', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => 'Nightly backup', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'type' => 'backup_schedule_run', + 'context' => ['backup_schedule_id' => (int) $schedule->getKey()], + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(BackupScheduleOperationRunsRelationManager::class, [ + 'ownerRecord' => $schedule, + 'pageClass' => EditBackupSchedule::class, + ]) + ->assertCanSeeTableRecords([$run]); + + $table = $livewire->instance()->getTable(); + + expect($table->getActions())->toBeEmpty() + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($run))->toBe(OperationRunLinks::view($run, $tenant)); +}); + +it('uses clickable rows while keeping remove grouped under More on backup items', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'metadata' => [], + ]); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $backupItem = BackupItem::factory()->for($backupSet)->for($tenant)->create([ + 'policy_id' => (int) $policy->getKey(), + 'policy_version_id' => (int) $version->getKey(), + 'metadata' => [], + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(BackupItemsRelationManager::class, [ + 'ownerRecord' => $backupSet, + 'pageClass' => EditBackupSet::class, + ]) + ->assertCanSeeTableRecords([$backupItem]); + + $table = $livewire->instance()->getTable(); + $rowActions = $table->getActions(); + $primaryRowActionNames = collect($rowActions) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $bulkActions = $table->getBulkActions(); + $bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup); + $bulkActionNames = collect($bulkGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($primaryRowActionNames)->toBe([]) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing(['remove']) + ->and($bulkGroup)->toBeInstanceOf(BulkActionGroup::class) + ->and($bulkGroup?->getLabel())->toBe('More') + ->and($bulkActionNames)->toEqualCanonicalizing(['bulk_remove']) + ->and($table->getRecordUrl($backupItem))->toBe(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); +}); + +it('keeps tenant memberships inline without a separate inspect affordance', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $member = User::factory()->create(); + $member->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'readonly'], + ]); + + $membership = TenantMembership::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('user_id', (int) $member->getKey()) + ->firstOrFail(); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(TenantMembershipsRelationManager::class, [ + 'ownerRecord' => $tenant, + 'pageClass' => ViewTenant::class, + ]) + ->assertCanSeeTableRecords([$membership]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['change_role', 'remove']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($membership))->toBeNull(); +}); + +it('keeps workspace memberships inline without a separate inspect affordance', function (): void { + $workspace = Workspace::factory()->create(); + $owner = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $owner->getKey(), + 'role' => 'owner', + ]); + + $member = User::factory()->create(); + + $membership = WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $member->getKey(), + 'role' => 'readonly', + ]); + + $this->actingAs($owner); + + $livewire = Livewire::test(WorkspaceMembershipsRelationManager::class, [ + 'ownerRecord' => $workspace, + 'pageClass' => ViewWorkspace::class, + ]) + ->assertCanSeeTableRecords([$membership]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $moreGroup = collect($table->getActions()) + ->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions() ?? []) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['change_role']) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing(['remove']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($membership))->toBeNull(); +}); + +it('renders the policy versions relation manager on the policy detail page', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'created_by' => 'versions-surface@example.test', + 'metadata' => [], + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $this->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)) + ->assertOk() + ->assertSee('Versions'); +}); + +it('renders tenant memberships only on the dedicated memberships page', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $member = User::factory()->create([ + 'email' => 'tenant-members-surface@example.test', + ]); + $member->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'readonly'], + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin')) + ->assertOk() + ->assertDontSeeLivewire(TenantMembershipsRelationManager::class); + + session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + + $membershipsPage = Livewire::actingAs($user) + ->test(ManageTenantMemberships::class, ['record' => $tenant->getRouteKey()]); + + expect($membershipsPage->instance()->getRelationManagers()) + ->toContain(TenantMembershipsRelationManager::class); + + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(TenantResource::getUrl('memberships', ['record' => $tenant->getRouteKey()], panel: 'admin')) + ->assertOk() + ->assertSeeLivewire(TenantMembershipsRelationManager::class); +}); + +it('renders the backup items relation manager on the backup set detail page', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'display_name' => 'Backup Items Surface Policy', + ]); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + BackupItem::factory()->for($backupSet)->for($tenant)->create([ + 'policy_id' => (int) $policy->getKey(), + 'policy_version_id' => null, + 'metadata' => [], + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant)) + ->assertOk() + ->assertSee('Items'); +}); + +it('renders the workspace memberships relation manager on the workspace detail page', function (): void { + $workspace = Workspace::factory()->create(); + $owner = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $owner->getKey(), + 'role' => 'owner', + ]); + + $member = User::factory()->create([ + 'email' => 'workspace-members-surface@example.test', + ]); + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $member->getKey(), + 'role' => 'readonly', + ]); + + $this->actingAs($owner); + + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(WorkspaceResource::getUrl('view', ['record' => $workspace])) + ->assertOk() + ->assertSee('Memberships'); +}); + +it('keeps inventory coverage as derived metadata without inspect or row action affordances', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $conditionalAccessKey = 'policy:conditionalAccessPolicy'; + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(InventoryCoverage::class) + ->assertCanSeeTableRecords([$conditionalAccessKey]) + ->assertTableEmptyStateActionsExistInOrder(['clear_filters']); + + $table = $livewire->instance()->getTable(); + $declaration = InventoryCoverage::actionSurfaceDeclaration(); + + expect($table->getActions())->toBeEmpty() + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getEmptyStateActions())->toHaveCount(1) + ->and((string) ($declaration->exemption(ActionSurfaceSlot::InspectAffordance)?->reason ?? '')) + ->toContain('runtime-derived metadata'); }); it('ensures representative declarations satisfy required slots', function (): void { $profiles = new ActionSurfaceProfileDefinition; $declarations = [ - \App\Filament\Pages\InventoryCoverage::class => \App\Filament\Pages\InventoryCoverage::actionSurfaceDeclaration(), - \App\Filament\Pages\Monitoring\Operations::class => \App\Filament\Pages\Monitoring\Operations::actionSurfaceDeclaration(), - \App\Filament\Pages\NoAccess::class => \App\Filament\Pages\NoAccess::actionSurfaceDeclaration(), - \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class => \App\Filament\Pages\Operations\TenantlessOperationRunViewer::actionSurfaceDeclaration(), - \App\Filament\Pages\TenantDiagnostics::class => \App\Filament\Pages\TenantDiagnostics::actionSurfaceDeclaration(), - \App\Filament\Pages\TenantRequiredPermissions::class => \App\Filament\Pages\TenantRequiredPermissions::actionSurfaceDeclaration(), + InventoryCoverage::class => InventoryCoverage::actionSurfaceDeclaration(), + NoAccess::class => NoAccess::actionSurfaceDeclaration(), + TenantlessOperationRunViewer::class => TenantlessOperationRunViewer::actionSurfaceDeclaration(), + TenantDiagnostics::class => TenantDiagnostics::actionSurfaceDeclaration(), + TenantRequiredPermissions::class => TenantRequiredPermissions::actionSurfaceDeclaration(), AlertDeliveryResource::class => AlertDeliveryResource::actionSurfaceDeclaration(), BackupScheduleResource::class => BackupScheduleResource::actionSurfaceDeclaration(), + BackupScheduleOperationRunsRelationManager::class => BackupScheduleOperationRunsRelationManager::actionSurfaceDeclaration(), BackupSetResource::class => BackupSetResource::actionSurfaceDeclaration(), + BackupItemsRelationManager::class => BackupItemsRelationManager::actionSurfaceDeclaration(), BaselineSnapshotResource::class => BaselineSnapshotResource::actionSurfaceDeclaration(), EntraGroupResource::class => EntraGroupResource::actionSurfaceDeclaration(), EvidenceSnapshotResource::class => EvidenceSnapshotResource::actionSurfaceDeclaration(), FindingExceptionResource::class => FindingExceptionResource::actionSurfaceDeclaration(), + Operations::class => Operations::actionSurfaceDeclaration(), PolicyResource::class => PolicyResource::actionSurfaceDeclaration(), OperationRunResource::class => OperationRunResource::actionSurfaceDeclaration(), + ReviewPackResource::class => ReviewPackResource::actionSurfaceDeclaration(), + RestoreRunResource::class => RestoreRunResource::actionSurfaceDeclaration(), + TenantMembershipsRelationManager::class => TenantMembershipsRelationManager::actionSurfaceDeclaration(), VersionsRelationManager::class => VersionsRelationManager::actionSurfaceDeclaration(), BaselineProfileResource::class => BaselineProfileResource::actionSurfaceDeclaration(), + WorkspaceMembershipsRelationManager::class => WorkspaceMembershipsRelationManager::actionSurfaceDeclaration(), WorkspaceResource::class => WorkspaceResource::actionSurfaceDeclaration(), + SystemRunsPage::class => SystemRunsPage::actionSurfaceDeclaration(), + SystemFailuresPage::class => SystemFailuresPage::actionSurfaceDeclaration(), + SystemStuckPage::class => SystemStuckPage::actionSurfaceDeclaration(), + SystemDirectoryTenantsPage::class => SystemDirectoryTenantsPage::actionSurfaceDeclaration(), + SystemDirectoryWorkspacesPage::class => SystemDirectoryWorkspacesPage::actionSurfaceDeclaration(), + SystemAccessLogsPage::class => SystemAccessLogsPage::actionSurfaceDeclaration(), ]; foreach ($declarations as $className => $declaration) { @@ -221,8 +705,15 @@ it('keeps first-slice tenant-owned action-surface exemptions registry-backed and explicit', function (): void { $baselineExemptions = ActionSurfaceExemptions::baseline(); + $registeredExemptions = TenantOwnedModelFamilies::actionSurfaceBaselineExemptions(); + $declaredExemptions = collect(TenantOwnedModelFamilies::firstSlice()) + ->filter(static fn (array $family): bool => $family['action_surface'] === 'baseline_exemption') + ->mapWithKeys(static fn (array $family): array => [$family['resource'] => $family['action_surface_reason']]) + ->all(); - foreach (TenantOwnedModelFamilies::actionSurfaceBaselineExemptions() as $className => $reason) { + expect($registeredExemptions)->toBe($declaredExemptions); + + foreach ($registeredExemptions as $className => $reason) { expect($baselineExemptions->reasonForClass($className)) ->toBe($reason); } @@ -240,12 +731,12 @@ it('keeps first-slice trusted-state page action-surface status explicit', function (): void { $baselineExemptions = ActionSurfaceExemptions::baseline(); - expect(method_exists(\App\Filament\Pages\TenantRequiredPermissions::class, 'actionSurfaceDeclaration'))->toBeTrue() - ->and($baselineExemptions->hasClass(\App\Filament\Pages\TenantRequiredPermissions::class))->toBeFalse(); + expect(method_exists(TenantRequiredPermissions::class, 'actionSurfaceDeclaration'))->toBeTrue() + ->and($baselineExemptions->hasClass(TenantRequiredPermissions::class))->toBeFalse(); - expect(method_exists(\App\Filament\Pages\Monitoring\Alerts::class, 'actionSurfaceDeclaration'))->toBeFalse() - ->and($baselineExemptions->hasClass(\App\Filament\Pages\Monitoring\Alerts::class))->toBeTrue() - ->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\Monitoring\Alerts::class))->toContain('cluster entry'); + expect(method_exists(Alerts::class, 'actionSurfaceDeclaration'))->toBeFalse() + ->and($baselineExemptions->hasClass(Alerts::class))->toBeTrue() + ->and((string) $baselineExemptions->reasonForClass(Alerts::class))->toContain('cluster entry'); expect($baselineExemptions->hasClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toBeTrue() ->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toContain('dedicated conformance tests'); @@ -254,16 +745,103 @@ ->and($baselineExemptions->hasClass(\App\Filament\System\Pages\Ops\Runbooks::class))->toBeFalse(); }); -it('keeps cleanup-slice pages declaration-backed without stale baseline exemptions', function (): void { +it('keeps enrolled system panel pages declaration-backed without stale baseline exemptions', function (): void { $baselineExemptions = ActionSurfaceExemptions::baseline(); foreach ([ - \App\Filament\Pages\InventoryCoverage::class, - \App\Filament\Pages\Monitoring\Operations::class, - \App\Filament\Pages\NoAccess::class, - \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class, - \App\Filament\Pages\TenantDiagnostics::class, - \App\Filament\Pages\TenantRequiredPermissions::class, + SystemRunsPage::class, + SystemFailuresPage::class, + SystemStuckPage::class, + SystemDirectoryTenantsPage::class, + SystemDirectoryWorkspacesPage::class, + SystemAccessLogsPage::class, + ] as $className) { + expect(method_exists($className, 'actionSurfaceDeclaration')) + ->toBeTrue("{$className} should declare its action surface once enrolled."); + + expect($baselineExemptions->hasClass($className)) + ->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment."); + } +}); + +it('keeps enrolled relation managers declaration-backed without stale baseline exemptions', function (): void { + $baselineExemptions = ActionSurfaceExemptions::baseline(); + + foreach ([ + BackupItemsRelationManager::class, + TenantMembershipsRelationManager::class, + WorkspaceMembershipsRelationManager::class, + ] as $className) { + expect(method_exists($className, 'actionSurfaceDeclaration')) + ->toBeTrue("{$className} should declare its action surface once enrolled."); + + expect($baselineExemptions->hasClass($className)) + ->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment."); + } +}); + +it('keeps enrolled monitoring pages declaration-backed without stale baseline exemptions', function (): void { + $baselineExemptions = ActionSurfaceExemptions::baseline(); + + foreach ([ + Operations::class, + ] as $className) { + expect(method_exists($className, 'actionSurfaceDeclaration')) + ->toBeTrue("{$className} should declare its action surface once enrolled."); + + expect($baselineExemptions->hasClass($className)) + ->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment."); + } +}); + +it('keeps enrolled tenant table pages declaration-backed without stale baseline exemptions', function (): void { + $baselineExemptions = ActionSurfaceExemptions::baseline(); + + foreach ([ + InventoryCoverage::class, + ] as $className) { + expect(method_exists($className, 'actionSurfaceDeclaration')) + ->toBeTrue("{$className} should declare its action surface once enrolled."); + + expect($baselineExemptions->hasClass($className)) + ->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment."); + } +}); + +it('keeps enrolled canonical detail pages declaration-backed without stale baseline exemptions', function (): void { + $baselineExemptions = ActionSurfaceExemptions::baseline(); + + foreach ([ + TenantlessOperationRunViewer::class, + ] as $className) { + expect(method_exists($className, 'actionSurfaceDeclaration')) + ->toBeTrue("{$className} should declare its action surface once enrolled."); + + expect($baselineExemptions->hasClass($className)) + ->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment."); + } +}); + +it('keeps enrolled singleton tenant pages declaration-backed without stale baseline exemptions', function (): void { + $baselineExemptions = ActionSurfaceExemptions::baseline(); + + foreach ([ + NoAccess::class, + TenantDiagnostics::class, + ] as $className) { + expect(method_exists($className, 'actionSurfaceDeclaration')) + ->toBeTrue("{$className} should declare its action surface once enrolled."); + + expect($baselineExemptions->hasClass($className)) + ->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment."); + } +}); + +it('keeps enrolled guided workspace diagnostic pages declaration-backed without stale baseline exemptions', function (): void { + $baselineExemptions = ActionSurfaceExemptions::baseline(); + + foreach ([ + TenantRequiredPermissions::class, ] as $className) { expect(method_exists($className, 'actionSurfaceDeclaration')) ->toBeTrue("{$className} should declare its action surface once enrolled."); @@ -368,9 +946,223 @@ ->values() ->all(); - expect($primaryRowActionNames)->toBe(['view_snapshot', 'expire']); - expect($table->getBulkActions())->toBeEmpty(); - expect($table->getRecordUrl($snapshot))->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot])); + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($primaryRowActionNames)->toBe([]) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing(['expire']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($snapshot))->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot])); +}); + +it('uses clickable rows without a duplicate View action on the tenant reviews list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $review = composeTenantReviewForTest($tenant, $user); + + $this->actingAs($user); + setTenantPanelContext($tenant); + + $livewire = Livewire::test(ListTenantReviews::class) + ->assertCanSeeTableRecords([$review]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['export_executive_pack']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($review))->toBe(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)); +}); + +it('uses clickable rows while keeping direct download and expire shortcuts on the review packs list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $pack = ReviewPack::factory()->ready()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'initiated_by_user_id' => (int) $user->getKey(), + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(ListReviewPacks::class) + ->assertCanSeeTableRecords([$pack]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['download', 'expire']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($pack))->toBe(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant)); +}); + +it('uses clickable rows while grouping restore-run maintenance actions under More', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'status' => 'completed', + ]); + + $restoreRun = RestoreRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'status' => 'completed', + 'deleted_at' => null, + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(ListRestoreRuns::class) + ->assertCanSeeTableRecords([$restoreRun]); + + $table = $livewire->instance()->getTable(); + $rowActions = $table->getActions(); + $primaryRowActionNames = collect($rowActions) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $bulkActions = $table->getBulkActions(); + $bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup); + $bulkActionNames = collect($bulkGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($primaryRowActionNames)->toBe([]) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing(['rerun', 'restore', 'archive', 'forceDelete']) + ->and($bulkGroup)->toBeInstanceOf(BulkActionGroup::class) + ->and($bulkGroup?->getLabel())->toBe('More') + ->and($bulkActionNames)->toEqualCanonicalizing(['bulk_delete', 'bulk_restore', 'bulk_force_delete']) + ->and($table->getRecordUrl($restoreRun))->toBe(RestoreRunResource::getUrl('view', ['record' => $restoreRun], tenant: $tenant)); +}); + +it('keeps findings on clickable-row inspection with a single related drill-down and grouped workflow actions', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $finding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(ListFindings::class) + ->assertCanSeeTableRecords([$finding]); + + $table = $livewire->instance()->getTable(); + $rowActions = $table->getActions(); + $primaryRowActionNames = collect($rowActions) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $bulkActions = $table->getBulkActions(); + $bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup); + $bulkActionNames = collect($bulkGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($primaryRowActionNames)->toEqualCanonicalizing(['primary_drill_down']) + ->and($table->getRecordUrl($finding))->toBe(FindingResource::getUrl('view', ['record' => $finding])) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing([ + 'triage', + 'start_progress', + 'assign', + 'resolve', + 'close', + 'request_exception', + 'renew_exception', + 'revoke_exception', + 'reopen', + ]) + ->and($bulkGroup)->toBeInstanceOf(BulkActionGroup::class) + ->and($bulkGroup?->getLabel())->toBe('More') + ->and($bulkActionNames)->toEqualCanonicalizing([ + 'triage_selected', + 'assign_selected', + 'resolve_selected', + 'close_selected', + ]); +}); + +it('uses clickable rows with restore as the only inline shortcut on the policy versions relation manager', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'metadata' => [], + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(VersionsRelationManager::class, [ + 'ownerRecord' => $policy, + 'pageClass' => ViewPolicy::class, + ]) + ->assertCanSeeTableRecords([$version]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['restore_to_intune']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($version))->toBe(PolicyVersionResource::getUrl('view', ['record' => $version])); }); it('uses canonical tenantless View run links on representative operation links', function (): void { @@ -384,6 +1176,270 @@ ->toBe(route('admin.operations.view', ['run' => (int) $run->getKey()])); }); +it('uses clickable rows without a lone View action on the monitoring operations list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'policy.sync', + 'status' => 'queued', + 'outcome' => 'pending', + ]); + + $this->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(Operations::class) + ->assertCanSeeTableRecords([$run]); + + $table = $livewire->instance()->getTable(); + + expect($table->getActions())->toBeEmpty() + ->and($table->getRecordUrl($run))->toBe(OperationRunLinks::tenantlessView($run)); +}); + +it('keeps tenantless run detail header actions on the canonical viewer without list affordances', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => null, + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + ]); + + session()->forget(WorkspaceContext::SESSION_KEY); + + Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]) + ->assertActionVisible('operate_hub_scope_run_detail') + ->assertActionVisible('operate_hub_back_to_operations') + ->assertActionVisible('refresh'); + + $declaration = TenantlessOperationRunViewer::actionSurfaceDeclaration(); + + expect((string) ($declaration->exemption(ActionSurfaceSlot::InspectAffordance)?->reason ?? '')) + ->toContain('canonical detail destination') + ->and((string) ($declaration->slot(ActionSurfaceSlot::DetailHeader)?->details ?? '')) + ->toContain('refresh'); +}); + +it('keeps tenant diagnostics as a singleton repair surface with header actions only', function (): void { + [$manager, $tenant] = createUserWithTenant(role: 'manager'); + + $this->actingAs($manager); + Filament::setTenant($tenant, true); + + Livewire::test(TenantDiagnostics::class) + ->assertActionVisible('bootstrapOwner') + ->assertActionEnabled('bootstrapOwner'); + + $declaration = TenantDiagnostics::actionSurfaceDeclaration(); + + expect((string) ($declaration->slot(ActionSurfaceSlot::ListHeader)?->details ?? '')) + ->toContain('repair actions') + ->and((string) ($declaration->exemption(ActionSurfaceSlot::InspectAffordance)?->reason ?? '')) + ->toContain('singleton diagnostic surface'); +}); + +it('keeps the no-access page as a singleton recovery surface with a header action', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + Livewire::test(NoAccess::class) + ->assertActionVisible('createWorkspace') + ->assertActionEnabled('createWorkspace'); + + $declaration = NoAccess::actionSurfaceDeclaration(); + + expect((string) ($declaration->slot(ActionSurfaceSlot::ListHeader)?->details ?? '')) + ->toContain('create-workspace recovery action') + ->and((string) ($declaration->exemption(ActionSurfaceSlot::InspectAffordance)?->reason ?? '')) + ->toContain('singleton recovery surface'); +}); + +it('keeps required permissions as a guided diagnostic page with inline filters and empty-state guidance', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $response = $this->actingAs($user) + ->get("/admin/tenants/{$tenant->external_id}/required-permissions"); + + $response->assertOk() + ->assertSee('Copy missing application permissions') + ->assertSee('Copy missing delegated permissions') + ->assertSee('Re-run verification') + ->assertSee('Start verification'); + + $declaration = TenantRequiredPermissions::actionSurfaceDeclaration(); + + expect((string) ($declaration->exemption(ActionSurfaceSlot::ListHeader)?->reason ?? '')) + ->toContain('body sections') + ->and((string) ($declaration->slot(ActionSurfaceSlot::ListEmptyState)?->details ?? '')) + ->toContain('no-data'); +}); + +it('uses clickable rows with direct triage actions on the system runs list', function (): void { + $run = OperationRun::factory()->create([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'type' => 'inventory_sync', + ]); + + actionSurfaceSystemPanelContext([ + PlatformCapabilities::OPERATIONS_VIEW, + PlatformCapabilities::OPERATIONS_MANAGE, + ]); + + $livewire = Livewire::test(SystemRunsPage::class) + ->assertCanSeeTableRecords([$run]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['retry', 'cancel', 'mark_investigated']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run)); +}); + +it('uses clickable rows with direct triage actions on the system failures list', function (): void { + $run = OperationRun::factory()->create([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'type' => 'inventory_sync', + ]); + + actionSurfaceSystemPanelContext([ + PlatformCapabilities::OPERATIONS_VIEW, + PlatformCapabilities::OPERATIONS_MANAGE, + ]); + + $livewire = Livewire::test(SystemFailuresPage::class) + ->assertCanSeeTableRecords([$run]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['retry', 'cancel', 'mark_investigated']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run)); +}); + +it('uses clickable rows with direct triage actions on the system stuck list', function (): void { + $run = OperationRun::factory()->create([ + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subHours(2), + 'started_at' => null, + 'type' => 'inventory_sync', + ]); + + actionSurfaceSystemPanelContext([ + PlatformCapabilities::OPERATIONS_VIEW, + PlatformCapabilities::OPERATIONS_MANAGE, + ]); + + $livewire = Livewire::test(SystemStuckPage::class) + ->assertCanSeeTableRecords([$run]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['retry', 'cancel', 'mark_investigated']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run)); +}); + +it('uses clickable rows without extra row actions on the system tenants directory', function (): void { + $workspace = Workspace::factory()->create([ + 'name' => 'System Directory Workspace', + ]); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'System Directory Tenant', + ]); + + actionSurfaceSystemPanelContext([ + PlatformCapabilities::DIRECTORY_VIEW, + ]); + + $livewire = Livewire::test(SystemDirectoryTenantsPage::class) + ->assertCanSeeTableRecords([$tenant]); + + $table = $livewire->instance()->getTable(); + + expect($table->getActions())->toBeEmpty() + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($tenant))->toBe(SystemDirectoryLinks::tenantDetail($tenant)); +}); + +it('uses clickable rows without extra row actions on the system workspaces directory', function (): void { + $workspace = Workspace::factory()->create([ + 'name' => 'System Directory Workspace', + ]); + + actionSurfaceSystemPanelContext([ + PlatformCapabilities::DIRECTORY_VIEW, + ]); + + $livewire = Livewire::test(SystemDirectoryWorkspacesPage::class) + ->assertCanSeeTableRecords([$workspace]); + + $table = $livewire->instance()->getTable(); + + expect($table->getActions())->toBeEmpty() + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($workspace))->toBe(SystemDirectoryLinks::workspaceDetail($workspace)); +}); + +it('keeps system access logs scan-only without row or bulk actions', function (): void { + $tenant = Tenant::factory()->create(); + + $log = AuditLog::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'action' => 'platform.auth.login', + 'status' => 'success', + 'metadata' => ['attempted_email' => 'operator@tenantpilot.test'], + 'recorded_at' => now(), + ]); + + actionSurfaceSystemPanelContext([ + PlatformCapabilities::CONSOLE_VIEW, + ]); + + $livewire = Livewire::test(SystemAccessLogsPage::class) + ->assertCanSeeTableRecords([$log]); + + $table = $livewire->instance()->getTable(); + + expect($table->getActions())->toBeEmpty() + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($log))->toBeNull(); +}); + it('removes lone View buttons and uses clickable rows on the inventory items list', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -406,6 +1462,223 @@ expect($recordUrl)->toBe(InventoryItemResource::getUrl('view', ['record' => $item])); }); +it('uses clickable rows without a lone View action on the workspaces list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + Filament::setTenant(null, true); + + $workspace = $tenant->workspace; + + $livewire = Livewire::test(ListWorkspaces::class) + ->assertCanSeeTableRecords([$workspace]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toContain('edit') + ->and($rowActionNames)->not->toContain('view') + ->and($table->getRecordUrl($workspace))->toBe(WorkspaceResource::getUrl('view', ['record' => $workspace])); +}); + +it('uses clickable rows without a lone View action on the policies list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $policy = Policy::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'external_id' => 'policy-action-surface-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy Action Surface', + 'platform' => 'windows', + 'last_synced_at' => now(), + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(ListPolicies::class) + ->assertCanSeeTableRecords([$policy]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->not->toContain('view') + ->and($table->getRecordUrl($policy))->toBe(PolicyResource::getUrl('view', ['record' => $policy])); +}); + +it('uses clickable rows without a duplicate Edit action on the alert rules list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $workspaceId = (int) $tenant->workspace_id; + $rule = AlertRule::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $this->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, $workspaceId); + Filament::setTenant(null, true); + + $livewire = Livewire::test(ListAlertRules::class) + ->assertCanSeeTableRecords([$rule]); + + $table = $livewire->instance()->getTable(); + $rowActions = $table->getActions(); + $rowActionNames = collect($rowActions) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->not->toContain('edit') + ->and($table->getRecordUrl($rule))->toBe(AlertRuleResource::getUrl('edit', ['record' => $rule])) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing(['toggle_enabled', 'delete']) + ->and($table->getBulkActions())->toBeEmpty(); +}); + +it('uses clickable rows without a duplicate Edit action on the alert destinations list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $workspaceId = (int) $tenant->workspace_id; + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $this->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, $workspaceId); + Filament::setTenant(null, true); + + $livewire = Livewire::test(ListAlertDestinations::class) + ->assertCanSeeTableRecords([$destination]); + + $table = $livewire->instance()->getTable(); + $rowActions = $table->getActions(); + $rowActionNames = collect($rowActions) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->not->toContain('edit') + ->and($table->getRecordUrl($destination))->toBe(AlertDestinationResource::getUrl('edit', ['record' => $destination])) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing(['toggle_enabled', 'delete']) + ->and($table->getBulkActions())->toBeEmpty(); +}); + +it('uses clickable-row view with all secondary provider connection actions grouped under More', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); + + $connection = ProviderConnection::factory()->platform()->consentGranted()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'provider' => 'microsoft', + 'status' => 'connected', + 'is_default' => false, + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(ListProviderConnections::class) + ->assertCanSeeTableRecords([$connection]); + + $table = $livewire->instance()->getTable(); + $rowActions = $table->getActions(); + $rowActionNames = collect($rowActions) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toBeEmpty() + ->and($table->getRecordUrl($connection))->toBe(ProviderConnectionResource::getUrl('view', ['record' => $connection], tenant: $tenant)) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing([ + 'edit', + 'check_connection', + 'inventory_sync', + 'compliance_snapshot', + 'set_default', + 'enable_dedicated_override', + 'rotate_dedicated_credential', + 'delete_dedicated_credential', + 'revert_to_platform', + 'enable_connection', + 'disable_connection', + ]) + ->and($table->getBulkActions())->toBeEmpty(); +}); + +it('uses clickable rows without extra row actions on the alert deliveries list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $workspaceId = (int) $tenant->workspace_id; + $rule = AlertRule::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + $delivery = AlertDelivery::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => (int) $tenant->getKey(), + 'alert_rule_id' => (int) $rule->getKey(), + 'alert_destination_id' => (int) $destination->getKey(), + ]); + + $this->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, $workspaceId); + Filament::setTenant(null, true); + + $livewire = Livewire::test(ListAlertDeliveries::class) + ->assertCanSeeTableRecords([$delivery]); + + $table = $livewire->instance()->getTable(); + + expect($table->getActions())->toBeEmpty() + ->and($table->getRecordUrl($delivery))->toBe(AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin')); +}); + it('keeps representative operation-start actions observable with actor and scope metadata', function (): void { Queue::fake(); bindFailHardGraphClient(); diff --git a/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php b/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php index 74b4f9f9..f3e69b1c 100644 --- a/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php +++ b/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php @@ -13,7 +13,6 @@ use App\Models\PolicyVersion; use App\Models\User; use App\Models\WorkspaceMembership; -use Filament\Actions\Action; use Filament\Facades\Filament; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; @@ -110,16 +109,16 @@ ], ]); - Livewire::test(BackupItemsRelationManager::class, [ + $component = Livewire::test(BackupItemsRelationManager::class, [ 'ownerRecord' => $backupSet, 'pageClass' => EditBackupSet::class, ]) - ->assertTableColumnFormattedStateSet('policy.display_name', 'Captured RBAC role', $backupItem) - ->assertTableActionVisible('view', $backupItem) - ->assertTableActionExists('view', function (Action $action) use ($tenant, $version): bool { - return $action->getLabel() === 'View version' - && $action->getUrl() === PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant); - }, $backupItem); + ->assertTableColumnFormattedStateSet('policy.display_name', 'Captured RBAC role', $backupItem); + + $table = $component->instance()->getTable(); + + expect($table->getRecordUrl($backupItem)) + ->toBe(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); }); it('returns 404 and queues nothing when a forged foreign-tenant row action record is submitted', function (): void { diff --git a/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php b/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php index 99fa5e16..222bd17c 100644 --- a/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php +++ b/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php @@ -9,6 +9,7 @@ use App\Models\Tenant; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Workspaces\WorkspaceContext; +use Filament\Actions\ActionGroup; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -224,7 +225,7 @@ function tenantActionSurfaceSearchTitles($results): array ->assertTableActionHidden('archive', $archivedTenant); }); -it('documents and preserves the tenant row overflow contract for lifecycle actions', function (): void { +it('documents the tenant row-click and More-menu contract for lifecycle actions', function (): void { $tenant = Tenant::factory()->active()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false); @@ -242,17 +243,36 @@ function tenantActionSurfaceSearchTitles($results): array $declaration = TenantResource::actionSurfaceDeclaration(); - expect($declaration->listRowPrimaryActionLimit())->toBe(2) - ->and((string) ($declaration->slot(ActionSurfaceSlot::ListRowMoreMenu)?->details ?? ''))->toContain('two'); + expect($declaration->listRowPrimaryActionLimit())->toBe(1) + ->and((string) ($declaration->slot(ActionSurfaceSlot::ListRowMoreMenu)?->details ?? ''))->toContain('one'); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); Filament::setTenant(null, true); - Livewire::actingAs($user) + $component = Livewire::actingAs($user) ->test(ListTenants::class) - ->assertTableActionVisible('view', $tenant) ->assertTableActionVisible('archive', $tenant) ->assertTableActionHidden('related_onboarding', $tenant) ->assertTableActionVisible('related_onboarding_overflow', $tenant) ->assertTableActionHidden('restore', $tenant); + + $table = $component->instance()->getTable(); + $rowActions = $table->getActions(); + $primaryRowActionNames = collect($rowActions) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions() ?? []) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($primaryRowActionNames)->not->toContain('view') + ->and($table->getRecordUrl($tenant))->toBe(TenantResource::getUrl('view', ['record' => $tenant])) + ->and($moreActionNames)->toContain('archive') + ->and($moreActionNames)->toContain('related_onboarding_overflow'); }); diff --git a/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php b/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php index f4813912..13c31108 100644 --- a/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php +++ b/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php @@ -36,7 +36,6 @@ Livewire::actingAs($user) ->test(ListTenants::class) - ->assertTableActionVisible('view', $tenant) ->assertTableActionVisible('related_onboarding', $tenant) ->assertTableActionExists('related_onboarding', fn (Action $action): bool => $action->getLabel() === 'Resume onboarding', $tenant) ->assertTableActionHidden('archive', $tenant) diff --git a/tests/Feature/TenantReview/TenantReviewUiContractTest.php b/tests/Feature/TenantReview/TenantReviewUiContractTest.php index 0e26639b..817dd821 100644 --- a/tests/Feature/TenantReview/TenantReviewUiContractTest.php +++ b/tests/Feature/TenantReview/TenantReviewUiContractTest.php @@ -43,6 +43,29 @@ ->assertSee('No review records match this view'); }); +it('keeps tenant review list inspection on row click and reserves the row action for executive export', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $review = composeTenantReviewForTest($tenant, $user); + + $this->actingAs($user); + setTenantPanelContext($tenant); + + $livewire = Livewire::actingAs($user) + ->test(ListTenantReviews::class) + ->assertCanSeeTableRecords([$review]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['export_executive_pack']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($review))->toBe(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)); +}); + it('requires confirmation for destructive tenant-review actions and preserves disabled management visibility for readonly users', function (): void { $tenant = Tenant::factory()->create(); [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); diff --git a/tests/Unit/Tenants/TenantActionPolicySurfaceTest.php b/tests/Unit/Tenants/TenantActionPolicySurfaceTest.php index f8acb17e..35e1f879 100644 --- a/tests/Unit/Tenants/TenantActionPolicySurfaceTest.php +++ b/tests/Unit/Tenants/TenantActionPolicySurfaceTest.php @@ -86,7 +86,7 @@ ->and($catalog[2]->label)->toBe('View completed onboarding'); }); -it('keeps tenant index catalogs within the two-primary-action overflow contract', function (): void { +it('keeps tenant index catalogs within the clickable-row overflow contract', function (): void { $tenant = Tenant::factory()->active()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false); @@ -110,15 +110,22 @@ ->values() ->all(); + $inspectKeys = collect($catalog) + ->filter(static fn ($action): bool => $action->group === 'inspect') + ->map(static fn ($action): string => $action->key) + ->values() + ->all(); + $overflowKeys = collect($catalog) ->filter(static fn ($action): bool => $action->group === 'overflow') ->map(static fn ($action): string => $action->key) ->values() ->all(); - expect($primaryKeys)->toBe(['view', 'archive']) + expect($inspectKeys)->toBe(['view']) + ->and($primaryKeys)->toBe(['archive']) ->and($overflowKeys)->toBe(['related_onboarding']) - ->and(TenantResource::actionSurfaceDeclaration()->listRowPrimaryActionLimit())->toBe(2); + ->and(TenantResource::actionSurfaceDeclaration()->listRowPrimaryActionLimit())->toBe(1); }); it('invalidates cached tenant index catalogs when the related onboarding draft lifecycle changes', function (): void {