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|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 */ 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 $filters * @return array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array,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,search:string} $state * @return array */ 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> */ 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> $rows * @return Collection> */ 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> $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 */ 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, }; } }