## Summary - replace the inventory dependency GET/apply flow with an embedded native Filament `TableComponent` - convert tenant required permissions and evidence overview to native page-owned Filament tables with mount-only query seeding and preserved scope authority - extend focused Pest, Livewire, RBAC, and guard coverage, and update the Spec 196 artifacts and release close-out notes ## Verification - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/InventoryItemDependenciesTest.php tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php tests/Feature/Filament/TenantRequiredPermissionsPageTest.php tests/Feature/Evidence/EvidenceOverviewPageTest.php tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php tests/Unit/TenantRequiredPermissionsFilteringTest.php tests/Unit/TenantRequiredPermissionsOverallStatusTest.php tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php` (`45` tests, `177` assertions) - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - integrated-browser smoke on localhost for inventory detail dependencies, tenant required permissions, and evidence overview Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #236
543 lines
19 KiB
PHP
543 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Pages;
|
|
|
|
use App\Filament\Resources\ProviderConnectionResource;
|
|
use App\Filament\Resources\TenantResource;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\WorkspaceMembership;
|
|
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\Filament\TablePaginationProfiles;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Filament\Actions\Action;
|
|
use Filament\Pages\Page;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Concerns\InteractsWithTable;
|
|
use Filament\Tables\Contracts\HasTable;
|
|
use Filament\Tables\Filters\SelectFilter;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Attributes\Locked;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class TenantRequiredPermissions extends Page implements HasTable
|
|
{
|
|
use InteractsWithTable;
|
|
|
|
protected static bool $isDiscovered = false;
|
|
|
|
protected static bool $shouldRegisterNavigation = false;
|
|
|
|
protected static ?string $slug = 'tenants/{tenant}/required-permissions';
|
|
|
|
protected static ?string $title = 'Required permissions';
|
|
|
|
protected string $view = 'filament.pages.tenant-required-permissions';
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
|
->exempt(ActionSurfaceSlot::ListHeader, 'Required permissions keeps guidance, copy flows, and filter reset actions inside body sections instead of page header actions.')
|
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'Required permissions rows are reviewed inline inside the diagnostic matrix and do not open a separate inspect destination.')
|
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Permission rows are read-only and do not expose row-level secondary actions.')
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Required permissions does not expose bulk actions.')
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The inline permissions matrix provides purposeful no-data, all-clear, and no-matches states with verification or reset guidance.');
|
|
}
|
|
|
|
#[Locked]
|
|
public ?int $scopedTenantId = null;
|
|
|
|
/**
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
private ?array $cachedViewModel = null;
|
|
|
|
private ?string $cachedViewModelStateKey = null;
|
|
|
|
public static function canAccess(): bool
|
|
{
|
|
return static::hasScopedTenantAccess(static::resolveScopedTenant());
|
|
}
|
|
|
|
public function currentTenant(): ?Tenant
|
|
{
|
|
return $this->trustedScopedTenant();
|
|
}
|
|
|
|
public function mount(Tenant|string|null $tenant = null): void
|
|
{
|
|
$tenant = static::resolveScopedTenant($tenant);
|
|
|
|
if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
|
|
abort(404);
|
|
}
|
|
|
|
$this->scopedTenantId = (int) $tenant->getKey();
|
|
$this->heading = $tenant->getFilamentName();
|
|
$this->subheading = 'Required permissions';
|
|
|
|
$this->seedTableStateFromQuery();
|
|
$this->mountInteractsWithTable();
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->defaultSort('sort_priority')
|
|
->defaultPaginationPageOption(25)
|
|
->paginated(TablePaginationProfiles::customPage())
|
|
->persistFiltersInSession()
|
|
->persistSearchInSession()
|
|
->persistSortInSession()
|
|
->searchable()
|
|
->searchPlaceholder('Search permission key or description…')
|
|
->records(function (
|
|
?string $sortColumn,
|
|
?string $sortDirection,
|
|
?string $search,
|
|
array $filters,
|
|
int $page,
|
|
int $recordsPerPage
|
|
): LengthAwarePaginator {
|
|
$state = $this->filterState(filters: $filters, search: $search);
|
|
$rows = $this->permissionRowsForState($state);
|
|
$rows = $this->sortPermissionRows($rows, $sortColumn, $sortDirection);
|
|
|
|
return $this->paginatePermissionRows($rows, $page, $recordsPerPage);
|
|
})
|
|
->filters([
|
|
SelectFilter::make('status')
|
|
->label('Status')
|
|
->default('missing')
|
|
->options([
|
|
'missing' => 'Missing',
|
|
'present' => 'Present',
|
|
'all' => 'All',
|
|
]),
|
|
SelectFilter::make('type')
|
|
->label('Type')
|
|
->default('all')
|
|
->options([
|
|
'all' => 'All',
|
|
'application' => 'Application',
|
|
'delegated' => 'Delegated',
|
|
]),
|
|
SelectFilter::make('features')
|
|
->label('Features')
|
|
->multiple()
|
|
->options(fn (): array => $this->featureFilterOptions())
|
|
->searchable(),
|
|
])
|
|
->columns([
|
|
TextColumn::make('key')
|
|
->label('Permission')
|
|
->description(fn (array $record): ?string => is_string($record['description'] ?? null) ? $record['description'] : null)
|
|
->wrap()
|
|
->sortable(),
|
|
TextColumn::make('type_label')
|
|
->label('Type')
|
|
->badge()
|
|
->color('gray')
|
|
->sortable(),
|
|
TextColumn::make('status')
|
|
->label('Status')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantPermissionStatus))
|
|
->color(BadgeRenderer::color(BadgeDomain::TenantPermissionStatus))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantPermissionStatus))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantPermissionStatus))
|
|
->sortable(),
|
|
TextColumn::make('features_label')
|
|
->label('Features')
|
|
->wrap()
|
|
->toggleable(),
|
|
])
|
|
->actions([])
|
|
->bulkActions([])
|
|
->emptyStateHeading(fn (): string => $this->permissionsEmptyStateHeading())
|
|
->emptyStateDescription(fn (): string => $this->permissionsEmptyStateDescription())
|
|
->emptyStateActions([
|
|
Action::make('clear_filters')
|
|
->label('Clear filters')
|
|
->icon('heroicon-o-x-mark')
|
|
->color('gray')
|
|
->visible(fn (): bool => $this->hasActivePermissionFilters())
|
|
->action(fn (): mixed => $this->clearPermissionFilters()),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function viewModel(): array
|
|
{
|
|
return $this->viewModelForState($this->filterState());
|
|
}
|
|
|
|
public function clearPermissionFilters(): void
|
|
{
|
|
$this->tableFilters = [
|
|
'status' => ['value' => 'missing'],
|
|
'type' => ['value' => 'all'],
|
|
'features' => ['values' => []],
|
|
];
|
|
$this->tableDeferredFilters = $this->tableFilters;
|
|
$this->tableSearch = '';
|
|
$this->cachedViewModel = null;
|
|
$this->cachedViewModelStateKey = null;
|
|
|
|
session()->put($this->getTableFiltersSessionKey(), $this->tableFilters);
|
|
session()->put($this->getTableSearchSessionKey(), $this->tableSearch);
|
|
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function reRunVerificationUrl(): string
|
|
{
|
|
$tenant = $this->trustedScopedTenant();
|
|
|
|
if ($tenant instanceof Tenant) {
|
|
return TenantResource::getUrl('view', ['record' => $tenant]);
|
|
}
|
|
|
|
return route('admin.onboarding');
|
|
}
|
|
|
|
public function manageProviderConnectionUrl(): ?string
|
|
{
|
|
$tenant = $this->trustedScopedTenant();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return null;
|
|
}
|
|
|
|
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
|
}
|
|
|
|
protected static function resolveScopedTenant(Tenant|string|null $tenant = null): ?Tenant
|
|
{
|
|
if ($tenant instanceof Tenant) {
|
|
return $tenant;
|
|
}
|
|
|
|
if (is_string($tenant) && $tenant !== '') {
|
|
return Tenant::query()
|
|
->where('external_id', $tenant)
|
|
->first();
|
|
}
|
|
|
|
$routeTenant = request()->route('tenant');
|
|
|
|
if ($routeTenant instanceof Tenant) {
|
|
return $routeTenant;
|
|
}
|
|
|
|
if (is_string($routeTenant) && $routeTenant !== '') {
|
|
return Tenant::query()
|
|
->where('external_id', $routeTenant)
|
|
->first();
|
|
}
|
|
|
|
$queryTenant = request()->query('tenant');
|
|
|
|
if (is_string($queryTenant) && $queryTenant !== '') {
|
|
return Tenant::query()
|
|
->where('external_id', $queryTenant)
|
|
->first();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static function hasScopedTenantAccess(?Tenant $tenant): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
|
|
return false;
|
|
}
|
|
|
|
$isWorkspaceMember = WorkspaceMembership::query()
|
|
->where('workspace_id', (int) $workspaceId)
|
|
->where('user_id', (int) $user->getKey())
|
|
->exists();
|
|
|
|
if (! $isWorkspaceMember) {
|
|
return false;
|
|
}
|
|
|
|
return $user->canAccessTenant($tenant);
|
|
}
|
|
|
|
private function trustedScopedTenant(): ?Tenant
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return null;
|
|
}
|
|
|
|
$workspaceContext = app(WorkspaceContext::class);
|
|
|
|
try {
|
|
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
|
|
} catch (NotFoundHttpException) {
|
|
return null;
|
|
}
|
|
|
|
$routeTenant = static::resolveScopedTenant();
|
|
|
|
if ($routeTenant instanceof Tenant) {
|
|
try {
|
|
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($routeTenant, $user, request());
|
|
} catch (NotFoundHttpException) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if ($this->scopedTenantId === null) {
|
|
return null;
|
|
}
|
|
|
|
$tenant = Tenant::query()->withTrashed()->whereKey($this->scopedTenantId)->first();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($tenant, $user, request());
|
|
} catch (NotFoundHttpException) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array<int, string>,search:string}
|
|
*/
|
|
private function filterState(array $filters = [], ?string $search = null): array
|
|
{
|
|
return TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
|
'status' => $filters['status']['value'] ?? data_get($this->tableFilters, 'status.value'),
|
|
'type' => $filters['type']['value'] ?? data_get($this->tableFilters, 'type.value'),
|
|
'features' => $filters['features']['values'] ?? data_get($this->tableFilters, 'features.values', []),
|
|
'search' => $search ?? $this->tableSearch,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array<int, string>,search:string} $state
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function viewModelForState(array $state): array
|
|
{
|
|
$tenant = $this->trustedScopedTenant();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return [];
|
|
}
|
|
|
|
$stateKey = json_encode([$tenant->getKey(), $state]);
|
|
|
|
if ($this->cachedViewModelStateKey === $stateKey && is_array($this->cachedViewModel)) {
|
|
return $this->cachedViewModel;
|
|
}
|
|
|
|
$builder = app(TenantRequiredPermissionsViewModelBuilder::class);
|
|
|
|
$this->cachedViewModelStateKey = $stateKey ?: null;
|
|
$this->cachedViewModel = $builder->build($tenant, $state);
|
|
|
|
return $this->cachedViewModel;
|
|
}
|
|
|
|
/**
|
|
* @return Collection<string, array<string, mixed>>
|
|
*/
|
|
private function permissionRowsForState(array $state): Collection
|
|
{
|
|
return collect($this->viewModelForState($state)['permissions'] ?? [])
|
|
->filter(fn (mixed $row): bool => is_array($row) && is_string($row['key'] ?? null))
|
|
->mapWithKeys(function (array $row): array {
|
|
$key = (string) $row['key'];
|
|
|
|
return [
|
|
$key => [
|
|
'key' => $key,
|
|
'description' => is_string($row['description'] ?? null) ? $row['description'] : null,
|
|
'type' => (string) ($row['type'] ?? 'application'),
|
|
'type_label' => ($row['type'] ?? 'application') === 'delegated' ? 'Delegated' : 'Application',
|
|
'status' => (string) ($row['status'] ?? 'missing'),
|
|
'features_label' => implode(', ', array_filter((array) ($row['features'] ?? []), 'is_string')),
|
|
'sort_priority' => $this->statusSortWeight((string) ($row['status'] ?? 'missing')),
|
|
],
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param Collection<string, array<string, mixed>> $rows
|
|
* @return Collection<string, array<string, mixed>>
|
|
*/
|
|
private function sortPermissionRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
|
{
|
|
$sortColumn = in_array($sortColumn, ['sort_priority', 'key', 'type_label', 'status', 'features_label'], true)
|
|
? $sortColumn
|
|
: 'sort_priority';
|
|
$descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc';
|
|
|
|
$records = $rows->all();
|
|
|
|
uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int {
|
|
$comparison = match ($sortColumn) {
|
|
'sort_priority' => ((int) ($left['sort_priority'] ?? 0)) <=> ((int) ($right['sort_priority'] ?? 0)),
|
|
default => strnatcasecmp(
|
|
(string) ($left[$sortColumn] ?? ''),
|
|
(string) ($right[$sortColumn] ?? ''),
|
|
),
|
|
};
|
|
|
|
if ($comparison === 0) {
|
|
$comparison = strnatcasecmp(
|
|
(string) ($left['key'] ?? ''),
|
|
(string) ($right['key'] ?? ''),
|
|
);
|
|
}
|
|
|
|
return $descending ? ($comparison * -1) : $comparison;
|
|
});
|
|
|
|
return collect($records);
|
|
}
|
|
|
|
/**
|
|
* @param Collection<string, array<string, mixed>> $rows
|
|
*/
|
|
private function paginatePermissionRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
|
|
{
|
|
return new LengthAwarePaginator(
|
|
items: $rows->forPage($page, $recordsPerPage),
|
|
total: $rows->count(),
|
|
perPage: $recordsPerPage,
|
|
currentPage: $page,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function featureFilterOptions(): array
|
|
{
|
|
return collect(data_get($this->viewModelForState([
|
|
'status' => 'all',
|
|
'type' => 'all',
|
|
'features' => [],
|
|
'search' => '',
|
|
]), 'overview.feature_impacts', []))
|
|
->filter(fn (mixed $impact): bool => is_array($impact) && is_string($impact['feature'] ?? null))
|
|
->mapWithKeys(fn (array $impact): array => [
|
|
(string) $impact['feature'] => (string) $impact['feature'],
|
|
])
|
|
->all();
|
|
}
|
|
|
|
private function permissionsEmptyStateHeading(): string
|
|
{
|
|
$viewModel = $this->viewModel();
|
|
$counts = is_array(data_get($viewModel, 'overview.counts')) ? data_get($viewModel, 'overview.counts') : [];
|
|
$state = $this->filterState();
|
|
$allPermissions = data_get($this->viewModelForState([
|
|
'status' => 'all',
|
|
'type' => 'all',
|
|
'features' => [],
|
|
'search' => '',
|
|
]), 'permissions', []);
|
|
|
|
$missingTotal = (int) ($counts['missing_application'] ?? 0)
|
|
+ (int) ($counts['missing_delegated'] ?? 0)
|
|
+ (int) ($counts['error'] ?? 0);
|
|
$requiredTotal = $missingTotal + (int) ($counts['present'] ?? 0);
|
|
|
|
if (! is_array($allPermissions) || $allPermissions === []) {
|
|
return 'No permissions configured';
|
|
}
|
|
|
|
if ($state['status'] === 'missing' && $missingTotal === 0 && $state['type'] === 'all' && $state['features'] === [] && trim($state['search']) === '') {
|
|
return 'All required permissions are present';
|
|
}
|
|
|
|
return 'No matches';
|
|
}
|
|
|
|
private function permissionsEmptyStateDescription(): string
|
|
{
|
|
return match ($this->permissionsEmptyStateHeading()) {
|
|
'No permissions configured' => 'No required permissions are currently configured in config/intune_permissions.php.',
|
|
'All required permissions are present' => 'Switch Status to All if you want to review the full matrix.',
|
|
default => 'No permissions match the current filters.',
|
|
};
|
|
}
|
|
|
|
private function hasActivePermissionFilters(): bool
|
|
{
|
|
$state = $this->filterState();
|
|
|
|
return $state['status'] !== 'missing'
|
|
|| $state['type'] !== 'all'
|
|
|| $state['features'] !== []
|
|
|| trim($state['search']) !== '';
|
|
}
|
|
|
|
private function seedTableStateFromQuery(): void
|
|
{
|
|
$query = request()->query();
|
|
|
|
if (! array_key_exists('status', $query) && ! array_key_exists('type', $query) && ! array_key_exists('features', $query) && ! array_key_exists('search', $query)) {
|
|
return;
|
|
}
|
|
|
|
$queryFeatures = request()->query('features', []);
|
|
|
|
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
|
'status' => request()->query('status', 'missing'),
|
|
'type' => request()->query('type', 'all'),
|
|
'features' => is_array($queryFeatures) ? $queryFeatures : [],
|
|
'search' => request()->query('search', ''),
|
|
]);
|
|
|
|
$this->tableFilters = [
|
|
'status' => ['value' => $state['status']],
|
|
'type' => ['value' => $state['type']],
|
|
'features' => ['values' => $state['features']],
|
|
];
|
|
$this->tableDeferredFilters = $this->tableFilters;
|
|
$this->tableSearch = $state['search'];
|
|
}
|
|
|
|
private function statusSortWeight(string $status): int
|
|
{
|
|
return match ($status) {
|
|
'missing' => 0,
|
|
'error' => 1,
|
|
default => 2,
|
|
};
|
|
}
|
|
}
|