TenantAtlas/app/Support/Verification/VerificationAssistViewModelBuilder.php
ahmido b182f55562 feat: add verify access required permissions assist (#168)
## Summary
- add an in-place Required Permissions assist to the onboarding Verify Access step via a Filament slideover
- route permission-related verification remediation links into the assist first and keep deep-dive links opening in a new tab
- add view-model and link-behavior helpers plus focused feature, browser, RBAC, and unit coverage for the new assist

## Scope
- onboarding wizard Verify Access UX
- Required Permissions assist rendering and link behavior
- Spec 139 artifacts, contracts, and checklist updates

## Notes
- branch: `139-verify-access-permissions-assist`
- commit: `b4193f1`
- worktree was clean at PR creation time

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #168
2026-03-14 02:00:28 +00:00

472 lines
19 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Verification;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Providers\ProviderReasonCodes;
final class VerificationAssistViewModelBuilder
{
public function __construct(
private readonly TenantRequiredPermissionsViewModelBuilder $requiredPermissionsViewModelBuilder,
private readonly ProviderNextStepsRegistry $providerNextStepsRegistry,
) {}
/**
* @param array<string, mixed>|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<string, mixed>|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<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}>,
* delegated: array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|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<string, mixed>
*/
private function requiredPermissionsViewModel(Tenant $tenant): array
{
return $this->requiredPermissionsViewModelBuilder->build($tenant, [
'status' => 'all',
'type' => 'all',
'features' => [],
'search' => '',
]);
}
/**
* @param array<string, mixed>|null $verificationReport
* @param array<string, mixed> $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<string, mixed>|null $verificationReport
* @param array<string, mixed> $overview
* @param array{missing_application:int,missing_delegated:int,present:int,error:int} $counts
* @param array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|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<string, mixed>|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<string, mixed>|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<string, mixed>|null $verificationReport
* @return array<int, array<string, mixed>>
*/
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<string, mixed>|null $verificationReport
*/
private function reportHasRelevantPermissionIssue(?array $verificationReport): bool
{
return $this->relevantChecks($verificationReport) !== [];
}
/**
* @param array<string, mixed> $counts
* @param array<int, array{label:string,url:string}> $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<int, array{label:string,url:string}> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<int, array<string, mixed>> $permissions
* @return array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|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<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}> $rows
* @return array{
* application: array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}>,
* delegated: array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|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);
}
}