feat: add tenant governance aggregate contract and action surface follow-ups #199

Merged
ahmido merged 6 commits from 168-tenant-governance-aggregate-contract into dev 2026-03-29 21:14:18 +00:00
38 changed files with 1811 additions and 260 deletions
Showing only changes of commit d39d6f0982 - Show all commits

View File

@ -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.')

View File

@ -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')

View File

@ -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');

View File

@ -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);

View File

@ -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<string, mixed> $arguments
* @param array<string, mixed> $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.');

View File

@ -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<int, int>
*/
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))

View File

@ -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([

View File

@ -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')

View File

@ -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.')

View File

@ -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')

View File

@ -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')

View File

@ -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.');

View File

@ -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.')

View File

@ -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.');
}

View File

@ -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')

View File

@ -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

View File

@ -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')

View File

@ -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'))

View File

@ -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,

View File

@ -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();

View File

@ -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();

View File

@ -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()

View File

@ -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();

View File

@ -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)

View File

@ -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();

View File

@ -153,7 +153,7 @@ private function viewAction(): TenantActionDescriptor
family: TenantActionFamily::Neutral,
label: 'View',
icon: 'heroicon-o-eye',
group: 'primary',
group: 'inspect',
);
}

View File

@ -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()));
}

View File

@ -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' => [

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantResource;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
pest()->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');
});

View File

@ -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');

View File

@ -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);
});

View File

@ -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]));
});

File diff suppressed because it is too large Load Diff

View File

@ -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 {

View File

@ -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');
});

View File

@ -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)

View File

@ -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');

View File

@ -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 {