TenantAtlas/apps/platform/app/Filament/Pages/TenantRequiredPermissions.php
2026-04-15 01:27:04 +02:00

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,
};
}
}