|null $verificationReport * @return array{is_visible:bool,reason:'permission_blocked'|'permission_attention'|'hidden_ready'|'hidden_irrelevant'} */ public function visibility(Tenant $tenant, ?array $verificationReport): array { return $this->deriveVisibility( $verificationReport, $this->requiredPermissionsViewModel($tenant), ); } /** * @param array|null $verificationReport * @return array{ * tenant: array{id:int,external_id:string,name:string}, * verification: array{overall:?string,status:?string,is_stale:bool,stale_reason:?string}, * overview: array{ * overall:string, * counts: array{missing_application:int,missing_delegated:int,present:int,error:int}, * freshness: array{last_refreshed_at:?string,is_stale:bool} * }, * missing_permissions: array{ * application: array,status:'granted'|'missing'|'error',details:array|null}>, * delegated: array,status:'granted'|'missing'|'error',details:array|null}> * }, * copy: array{application:string,delegated:string}, * actions: array{ * full_page: array{label:string,url:string,opens_in_new_tab:bool,available:bool,is_secondary:bool}, * copy_application: array{label:string,payload:string,available:bool}, * copy_delegated: array{label:string,payload:string,available:bool}, * grant_admin_consent: array{label:string,url:?string,opens_in_new_tab:bool,available:bool}, * manage_provider_connection: array{label:string,url:?string,opens_in_new_tab:bool,available:bool}, * rerun_verification: array{label:string,handled_by_existing_wizard:true} * }, * fallback: array{has_incomplete_detail:bool,message:?string} * } */ public function build( Tenant $tenant, ?array $verificationReport, ?ProviderConnection $providerConnection = null, ?string $verificationStatus = null, bool $isVerificationStale = false, ?string $staleReason = null, bool $canAccessProviderConnectionDiagnostics = false, ): array { $requiredPermissionsViewModel = $this->requiredPermissionsViewModel($tenant); $tenantViewModel = is_array($requiredPermissionsViewModel['tenant'] ?? null) ? $requiredPermissionsViewModel['tenant'] : []; $overview = is_array($requiredPermissionsViewModel['overview'] ?? null) ? $requiredPermissionsViewModel['overview'] : []; $counts = $this->normalizeCounts(is_array($overview['counts'] ?? null) ? $overview['counts'] : []); $freshness = $this->normalizeFreshness(is_array($overview['freshness'] ?? null) ? $overview['freshness'] : []); $rows = $this->attentionRows(is_array($requiredPermissionsViewModel['permissions'] ?? null) ? $requiredPermissionsViewModel['permissions'] : []); $partitionedRows = $this->partitionRows($rows); $copy = is_array($requiredPermissionsViewModel['copy'] ?? null) ? $requiredPermissionsViewModel['copy'] : []; $reasonCode = $this->primaryRelevantReasonCode($verificationReport); $registrySteps = $reasonCode === null ? [] : $this->providerNextStepsRegistry->forReason($tenant, $reasonCode, $providerConnection); $fallbackMessage = $this->fallbackMessage( verificationReport: $verificationReport, overview: $overview, counts: $counts, rows: $rows, ); return [ 'tenant' => [ 'id' => (int) ($tenantViewModel['id'] ?? $tenant->getKey()), 'external_id' => (string) ($tenantViewModel['external_id'] ?? $tenant->external_id), 'name' => (string) (($tenantViewModel['name'] ?? $tenant->name) ?: $tenant->external_id), ], 'verification' => [ 'overall' => $this->verificationOverall($verificationReport), 'status' => $this->normalizeOptionalString($verificationStatus), 'is_stale' => $isVerificationStale, 'stale_reason' => $this->normalizeOptionalString($staleReason), ], 'overview' => [ 'overall' => $this->normalizeOverviewOverall($overview['overall'] ?? null), 'counts' => $counts, 'freshness' => $freshness, ], 'missing_permissions' => $partitionedRows, 'copy' => [ 'application' => (string) ($copy['application'] ?? ''), 'delegated' => (string) ($copy['delegated'] ?? ''), ], 'actions' => [ 'full_page' => [ 'label' => 'Open full page', 'url' => RequiredPermissionsLinks::requiredPermissions($tenant), 'opens_in_new_tab' => true, 'available' => true, 'is_secondary' => true, ], 'copy_application' => [ 'label' => 'Copy missing application permissions', 'payload' => (string) ($copy['application'] ?? ''), 'available' => trim((string) ($copy['application'] ?? '')) !== '', ], 'copy_delegated' => [ 'label' => 'Copy missing delegated permissions', 'payload' => (string) ($copy['delegated'] ?? ''), 'available' => trim((string) ($copy['delegated'] ?? '')) !== '', ], 'grant_admin_consent' => $this->grantAdminConsentAction($tenant, $counts, $registrySteps), 'manage_provider_connection' => $this->manageProviderConnectionAction($registrySteps, $canAccessProviderConnectionDiagnostics), 'rerun_verification' => [ 'label' => 'Use the existing Start verification action in this step after reviewing changes.', 'handled_by_existing_wizard' => true, ], ], 'fallback' => [ 'has_incomplete_detail' => $fallbackMessage !== null, 'message' => $fallbackMessage, ], ]; } /** * @return array */ private function requiredPermissionsViewModel(Tenant $tenant): array { return $this->requiredPermissionsViewModelBuilder->build($tenant, [ 'status' => 'all', 'type' => 'all', 'features' => [], 'search' => '', ]); } /** * @param array|null $verificationReport * @param array $requiredPermissionsViewModel * @return array{is_visible:bool,reason:'permission_blocked'|'permission_attention'|'hidden_ready'|'hidden_irrelevant'} */ private function deriveVisibility(?array $verificationReport, array $requiredPermissionsViewModel): array { $overview = is_array($requiredPermissionsViewModel['overview'] ?? null) ? $requiredPermissionsViewModel['overview'] : []; $overviewOverall = $this->normalizeOverviewOverall($overview['overall'] ?? null); $hasRelevantPermissionIssue = $this->reportHasRelevantPermissionIssue($verificationReport); if ($overviewOverall === VerificationReportOverall::Blocked->value) { return [ 'is_visible' => true, 'reason' => 'permission_blocked', ]; } if ($overviewOverall === VerificationReportOverall::NeedsAttention->value || $hasRelevantPermissionIssue) { return [ 'is_visible' => true, 'reason' => 'permission_attention', ]; } if ($overviewOverall === VerificationReportOverall::Ready->value || $this->verificationOverall($verificationReport) === VerificationReportOverall::Ready->value) { return [ 'is_visible' => false, 'reason' => 'hidden_ready', ]; } return [ 'is_visible' => false, 'reason' => 'hidden_irrelevant', ]; } /** * @param array|null $verificationReport * @param array $overview * @param array{missing_application:int,missing_delegated:int,present:int,error:int} $counts * @param array,status:'granted'|'missing'|'error',details:array|null}> $rows */ private function fallbackMessage(?array $verificationReport, array $overview, array $counts, array $rows): ?string { if ($counts['error'] > 0) { return 'Some stored permission details are incomplete. Open the full page or rerun verification for a complete diagnostic view.'; } $freshness = is_array($overview['freshness'] ?? null) ? $overview['freshness'] : []; if ((bool) ($freshness['is_stale'] ?? false) && $rows === []) { return 'Stored permission data is stale. Open the full page or rerun verification to refresh diagnostics.'; } if ($rows === [] && $this->reportHasRelevantPermissionIssue($verificationReport)) { return 'Stored verification found a permissions issue, but the compact detail is incomplete. Open the full page or rerun verification.'; } return null; } /** * @param array|null $verificationReport */ private function verificationOverall(?array $verificationReport): ?string { $summary = is_array($verificationReport['summary'] ?? null) ? $verificationReport['summary'] : []; $overall = $summary['overall'] ?? null; return is_string($overall) && in_array($overall, VerificationReportOverall::values(), true) ? $overall : null; } /** * @param array|null $verificationReport */ private function primaryRelevantReasonCode(?array $verificationReport): ?string { foreach ($this->relevantChecks($verificationReport) as $check) { $reasonCode = $check['reason_code'] ?? null; if (is_string($reasonCode) && $reasonCode !== '') { return $reasonCode; } } return null; } /** * @param array|null $verificationReport * @return array> */ private function relevantChecks(?array $verificationReport): array { $checks = is_array($verificationReport['checks'] ?? null) ? $verificationReport['checks'] : []; return array_values(array_filter($checks, function (mixed $check): bool { if (! is_array($check)) { return false; } $status = $check['status'] ?? null; $key = $check['key'] ?? null; $reasonCode = $check['reason_code'] ?? null; if (! is_string($status) || in_array($status, ['pass', 'skip', 'running'], true)) { return false; } if (is_string($key) && str_starts_with($key, 'permissions.')) { return true; } return in_array($reasonCode, [ ProviderReasonCodes::ProviderConsentMissing, ProviderReasonCodes::ProviderConsentFailed, ProviderReasonCodes::ProviderConsentRevoked, ProviderReasonCodes::ProviderPermissionMissing, ProviderReasonCodes::ProviderPermissionDenied, ProviderReasonCodes::ProviderPermissionRefreshFailed, ProviderReasonCodes::IntuneRbacPermissionMissing, ], true); })); } /** * @param array|null $verificationReport */ private function reportHasRelevantPermissionIssue(?array $verificationReport): bool { return $this->relevantChecks($verificationReport) !== []; } /** * @param array $counts * @param array $registrySteps * @return array{label:string,url:?string,opens_in_new_tab:bool,available:bool} */ private function grantAdminConsentAction(Tenant $tenant, array $counts, array $registrySteps): array { $available = ($counts['missing_application'] + $counts['missing_delegated']) > 0; if (! $available) { return [ 'label' => 'Admin consent guide', 'url' => null, 'opens_in_new_tab' => true, 'available' => false, ]; } foreach ($registrySteps as $step) { $label = is_string($step['label'] ?? null) ? (string) $step['label'] : ''; $url = is_string($step['url'] ?? null) ? (string) $step['url'] : ''; if ($label !== '' && str_contains(strtolower($label), 'consent') && $url !== '') { return [ 'label' => $label, 'url' => $url, 'opens_in_new_tab' => true, 'available' => true, ]; } } return [ 'label' => 'Admin consent guide', 'url' => RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant), 'opens_in_new_tab' => true, 'available' => true, ]; } /** * @param array $registrySteps * @return array{label:string,url:?string,opens_in_new_tab:bool,available:bool} */ private function manageProviderConnectionAction(array $registrySteps, bool $canAccessProviderConnectionDiagnostics): array { if (! $canAccessProviderConnectionDiagnostics) { return [ 'label' => 'Manage provider connection', 'url' => null, 'opens_in_new_tab' => true, 'available' => false, ]; } foreach ($registrySteps as $step) { $label = is_string($step['label'] ?? null) ? (string) $step['label'] : ''; $url = is_string($step['url'] ?? null) ? (string) $step['url'] : ''; $path = parse_url($url, PHP_URL_PATH); if (is_string($path) && str_contains($path, '/provider-connections')) { return [ 'label' => $label !== '' ? $label : 'Manage provider connection', 'url' => $url, 'opens_in_new_tab' => true, 'available' => true, ]; } } return [ 'label' => 'Manage provider connection', 'url' => null, 'opens_in_new_tab' => true, 'available' => false, ]; } /** * @param array $overview */ private function normalizeOverviewOverall(mixed $overview): string { return is_string($overview) && in_array($overview, [ VerificationReportOverall::Ready->value, VerificationReportOverall::NeedsAttention->value, VerificationReportOverall::Blocked->value, ], true) ? $overview : VerificationReportOverall::NeedsAttention->value; } /** * @param array $counts * @return array{missing_application:int,missing_delegated:int,present:int,error:int} */ private function normalizeCounts(array $counts): array { return [ 'missing_application' => $this->normalizeNonNegativeInteger($counts['missing_application'] ?? 0), 'missing_delegated' => $this->normalizeNonNegativeInteger($counts['missing_delegated'] ?? 0), 'present' => $this->normalizeNonNegativeInteger($counts['present'] ?? 0), 'error' => $this->normalizeNonNegativeInteger($counts['error'] ?? 0), ]; } /** * @param array $freshness * @return array{last_refreshed_at:?string,is_stale:bool} */ private function normalizeFreshness(array $freshness): array { return [ 'last_refreshed_at' => $this->normalizeOptionalString($freshness['last_refreshed_at'] ?? null), 'is_stale' => (bool) ($freshness['is_stale'] ?? true), ]; } /** * @param array> $permissions * @return array,status:'granted'|'missing'|'error',details:array|null}> */ private function attentionRows(array $permissions): array { return array_values(array_filter($permissions, function (mixed $row): bool { return is_array($row) && (($row['status'] ?? null) === 'missing' || ($row['status'] ?? null) === 'error'); })); } /** * @param array,status:'granted'|'missing'|'error',details:array|null}> $rows * @return array{ * application: array,status:'granted'|'missing'|'error',details:array|null}>, * delegated: array,status:'granted'|'missing'|'error',details:array|null}> * } */ private function partitionRows(array $rows): array { return [ 'application' => array_values(array_filter($rows, static fn (array $row): bool => ($row['type'] ?? null) === 'application')), 'delegated' => array_values(array_filter($rows, static fn (array $row): bool => ($row['type'] ?? null) === 'delegated')), ]; } private function normalizeNonNegativeInteger(mixed $value): int { if (is_int($value)) { return max(0, $value); } if (is_numeric($value)) { return max(0, (int) $value); } return 0; } private function normalizeOptionalString(mixed $value): ?string { if (! is_string($value) || trim($value) === '') { return null; } return trim($value); } }