Compare commits

...

1 Commits

Author SHA1 Message Date
Ahmed Darrazi
b4193f1ff9 feat: add onboarding permissions assist 2026-03-14 02:59:06 +01:00
21 changed files with 3474 additions and 18 deletions

View File

@ -68,6 +68,8 @@ ## Active Technologies
- PostgreSQL application database and session-backed Filament table state (136-admin-canonical-tenant)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12 (137-platform-provider-identity)
- PostgreSQL via Laravel migrations and encrypted model casts (137-platform-provider-identity)
- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Laravel Blade, existing onboarding/verification support classes (139-verify-access-permissions-assist)
- PostgreSQL; existing JSON-backed onboarding draft state and `OperationRun.context.verification_report` (139-verify-access-permissions-assist)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -87,8 +89,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 139-verify-access-permissions-assist: Added PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Laravel Blade, existing onboarding/verification support classes
- 137-platform-provider-identity: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12
- 136-admin-canonical-tenant: Added PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail
- 135-canonical-tenant-context-resolution: Added PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -43,6 +43,7 @@
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\Verification\VerificationAssistViewModelBuilder;
use App\Support\Verification\VerificationCheckStatus;
use App\Support\Verification\VerificationReportOverall;
use App\Support\Workspaces\WorkspaceContext;
@ -68,6 +69,7 @@
use Filament\Support\Enums\FontWeight;
use Filament\Support\Enums\Width;
use Filament\Support\Exceptions\Halt;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@ -733,6 +735,8 @@ private function resumeContextSchema(): array
return [
Section::make('Onboarding draft')
->compact()
->collapsible()
->collapsed()
->columns(2)
->schema([
Text::make('Tenant')
@ -1247,13 +1251,16 @@ private function verificationRunUrl(): ?string
* acknowledged_at: string|null,
* expires_at: string|null,
* acknowledged_by: array{id: int, name: string}|null
* }>
* }>,
* assistVisibility: array{is_visible: bool, reason: 'permission_blocked'|'permission_attention'|'hidden_ready'|'hidden_irrelevant'},
* assistActionName: string
* }
*/
private function verificationReportViewData(): array
{
$run = $this->verificationRun();
$runUrl = $this->verificationRunUrl();
$assistVisibility = $this->verificationAssistVisibility();
if (! $run instanceof OperationRun) {
return [
@ -1265,6 +1272,8 @@ private function verificationReportViewData(): array
'previousRunUrl' => null,
'canAcknowledge' => false,
'acknowledgements' => [],
'assistVisibility' => $assistVisibility,
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
];
}
@ -1333,9 +1342,30 @@ private function verificationReportViewData(): array
'previousRunUrl' => $previousRunUrl,
'canAcknowledge' => $canAcknowledge,
'acknowledgements' => $acknowledgements,
'assistVisibility' => $assistVisibility,
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
];
}
public function wizardVerificationRequiredPermissionsAssistAction(): Action
{
return Action::make('wizardVerificationRequiredPermissionsAssist')
->label('View required permissions')
->icon('heroicon-m-key')
->color('warning')
->modal()
->slideOver()
->stickyModalHeader()
->modalHeading('Required permissions assist')
->modalDescription('Review stored permission diagnostics without leaving the onboarding wizard.')
->modalSubmitAction(false)
->modalCancelAction(fn (Action $action): Action => $action->label('Close'))
->modalContent(fn (): View => view('filament.actions.verification-required-permissions-assist', [
'assist' => $this->verificationAssistViewModel(),
]))
->visible(fn (): bool => $this->verificationAssistVisibility()['is_visible']);
}
public function acknowledgeVerificationCheckAction(): Action
{
return Action::make('acknowledgeVerificationCheck')
@ -2949,6 +2979,101 @@ private function verificationRunMatchesSelectedConnection(OperationRun $run): bo
return $runProviderConnectionId === $selectedProviderConnectionId;
}
/**
* @return array{is_visible: bool, reason: 'permission_blocked'|'permission_attention'|'hidden_ready'|'hidden_irrelevant'}
*/
private function verificationAssistVisibility(): array
{
$tenant = $this->managedTenant;
$user = $this->currentUser();
$run = $this->verificationRun();
if (! $tenant instanceof Tenant || ! $user instanceof User || ! $user->canAccessTenant($tenant)) {
return $this->hiddenVerificationAssistVisibility();
}
if (! $run instanceof OperationRun || $run->status !== OperationRunStatus::Completed->value) {
return $this->hiddenVerificationAssistVisibility();
}
$report = VerificationReportViewer::report($run);
if (! is_array($report)) {
return $this->hiddenVerificationAssistVisibility();
}
return app(VerificationAssistViewModelBuilder::class)->visibility($tenant, $report);
}
/**
* @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<string, mixed>,
* fallback: array{has_incomplete_detail:bool,message:?string}
* }
*/
private function verificationAssistViewModel(): array
{
$tenant = $this->managedTenant;
$user = $this->currentUser();
$run = $this->verificationRun();
if (! $tenant instanceof Tenant || ! $user instanceof User || ! $user->canAccessTenant($tenant)) {
abort(404);
}
if (! $run instanceof OperationRun || $run->status !== OperationRunStatus::Completed->value) {
abort(404);
}
$report = VerificationReportViewer::report($run);
if (! is_array($report)) {
abort(404);
}
return app(VerificationAssistViewModelBuilder::class)->build(
tenant: $tenant,
verificationReport: $report,
providerConnection: $this->resolveSelectedProviderConnection($tenant),
verificationStatus: $this->verificationStatus(),
isVerificationStale: $this->verificationRunIsStaleForSelectedConnection(),
staleReason: $this->verificationAssistStaleReason(),
canAccessProviderConnectionDiagnostics: $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW),
);
}
private function verificationAssistStaleReason(): ?string
{
if (! $this->verificationRunIsStaleForSelectedConnection()) {
return null;
}
return 'The selected provider connection has changed since this verification run. Start verification again to validate the current connection.';
}
/**
* @return array{is_visible: bool, reason: 'hidden_irrelevant'}
*/
private function hiddenVerificationAssistVisibility(): array
{
return [
'is_visible' => false,
'reason' => 'hidden_irrelevant',
];
}
/**
* @param array<string, mixed> $state
* @return array<string, mixed>

View File

@ -0,0 +1,471 @@
<?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);
}
}

View File

@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Support\Verification;
use App\Support\Providers\ProviderReasonCodes;
final class VerificationLinkBehavior
{
/**
* @return array{
* label:string,
* url:string,
* kind:'external'|'internal-diagnostic'|'internal-inline-safe',
* opens_in_new_tab:bool,
* show_new_tab_hint:bool
* }
*/
public function describe(?string $label, ?string $url): array
{
$normalizedLabel = is_string($label) && trim($label) !== ''
? trim($label)
: 'Open link';
$normalizedUrl = is_string($url) && trim($url) !== ''
? trim($url)
: '';
$kind = $this->classify($normalizedUrl);
$opensInNewTab = $kind !== 'internal-inline-safe' && $normalizedUrl !== '';
return [
'label' => $normalizedLabel,
'url' => $normalizedUrl,
'kind' => $kind,
'opens_in_new_tab' => $opensInNewTab,
'show_new_tab_hint' => $opensInNewTab,
];
}
/**
* @param array<string, mixed> $check
*/
public function shouldRouteThroughAssist(array $check, bool $assistVisible): bool
{
if (! $assistVisible) {
return false;
}
$key = $check['key'] ?? null;
$key = is_string($key) ? trim($key) : '';
if ($key !== '' && str_starts_with($key, 'permissions.')) {
return true;
}
$reasonCode = $check['reason_code'] ?? null;
$reasonCode = is_string($reasonCode) ? trim($reasonCode) : '';
return in_array($reasonCode, [
ProviderReasonCodes::ProviderConsentMissing,
ProviderReasonCodes::ProviderConsentFailed,
ProviderReasonCodes::ProviderConsentRevoked,
ProviderReasonCodes::ProviderPermissionMissing,
ProviderReasonCodes::ProviderPermissionDenied,
ProviderReasonCodes::ProviderPermissionRefreshFailed,
ProviderReasonCodes::IntuneRbacPermissionMissing,
], true);
}
/**
* @return 'external'|'internal-diagnostic'|'internal-inline-safe'
*/
private function classify(string $url): string
{
if ($url === '') {
return 'internal-inline-safe';
}
$path = $this->extractPath($url);
if ($path !== null && $this->isInternalDiagnosticPath($path)) {
return 'internal-diagnostic';
}
if ($this->isExternalUrl($url)) {
return 'external';
}
return 'internal-inline-safe';
}
private function extractPath(string $url): ?string
{
$path = parse_url($url, PHP_URL_PATH);
if (is_string($path) && $path !== '') {
return '/'.ltrim($path, '/');
}
if (str_starts_with($url, '/')) {
return '/'.ltrim($url, '/');
}
return null;
}
private function isExternalUrl(string $url): bool
{
$scheme = parse_url($url, PHP_URL_SCHEME);
if (! is_string($scheme) || ! in_array(strtolower($scheme), ['http', 'https'], true)) {
return false;
}
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return true;
}
$applicationHost = parse_url(url('/'), PHP_URL_HOST);
$applicationPort = parse_url(url('/'), PHP_URL_PORT);
$urlPort = parse_url($url, PHP_URL_PORT);
if (! is_string($applicationHost) || $applicationHost === '') {
return true;
}
if (strcasecmp($host, $applicationHost) !== 0) {
return true;
}
if ($applicationPort === null || $urlPort === null) {
return false;
}
return (int) $urlPort !== (int) $applicationPort;
}
private function isInternalDiagnosticPath(string $path): bool
{
return (bool) preg_match(
'/^\/admin\/(?:tenants\/[^\/]+\/required-permissions|tenants\/[^\/]+\/provider-connections(?:\/create|\/[^\/]+(?:\/edit)?)?|provider-connections(?:\/create|\/[^\/]+(?:\/edit)?)?)$/',
$path,
);
}
}

View File

@ -0,0 +1,290 @@
@php
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use Illuminate\Support\Carbon;
$assist = is_array($assist ?? null) ? $assist : [];
$tenant = is_array($assist['tenant'] ?? null) ? $assist['tenant'] : [];
$verification = is_array($assist['verification'] ?? null) ? $assist['verification'] : [];
$overview = is_array($assist['overview'] ?? null) ? $assist['overview'] : [];
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
$freshness = is_array($overview['freshness'] ?? null) ? $overview['freshness'] : [];
$missingPermissions = is_array($assist['missing_permissions'] ?? null) ? $assist['missing_permissions'] : [];
$applicationRows = is_array($missingPermissions['application'] ?? null) ? $missingPermissions['application'] : [];
$delegatedRows = is_array($missingPermissions['delegated'] ?? null) ? $missingPermissions['delegated'] : [];
$copy = is_array($assist['copy'] ?? null) ? $assist['copy'] : [];
$actions = is_array($assist['actions'] ?? null) ? $assist['actions'] : [];
$fallback = is_array($assist['fallback'] ?? null) ? $assist['fallback'] : [];
$overviewSpec = BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $overview['overall'] ?? null);
$verificationSpec = BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $verification['overall'] ?? null);
$lastRefreshedAt = is_string($freshness['last_refreshed_at'] ?? null) ? (string) $freshness['last_refreshed_at'] : null;
$lastRefreshedLabel = 'Not yet refreshed';
if ($lastRefreshedAt !== null) {
try {
$lastRefreshedLabel = Carbon::parse($lastRefreshedAt)->diffForHumans();
} catch (\Throwable) {
$lastRefreshedLabel = $lastRefreshedAt;
}
}
$copyApplication = (string) ($copy['application'] ?? '');
$copyDelegated = (string) ($copy['delegated'] ?? '');
$fullPageAction = is_array($actions['full_page'] ?? null) ? $actions['full_page'] : [];
$grantAdminConsentAction = is_array($actions['grant_admin_consent'] ?? null) ? $actions['grant_admin_consent'] : [];
$manageProviderConnectionAction = is_array($actions['manage_provider_connection'] ?? null) ? $actions['manage_provider_connection'] : [];
$rerunVerificationAction = is_array($actions['rerun_verification'] ?? null) ? $actions['rerun_verification'] : [];
$renderActionLink = static function (array $action, string $testId, string $tone = 'primary'): string {
$label = is_string($action['label'] ?? null) ? trim((string) $action['label']) : '';
$url = is_string($action['url'] ?? null) ? trim((string) $action['url']) : '';
$available = (bool) ($action['available'] ?? false);
if (! $available || $label === '' || $url === '') {
return '';
}
$baseClasses = match ($tone) {
'secondary' => 'border-gray-300 bg-white text-gray-900 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-white dark:hover:bg-gray-800',
'warning' => 'border-warning-300 bg-warning-50 text-warning-900 hover:bg-warning-100 dark:border-warning-700 dark:bg-warning-950/40 dark:text-warning-100 dark:hover:bg-warning-900/60',
default => 'border-primary-300 bg-primary-50 text-primary-900 hover:bg-primary-100 dark:border-primary-700 dark:bg-primary-950/40 dark:text-primary-100 dark:hover:bg-primary-900/60',
};
return sprintf(
'<a href="%s" target="_blank" rel="noopener noreferrer" data-testid="%s" class="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition %s"><span>%s</span><span class="text-xs font-normal opacity-80">Opens in new tab</span></a>',
e($url),
e($testId),
$baseClasses,
e($label),
);
};
@endphp
<div class="space-y-6" data-testid="verification-assist-root">
<div class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-2">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Tenant
</div>
<div class="text-lg font-semibold text-gray-950 dark:text-white">
{{ (string) ($tenant['name'] ?? 'Tenant') }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ (string) ($tenant['external_id'] ?? 'Unknown tenant') }}
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
@if ($overviewSpec)
<x-filament::badge :color="$overviewSpec->color" :icon="$overviewSpec->icon">
{{ $overviewSpec->label }}
</x-filament::badge>
@endif
@if ($verificationSpec)
<x-filament::badge :color="$verificationSpec->color" :icon="$verificationSpec->icon">
Verification: {{ $verificationSpec->label }}
</x-filament::badge>
@endif
<x-filament::badge color="gray">
Refreshed {{ $lastRefreshedLabel }}
</x-filament::badge>
</div>
</div>
<div class="grid auto-rows-fr grid-cols-2 gap-2 sm:grid-cols-4">
<div class="flex h-full min-h-20 flex-col justify-between gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-700 dark:bg-gray-950">
<div class="text-xs font-medium leading-tight text-gray-500 dark:text-gray-400">Missing (app)</div>
<div class="text-sm font-semibold leading-none text-gray-950 dark:text-white">{{ (int) ($counts['missing_application'] ?? 0) }}</div>
</div>
<div class="flex h-full min-h-20 flex-col justify-between gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-700 dark:bg-gray-950">
<div class="text-xs font-medium leading-tight text-gray-500 dark:text-gray-400">Missing (delegated)</div>
<div class="text-sm font-semibold leading-none text-gray-950 dark:text-white">{{ (int) ($counts['missing_delegated'] ?? 0) }}</div>
</div>
<div class="flex h-full min-h-20 flex-col justify-between gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-700 dark:bg-gray-950">
<div class="text-xs font-medium leading-tight text-gray-500 dark:text-gray-400">Present</div>
<div class="text-sm font-semibold leading-none text-gray-950 dark:text-white">{{ (int) ($counts['present'] ?? 0) }}</div>
</div>
<div class="flex h-full min-h-20 flex-col justify-between gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-700 dark:bg-gray-950">
<div class="text-xs font-medium leading-tight text-gray-500 dark:text-gray-400">Errors</div>
<div class="text-sm font-semibold leading-none text-gray-950 dark:text-white">{{ (int) ($counts['error'] ?? 0) }}</div>
</div>
</div>
</div>
</div>
@if ((bool) ($verification['is_stale'] ?? false))
<div class="rounded-xl border border-warning-300 bg-warning-50 p-4 text-sm text-warning-900 dark:border-warning-700 dark:bg-warning-950/40 dark:text-warning-100">
<div class="font-semibold">Verification result is stale</div>
<div class="mt-1">
{{ (string) ($verification['stale_reason'] ?? 'Start verification again to validate the current provider connection.') }}
</div>
</div>
@endif
@if ((bool) ($freshness['is_stale'] ?? false))
<div class="rounded-xl border border-warning-300 bg-warning-50 p-4 text-sm text-warning-900 dark:border-warning-700 dark:bg-warning-950/40 dark:text-warning-100">
<div class="font-semibold">Stored permission data needs refresh</div>
<div class="mt-1">
The permission summary is based on stored diagnostics only. Re-run verification after fixing access to refresh the stored result.
</div>
</div>
@endif
@if ((bool) ($fallback['has_incomplete_detail'] ?? false))
<div class="rounded-xl border border-gray-300 bg-gray-50 p-4 text-sm text-gray-800 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-200">
<div class="font-semibold">Compact detail is incomplete</div>
<div class="mt-1">
{{ (string) ($fallback['message'] ?? 'Open the full page for more detail or rerun verification after addressing access issues.') }}
</div>
</div>
@endif
<div class="space-y-3">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Recovery actions</div>
<div class="flex flex-wrap gap-2">
{!! $renderActionLink($grantAdminConsentAction, 'verification-assist-admin-consent', 'warning') !!}
{!! $renderActionLink($manageProviderConnectionAction, 'verification-assist-manage-provider-connection', 'primary') !!}
{!! $renderActionLink($fullPageAction, 'verification-assist-full-page', 'secondary') !!}
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ (string) ($rerunVerificationAction['label'] ?? 'Use the existing Start verification action in this step after reviewing changes.') }}
</div>
</div>
@if (trim($copyApplication) !== '')
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900" x-data="{ copied: false, text: @js($copyApplication), async copyPayload() { try { if (navigator.clipboard && (location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1')) { await navigator.clipboard.writeText(this.text); } else { const textarea = document.createElement('textarea'); textarea.value = this.text; textarea.setAttribute('readonly', 'readonly'); textarea.style.position = 'absolute'; textarea.style.left = '-9999px'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } this.copied = true; setTimeout(() => this.copied = false, 1600); } catch (error) { this.copied = false; } } }">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-gray-950 dark:text-white">Copy missing application permissions</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">Newline-separated payload for admin consent or handoff.</div>
</div>
<div class="flex items-center gap-2">
<div x-show="copied" x-cloak class="text-xs font-medium text-success-700 dark:text-success-300">Copied</div>
<x-filament::button size="sm" color="primary" x-on:click="copyPayload()" data-testid="verification-assist-copy-application">
Copy missing application permissions
</x-filament::button>
</div>
</div>
<pre class="mt-3 overflow-x-auto rounded-lg bg-gray-950/95 p-3 text-xs text-white">{{ $copyApplication }}</pre>
</div>
@endif
@if (trim($copyDelegated) !== '')
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900" x-data="{ copied: false, text: @js($copyDelegated), async copyPayload() { try { if (navigator.clipboard && (location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1')) { await navigator.clipboard.writeText(this.text); } else { const textarea = document.createElement('textarea'); textarea.value = this.text; textarea.setAttribute('readonly', 'readonly'); textarea.style.position = 'absolute'; textarea.style.left = '-9999px'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } this.copied = true; setTimeout(() => this.copied = false, 1600); } catch (error) { this.copied = false; } } }">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-gray-950 dark:text-white">Copy missing delegated permissions</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">Only shown when delegated permission detail exists in the stored diagnostics.</div>
</div>
<div class="flex items-center gap-2">
<div x-show="copied" x-cloak class="text-xs font-medium text-success-700 dark:text-success-300">Copied</div>
<x-filament::button size="sm" color="gray" x-on:click="copyPayload()" data-testid="verification-assist-copy-delegated">
Copy missing delegated permissions
</x-filament::button>
</div>
</div>
<pre class="mt-3 overflow-x-auto rounded-lg bg-gray-950/95 p-3 text-xs text-white">{{ $copyDelegated }}</pre>
</div>
@endif
@if ($applicationRows !== [])
<div class="space-y-3">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Missing application permissions</div>
<div class="space-y-3">
@foreach ($applicationRows as $row)
@php
$features = is_array($row['features'] ?? null) ? $row['features'] : [];
@endphp
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="font-mono text-sm font-semibold text-gray-950 dark:text-white">
{{ (string) ($row['key'] ?? 'Unknown permission') }}
</div>
@if (filled($row['description'] ?? null))
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ (string) $row['description'] }}
</div>
@endif
</div>
<x-filament::badge :color="($row['status'] ?? null) === 'error' ? 'warning' : 'danger'" size="sm">
{{ ($row['status'] ?? null) === 'error' ? 'Error' : 'Missing' }}
</x-filament::badge>
</div>
@if ($features !== [])
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($features as $feature)
<span class="rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-200">
{{ (string) $feature }}
</span>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
</div>
@endif
@if ($delegatedRows !== [])
<div class="space-y-3">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Missing delegated permissions</div>
<div class="space-y-3">
@foreach ($delegatedRows as $row)
@php
$features = is_array($row['features'] ?? null) ? $row['features'] : [];
@endphp
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="font-mono text-sm font-semibold text-gray-950 dark:text-white">
{{ (string) ($row['key'] ?? 'Unknown permission') }}
</div>
@if (filled($row['description'] ?? null))
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ (string) $row['description'] }}
</div>
@endif
</div>
<x-filament::badge :color="($row['status'] ?? null) === 'error' ? 'warning' : 'danger'" size="sm">
{{ ($row['status'] ?? null) === 'error' ? 'Error' : 'Missing' }}
</x-filament::badge>
</div>
@if ($features !== [])
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($features as $feature)
<span class="rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-200">
{{ (string) $feature }}
</span>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
</div>
@endif
@if ($applicationRows === [] && $delegatedRows === [] && ! (bool) ($fallback['has_incomplete_detail'] ?? false))
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
No missing permission rows are currently stored for this tenant. Use the full page or rerun verification if the summary still needs attention.
</div>
@endif
</div>

View File

@ -24,6 +24,24 @@
$acknowledgements = $acknowledgements ?? [];
$acknowledgements = is_array($acknowledgements) ? $acknowledgements : [];
$assistVisibility = $assistVisibility ?? [];
$assistVisibility = is_array($assistVisibility) ? $assistVisibility : [];
$assistActionName = $assistActionName ?? 'wizardVerificationRequiredPermissionsAssist';
$assistActionName = is_string($assistActionName) && trim($assistActionName) !== ''
? trim($assistActionName)
: 'wizardVerificationRequiredPermissionsAssist';
$showAssist = (bool) ($assistVisibility['is_visible'] ?? false);
$assistReason = $assistVisibility['reason'] ?? 'hidden_irrelevant';
$assistReason = is_string($assistReason) ? $assistReason : 'hidden_irrelevant';
$assistDescription = match ($assistReason) {
'permission_blocked' => 'Stored permission diagnostics show blockers. Review them without leaving onboarding.',
'permission_attention' => 'Stored permission diagnostics need attention before you rerun verification.',
default => 'Review required permissions without leaving onboarding.',
};
$status = $run['status'] ?? null;
$status = is_string($status) ? $status : null;
@ -129,6 +147,8 @@
if (isset($this) && method_exists($this, 'acknowledgeVerificationCheckAction')) {
$ackAction = $this->acknowledgeVerificationCheckAction();
}
$linkBehavior = app(\App\Support\Verification\VerificationLinkBehavior::class);
@endphp
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
@ -206,6 +226,30 @@
</div>
</div>
@if ($showAssist)
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 shadow-sm dark:border-warning-700 dark:bg-warning-950/40">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-semibold text-warning-950 dark:text-warning-50">
Required permissions assist
</div>
<div class="text-sm text-warning-900 dark:text-warning-100">
{{ $assistDescription }}
</div>
</div>
<x-filament::button
size="sm"
color="warning"
data-testid="verification-assist-trigger"
wire:click="mountAction('{{ $assistActionName }}')"
>
View required permissions
</x-filament::button>
</div>
</div>
@endif
@if ($report === null || $summary === null)
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
<div class="font-medium text-gray-900 dark:text-white">
@ -298,6 +342,7 @@ class="space-y-4"
$blocking = $check['blocking'] ?? false;
$blocking = is_bool($blocking) ? $blocking : false;
$routeNextStepsThroughAssist = $linkBehavior->shouldRouteThroughAssist($check, $showAssist);
@endphp
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
@ -339,25 +384,59 @@ class="space-y-4"
Next steps
</div>
<ul class="mt-2 space-y-1 text-sm">
@foreach ($nextSteps as $step)
@php
$step = is_array($step) ? $step : [];
$label = $step['label'] ?? null;
$url = $step['url'] ?? null;
$isExternal = is_string($url) && (str_starts_with($url, 'http://') || str_starts_with($url, 'https://'));
@endphp
@foreach ($nextSteps as $step)
@php
$step = is_array($step) ? $step : [];
$label = $step['label'] ?? null;
$url = $step['url'] ?? null;
$testId = is_string($label) && $label !== ''
? 'verification-next-step-'.\Illuminate\Support\Str::slug($label)
: null;
$behavior = $routeNextStepsThroughAssist
? null
: $linkBehavior->describe(
is_string($label) ? $label : null,
is_string($url) ? $url : null,
);
$opensInNewTab = (bool) ($behavior['opens_in_new_tab'] ?? false);
$showNewTabHint = (bool) ($behavior['show_new_tab_hint'] ?? false);
@endphp
@if (is_string($label) && $label !== '' && is_string($url) && $url !== '')
<li>
<a
href="{{ $url }}"
class="text-primary-600 hover:underline dark:text-primary-400"
@if ($isExternal)
target="_blank" rel="noreferrer"
@endif
>
{{ $label }}
</a>
@if ($routeNextStepsThroughAssist)
<button
type="button"
class="inline-flex items-center gap-2 text-primary-600 hover:underline dark:text-primary-400"
wire:click="mountAction('{{ $assistActionName }}')"
@if ($testId)
data-testid="{{ $testId }}"
@endif
>
<span>{{ $label }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
Open in assist
</span>
</button>
@else
<a
href="{{ $url }}"
class="inline-flex items-center gap-2 text-primary-600 hover:underline dark:text-primary-400"
@if ($testId)
data-testid="{{ $testId }}"
@endif
@if ($opensInNewTab)
target="_blank" rel="noopener noreferrer"
@endif
>
<span>{{ $label }}</span>
@if ($showNewTabHint)
<span class="text-xs text-gray-500 dark:text-gray-400">
Opens in new tab
</span>
@endif
</a>
@endif
</li>
@endif
@endforeach

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: Verify Access Required Permissions Assist
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-14
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/139-verify-access-permissions-assist/spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation pass complete. The spec remains bounded to an additive Verify Access recovery improvement: no new routes, no repurposed Required Permissions page, and no duplicate permission backend.

View File

@ -0,0 +1,182 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "specs/139-verify-access-permissions-assist/contracts/verification-assist.view-model.json",
"title": "Verification Assist View Model",
"type": "object",
"required": [
"tenant",
"verification",
"overview",
"missing_permissions",
"copy",
"actions",
"fallback"
],
"properties": {
"tenant": {
"type": "object",
"required": ["id", "external_id", "name"],
"properties": {
"id": { "type": "integer", "minimum": 1 },
"external_id": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"verification": {
"type": "object",
"required": ["overall", "status", "is_stale", "stale_reason"],
"properties": {
"overall": { "type": ["string", "null"] },
"status": { "type": ["string", "null"] },
"is_stale": { "type": "boolean" },
"stale_reason": { "type": ["string", "null"] }
},
"additionalProperties": false
},
"overview": {
"type": "object",
"required": ["overall", "counts", "freshness"],
"properties": {
"overall": {
"type": "string",
"enum": ["ready", "needs_attention", "blocked"]
},
"counts": {
"type": "object",
"required": ["missing_application", "missing_delegated", "present", "error"],
"properties": {
"missing_application": { "type": "integer", "minimum": 0 },
"missing_delegated": { "type": "integer", "minimum": 0 },
"present": { "type": "integer", "minimum": 0 },
"error": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
},
"freshness": {
"type": "object",
"required": ["last_refreshed_at", "is_stale"],
"properties": {
"last_refreshed_at": { "type": ["string", "null"] },
"is_stale": { "type": "boolean" }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"missing_permissions": {
"type": "object",
"required": ["application", "delegated"],
"properties": {
"application": {
"type": "array",
"items": { "$ref": "#/$defs/permissionRow" }
},
"delegated": {
"type": "array",
"items": { "$ref": "#/$defs/permissionRow" }
}
},
"additionalProperties": false
},
"copy": {
"type": "object",
"required": ["application", "delegated"],
"properties": {
"application": { "type": "string" },
"delegated": { "type": "string" }
},
"additionalProperties": false
},
"actions": {
"type": "object",
"required": [
"full_page",
"copy_application",
"copy_delegated",
"grant_admin_consent",
"manage_provider_connection",
"rerun_verification"
],
"properties": {
"full_page": { "$ref": "#/$defs/linkAction" },
"copy_application": { "$ref": "#/$defs/copyAction" },
"copy_delegated": { "$ref": "#/$defs/copyAction" },
"grant_admin_consent": { "$ref": "#/$defs/linkActionNullable" },
"manage_provider_connection": { "$ref": "#/$defs/linkActionNullable" },
"rerun_verification": {
"type": "object",
"required": ["label", "handled_by_existing_wizard"],
"properties": {
"label": { "type": "string", "minLength": 1 },
"handled_by_existing_wizard": { "const": true }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"fallback": {
"type": "object",
"required": ["has_incomplete_detail", "message"],
"properties": {
"has_incomplete_detail": { "type": "boolean" },
"message": { "type": ["string", "null"] }
},
"additionalProperties": false
}
},
"$defs": {
"permissionRow": {
"type": "object",
"required": ["key", "type", "description", "features", "status", "details"],
"properties": {
"key": { "type": "string", "minLength": 1 },
"type": { "type": "string", "enum": ["application", "delegated"] },
"description": { "type": ["string", "null"] },
"features": {
"type": "array",
"items": { "type": "string" }
},
"status": { "type": "string", "enum": ["granted", "missing", "error"] },
"details": { "type": ["object", "null"] }
},
"additionalProperties": false
},
"linkAction": {
"type": "object",
"required": ["label", "url", "opens_in_new_tab", "available"],
"properties": {
"label": { "type": "string", "minLength": 1 },
"url": { "type": "string", "minLength": 1 },
"opens_in_new_tab": { "type": "boolean" },
"available": { "type": "boolean" },
"is_secondary": { "type": "boolean" }
},
"additionalProperties": false
},
"linkActionNullable": {
"type": "object",
"required": ["label", "url", "opens_in_new_tab", "available"],
"properties": {
"label": { "type": "string", "minLength": 1 },
"url": { "type": ["string", "null"] },
"opens_in_new_tab": { "type": "boolean" },
"available": { "type": "boolean" }
},
"additionalProperties": false
},
"copyAction": {
"type": "object",
"required": ["label", "payload", "available"],
"properties": {
"label": { "type": "string", "minLength": 1 },
"payload": { "type": "string" },
"available": { "type": "boolean" }
},
"additionalProperties": false
}
},
"additionalProperties": false
}

View File

@ -0,0 +1,85 @@
# Verification Link Behavior Contract — Spec 139
## Purpose
Define how Verify Access remediation controls and deep-dive links behave so the onboarding tab is preserved while deeper investigation remains available.
## Surfaces covered
- Verify Access report remediation controls rendered from `managed-tenant-onboarding-verification-report.blade.php`
- The in-place Required Permissions assist slideover
## Link categories
### 1) Assist-routed remediation controls
- Examples:
- `Grant admin consent` shown directly in the Verify Access report for permission-related checks
- `Review platform connection` shown directly in the Verify Access report for permission-related checks
- Behavior:
- Must open the in-place Required Permissions assist instead of navigating away.
- Must keep the current onboarding tab and wizard step intact.
- Must make it clear that the operator is opening the assist, not leaving the wizard.
### 2) External remediation links
- Examples:
- Admin consent URLs
- Microsoft Learn guidance URLs
- Behavior:
- Must open in a new tab.
- Must retain `rel` protection.
- Must be visually or semantically clear that they open a new tab.
### 3) Internal diagnostic deep-dive links
- Examples:
- Existing Required Permissions full page
- Relevant provider-connection diagnostic pages linked from verification next steps
- Other operator investigation pages that intentionally leave the wizard context and explicitly opt into diagnostic behavior
- Behavior:
- Must open in a new tab when launched from Verify Access or the assist.
- Must not replace the onboarding tab.
- Must be visually or semantically clear that they open a new tab.
## Concrete classification rules
### External remediation links
- Match absolute `http://` and `https://` URLs.
- Examples:
- `RequiredPermissionsLinks::adminConsentUrl(...)`
- `RequiredPermissionsLinks::adminConsentGuideUrl()`
### Internal diagnostic deep-dive links
- Match internal admin URLs that intentionally leave the in-place recovery flow for investigation.
- The initial allowlist for Spec 139 is:
- `RequiredPermissionsLinks::requiredPermissions(...)`
- Admin Provider Connection management URLs generated by `ProviderConnectionResource::getUrl('index'|'view'|'edit', ..., panel: 'admin')`
- Any future internal diagnostic deep-dive URL must opt in through the shared `VerificationLinkBehavior` helper rather than adding ad-hoc Blade conditions.
### Internal inline-safe links or actions
- Anything that stays inside the current onboarding interaction remains same-tab.
- Examples:
- Existing wizard actions such as rerun verification
- Slideover close/dismiss controls
- Non-navigation UI toggles
### 4) Internal inline-safe actions
- Examples:
- Existing in-component rerun verification action
- Slideover close action
- Behavior:
- Must remain in the current onboarding tab.
- Must not be converted into link-based new-tab navigation.
## Required semantics
- Permission-related remediation controls in the Verify Access report are always category 1.
- The full-page Required Permissions action inside the assist is always category 3.
- Existing external links exposed from the assist remain category 2.
- Verification-report rendering must no longer assume that permission remediation should navigate directly when the assist is available.
- Link and remediation classification must be rule-based and reusable so additional permission-related checks can route through the assist without view-level duplication.
- The helper must distinguish provider-connection management routes from same-tab onboarding controls so the onboarding tab is preserved only for true diagnostic escapes.
## Explicit non-goals
- No new routes.
- No same-tab replacement of the onboarding wizard for category 2 or category 3 links.
- No conversion of the Required Permissions page into an onboarding sub-step.

View File

@ -0,0 +1,116 @@
# Data Model — Spec 139 (Verify Access Required Permissions Assist)
## Existing persisted entities reused
### TenantOnboardingSession (existing)
- Source: `app/Models/TenantOnboardingSession.php`
- Role in this feature:
- Anchors the current onboarding draft and current wizard step.
- Holds the selected provider connection and latest verification run reference in `state`.
- Relevant persisted state:
- `tenant_id`
- `current_step`
- `state.provider_connection_id`
- `state.verification_operation_run_id`
### OperationRun (existing)
- Source: `app/Models/OperationRun.php`
- Role in this feature:
- Supplies the latest stored verification result used by Verify Access.
- Remains the source of truth for the existing verification report.
- Relevant persisted context:
- `context.provider_connection_id`
- `context.target_scope`
- `context.verification_report`
### TenantPermission dataset (existing)
- Source: `tenant_permissions` via `TenantPermissionService::compare(... persist: false, liveCheck: false)`
- Role in this feature:
- Supplies the current DB-only permission diagnostics summary and missing-permission breakdown used by the assist and full page.
## Computed view models
### VerificationAssistVisibility (computed)
- Purpose: determines whether the Verify Access step should expose `View required permissions`.
- Inputs:
- Stored verification report overall/check state.
- Existing permission diagnostics summary.
- Stale verification context for the selected provider connection.
- Shape:
- `is_visible: bool`
- `reason: 'permission_blocked' | 'permission_attention' | 'hidden_ready' | 'hidden_irrelevant'`
### VerificationAssistViewModel (computed)
- Purpose: compact payload rendered inside the slideover.
- Derived from:
- `verificationReportViewData()` output
- `TenantRequiredPermissionsViewModelBuilder::build($tenant, ...)`
- Shape:
- `tenant: { id: int, external_id: string, name: string }`
- `verification: { overall: string|null, status: string|null, is_stale: bool, stale_reason: string|null }`
- `overview: { overall: string, counts: { missing_application: int, missing_delegated: int, present: int, error: int }, freshness: { last_refreshed_at: string|null, is_stale: bool } }`
- `missing_permissions: { application: PermissionRow[], delegated: PermissionRow[] }`
- `copy: { application: string, delegated: string }`
- `actions: AssistActions`
- `fallback: { has_incomplete_detail: bool, message: string|null }`
### PermissionRow (existing computed shape reused)
- Source: `TenantRequiredPermissionsViewModelBuilder`
- Shape:
- `key: string`
- `type: 'application' | 'delegated'`
- `description: string|null`
- `features: string[]`
- `status: 'granted' | 'missing' | 'error'`
- `details: array|null`
### AssistActions (computed)
- Purpose: explicit, authorization-safe actions shown in the slideover.
- Shape:
- `full_page: { label: string, url: string, opens_in_new_tab: true, is_secondary: true }`
- `copy_application: { label: string, payload: string, available: bool }`
- `copy_delegated: { label: string, payload: string, available: bool }`
- `grant_admin_consent: { label: string, url: string|null, opens_in_new_tab: bool, available: bool }`
- `manage_provider_connection: { label: string, url: string|null, opens_in_new_tab: bool, available: bool }`
- `rerun_verification: { label: string, handled_by_existing_wizard: true }`
### VerificationLinkBehavior (computed)
- Purpose: normalize how Verify Access next-step links are rendered.
- Shape:
- `label: string`
- `url: string`
- `kind: 'external' | 'internal-diagnostic' | 'internal-inline-safe'`
- `opens_in_new_tab: bool`
- `show_new_tab_hint: bool`
## State transitions
### Assist visibility transition
- Hidden → Visible when:
- verification report indicates permission-related blockers or relevant needs-attention state, and
- the current user remains authorized for the underlying tenant context.
- Visible → Hidden when:
- verification becomes fully ready with no relevant permission guidance, or
- authorization context no longer permits the assist.
### Wizard continuity invariant
- Opening the assist does not change `TenantOnboardingSession.current_step`.
- Closing the assist does not mutate onboarding draft state.
- Opening the full page from the assist does not navigate the current onboarding tab away from the wizard.
### Copy-action availability invariant
- A copy action is available only when the corresponding `copy.*` payload is a non-empty string.
- Empty payloads result in omitted or replaced controls, not broken actions.
## Authorization model reuse
- Onboarding wizard access continues to depend on workspace membership plus onboarding capability rules.
- Required Permissions deep-dive access continues to depend on tenant entitlement and existing page authorization.
- The assist is visible only when both contexts are valid enough to avoid leaking tenant or permission detail.
## No persistence changes
- No new tables.
- No new route-backed models.
- No new onboarding-only permissions store.
- No mutation to the existing Required Permissions page role.

View File

@ -0,0 +1,195 @@
# Implementation Plan: Verify Access Required Permissions Assist
**Branch**: `139-verify-access-permissions-assist` | **Date**: 2026-03-14 | **Spec**: `specs/139-verify-access-permissions-assist/spec.md`
**Input**: Feature specification from `specs/139-verify-access-permissions-assist/spec.md`
## Summary
Add a contextual Required Permissions assist to the onboarding Verify Access step without changing routing or the product role of the existing Required Permissions page. The implementation stays additive by reusing the existing verification report, next-step registry, and Required Permissions view-model builder: the wizard gets a new in-place slideover action for compact diagnostics and copy/remediation actions, permission-related verification-report next steps route into that assist first, and the deep-dive links exposed from the assist open in a new tab so the onboarding tab remains stable.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Filament v5 (Livewire v4), Laravel Blade, existing onboarding/verification support classes
**Storage**: PostgreSQL; existing JSON-backed onboarding draft state and `OperationRun.context.verification_report`
**Testing**: Pest v4 feature tests, Filament/Livewire component tests, Pest browser tests
**Target Platform**: Web application (Laravel Sail local environment)
**Project Type**: Web application
**Performance Goals**:
- Verify Access and the new assist render from stored DB/config-backed diagnostics only, with no provider or Graph calls during render.
- Opening or closing the assist is an in-component UI action that preserves current wizard state and feels immediate.
**Constraints**:
- No new routes.
- No repurposing `TenantRequiredPermissions` into an onboarding step.
- No duplicate permission-summary backend or onboarding-only diagnostics pipeline.
- Permission-related remediation controls in Verify Access must route through the in-place assist, and any internal or external deep-dive links that leave that recovery surface must preserve onboarding continuity by opening in a new tab.
**Scale/Scope**:
- One existing onboarding wizard step (`Verify access`) plus one existing full-page deep dive.
- Permission detail is limited to the current tenants configured/stored required permission dataset and existing verification report checks.
- Test scope includes targeted feature, Livewire, and browser coverage for visibility, continuity, copy feedback, and link behavior.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first / read-only render: PASS. The assist is a DB-only render surface over stored verification report + stored/config-based permission diagnostics.
- Read/write separation: PASS. No new mutation flow is introduced; rerun verification remains the existing explicit user-triggered operation with existing audit/ops behavior.
- Graph contract path: PASS. No new Graph calls or contract additions are required for this feature.
- Deterministic capabilities: PASS. Existing capability checks and tenant entitlement rules remain the source of truth; no new ad-hoc capability strings are needed.
- RBAC-UX plane separation and 404/403 semantics: PASS. The feature remains in the `/admin` plane; onboarding authorization and Required Permissions authorization continue unchanged.
- Workspace / tenant isolation: PASS. The assist is only available within an already authorized onboarding draft context and must reuse tenant-safe Required Permissions access semantics.
- Destructive confirmations: PASS. No destructive actions are added.
- Global search safety: PASS. No global-search behavior changes.
- Run observability and Ops-UX lifecycle: PASS. The assist creates no new `OperationRun`; existing verification reruns keep the current run lifecycle and feedback contract unchanged.
- Data minimization: PASS. The assist shows summary and copy payloads derived from existing permission data; no secrets are introduced.
- Badge semantics: PASS. Existing `BadgeRenderer` / centralized verification badge domains remain the only badge mapping source.
- UI naming: PASS. Labels remain operator-facing domain language such as `View required permissions`, `Open full page`, and `Rerun verification`.
- Filament Action Surface Contract: PASS with explicit exemption already documented in the spec for the in-step composite assist surface.
- UX-001: PASS with documented exemption. This is an extension of an existing wizard step, not a new CRUD form or view page.
No constitution violations are required for this feature.
## Project Structure
### Documentation (this feature)
```text
specs/139-verify-access-permissions-assist/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ ├── verification-assist.view-model.json
│ └── verification-link-behavior.md
└── tasks.md
```
### Source Code (existing, relevant)
```text
app/
├── Filament/
│ └── Pages/
│ ├── TenantRequiredPermissions.php
│ └── Workspaces/ManagedTenantOnboardingWizard.php
├── Filament/Support/VerificationReportViewer.php
├── Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
└── Support/
├── Links/RequiredPermissionsLinks.php
├── Providers/ProviderNextStepsRegistry.php
└── Verification/TenantPermissionCheckClusters.php
resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
tests/
├── Browser/
│ └── OnboardingDraftVerificationResumeTest.php
├── Feature/Onboarding/
│ ├── OnboardingVerificationClustersTest.php
│ ├── OnboardingVerificationTest.php
│ └── OnboardingVerificationV1_5UxTest.php
├── Feature/Rbac/
│ └── OnboardingWizardUiEnforcementTest.php
└── Unit/
├── RequiredPermissionsLinksTest.php
└── TenantPermissionCheckClustersTest.php
```
### Source Code (planned additions)
```text
tests/
├── Feature/Onboarding/
│ └── OnboardingVerificationAssistTest.php
└── Unit/
├── VerificationAssistViewModelBuilderTest.php
└── VerificationLinkBehaviorTest.php
```
**Structure Decision**: Web application (Laravel + Filament admin). Changes remain localized to the onboarding wizard page, the Verify Access Blade component, link/permission summary support code, and targeted Pest tests.
## Phase 0 — Outline & Research
### Key decisions (grounded in current code)
- Use an in-place Filament action on `ManagedTenantOnboardingWizard` to open the Required Permissions assist as a slideover.
- Filament v5 already supports `Action::make(...)->slideOver()` on this page, and the wizard already uses slideovers for connection editing and technical-details inspection.
- Keep Verify Access rendering DB-only and source the assist from existing data.
- `verificationReportViewData()` already exposes the current verification report for the wizard.
- `TenantRequiredPermissionsViewModelBuilder` already computes the required overview, missing permission groups, freshness, and copy payloads with DB-only semantics.
- Reuse the existing Required Permissions page as the only full-page deep dive.
- The assist should surface the same destination via `RequiredPermissionsLinks::requiredPermissions()` and must not introduce another page or route.
- Harden verification report remediation behavior using shared rules instead of hardcoding only one label.
- Permission-related remediation steps in the report should open the in-place assist.
- Internal and external diagnostic links exposed from the assist should still open in a new tab when they leave the wizard context.
- Reuse existing test suites and extend them at the nearest seams.
- Feature / Livewire coverage belongs next to existing onboarding verification tests.
- Browser continuity coverage belongs next to existing onboarding draft verification resume tests.
### Outputs
- `research.md` captures the decisions, rationale, and rejected alternatives.
## Phase 1 — Design & Contracts
### Data model (no DB migration expected)
- No schema change is expected.
- Introduce a computed assist view model derived from:
- the current onboarding verification report, and
- the existing `TenantRequiredPermissionsViewModelBuilder` output for the same tenant.
- Define explicit action/link semantics for:
- assist visibility,
- copy availability and feedback,
- full-page deep-dive behavior,
- internal diagnostic link target behavior.
### Contracts
- `contracts/verification-assist.view-model.json`
- Documents the computed in-place assist payload expected by the wizard and Blade view.
- `contracts/verification-link-behavior.md`
- Documents which Verify Access links must open in a new tab and how internal vs external deep-dive behavior is classified.
### Outputs
- `data-model.md`, `contracts/*`, `quickstart.md`.
### Post-design Constitution Re-check
- PASS. Phase 1 design keeps render paths DB-only, reuses existing authorization semantics, introduces no new operations or routes, and stays additive to the existing Required Permissions and onboarding surfaces.
## Phase 2 — Implementation Planning (for `/speckit.tasks`)
Planned work items to convert into `tasks.md`:
1. Add Verify Access assist action plumbing on `ManagedTenantOnboardingWizard`.
- Compute assist visibility from the existing verification report and stale/needs-attention context.
- Add a slideover action with explicit secondary deep-dive behavior.
2. Add a compact assist view model + rendering layer.
- Reuse `TenantRequiredPermissionsViewModelBuilder` output for summary counts, missing application/delegated groups, freshness, and copy payloads.
- Render safe fallback states when detail is incomplete or unavailable.
3. Harden remediation behavior in the verification report Blade component.
- Permission-related next steps route through the in-place assist.
- Existing external and internal diagnostic links that leave the wizard from the assist continue opening in a new tab.
- Link semantics remain clear to operators, including explicit assist wording in the report and explicit new-tab wording for assist deep dives.
- Initial internal diagnostic allowlist remains limited to Required Permissions and admin Provider Connection management routes.
4. Add copy feedback behavior.
- Show confirmation only when copy payload exists.
- Reuse an existing clipboard/confirmation pattern instead of creating a separate UX convention.
5. Preserve authorization and continuity.
- Assist render conditions must remain authorization-safe.
- Non-members or out-of-scope actors remain `404`; in-scope members missing capability remain policy-consistent denial.
- Closing the assist or opening deep dives must not alter the current wizard step.
6. Extend tests.
- Feature/Livewire tests for assist visibility, slideover rendering, empty states, link behavior, and authorization.
- Browser tests for: deep dive opens in a new tab, the onboarding tab remains usable afterward, and the slideover does not break normal wizard controls.
7. Separate stale-state handling.
- Verification-run staleness caused by provider-connection changes stays distinct from permission-data freshness derived from stored permission diagnostics.
- Both states must render clearly and be tested independently.
## Complexity Tracking
No constitution exceptions or justified complexity violations are required.

View File

@ -0,0 +1,50 @@
# Quickstart — Spec 139 (Verify Access Required Permissions Assist)
## Local run (Sail)
- Start services: `vendor/bin/sail up -d`
- Open the app: `vendor/bin/sail open`
## Where to click
1. Sign in to the admin panel and select a workspace.
2. Open a managed tenant onboarding draft that is on the `Verify access` step.
3. Use a tenant/provider connection combination that produces permission-related blocked or needs-attention verification results.
4. In `Verify access`, confirm `View required permissions` appears near the verification result.
5. Open the assist and verify:
- summary metadata is visible,
- missing application and/or delegated permissions render when present,
- copy actions only appear when a payload exists,
- the full-page deep dive is visibly secondary.
6. Open the full-page deep dive and confirm it launches in a new tab while the onboarding tab remains usable.
7. Close the assist and rerun verification to confirm normal continuity.
8. Inspect permission-related Verify Access report next steps and confirm they open the in-place assist instead of navigating away.
9. Inside the assist, confirm deep-dive actions clearly communicate their new-tab behavior before activation.
## Expected behavior
- Fully ready verification with no permission guidance: no assist is shown.
- Permission-blocked or permission-relevant needs-attention verification: assist is shown.
- Permission-related remediation controls from Verify Access open the assist in place.
- Internal and external deep-dive links exposed from the assist continue opening in a new tab.
- Assist deep-dive links communicate their new-tab behavior clearly in the UI.
- Closing the assist preserves the current wizard step.
## Targeted tests
- Feature / Livewire:
- `vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingVerificationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingVerificationClustersTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php`
- Browser:
- `vendor/bin/sail artisan test --compact tests/Browser/OnboardingDraftVerificationResumeTest.php`
## Formatting
- `vendor/bin/sail bin pint --dirty --format agent`
## Deploy notes
- No new route registration.
- No new Filament asset registration expected.
- If implementation later adds registered assets for the assist, deployment must include `php artisan filament:assets`.

View File

@ -0,0 +1,72 @@
# Research — Spec 139 (Verify Access Required Permissions Assist)
## Decisions
### 1) Use a page action slideover on the existing onboarding wizard
- Decision: Implement the contextual assist as a Filament action on `ManagedTenantOnboardingWizard` that opens a slideover from the existing Verify Access step.
- Rationale:
- The page already uses Filament v5 `Action::make(...)->slideOver()` successfully for other in-place workflows.
- This preserves wizard continuity, satisfies the no-new-route requirement, and keeps the assist visually subordinate to the main step progression.
- Alternatives considered:
- Full-page navigation only: rejected because it breaks onboarding continuity and duplicates the exact problem this spec is meant to solve.
- A separate onboarding-only page or route: rejected because the spec explicitly forbids new routes and parallel surfaces.
### 2) Reuse the existing Required Permissions builder as the source of compact diagnostics
- Decision: Reuse `TenantRequiredPermissionsViewModelBuilder` as the canonical source for summary counts, overall status, freshness, missing application permissions, missing delegated permissions, and copy payloads.
- Rationale:
- The builder already provides DB-only permission diagnostics and copy payload semantics.
- Reusing it avoids forking permission summary logic and keeps the onboarding assist consistent with the full-page deep dive.
- Alternatives considered:
- Recomputing a second onboarding-specific summary from verification-report evidence alone: rejected because it would drift from the Required Permissions page and violate the additive-only intent.
### 3) Classify deep-dive links by behavior, not only by absolute URL scheme
- Decision: Harden Verify Access next-step link rendering with a shared internal-vs-deep-dive classification rule so relevant internal diagnostic links also open in a new tab.
- Rationale:
- The current Blade template opens only absolute external URLs in a new tab.
- Spec 139 requires internal diagnostic links from the verification report to stop replacing the onboarding tab, including pages other than Required Permissions.
- Alternatives considered:
- Hardcoding `Open required permissions` as the only new-tab internal link: rejected because the spec explicitly calls out other internal diagnostic links.
- Opening every internal `/admin/*` link in a new tab: rejected because not every internal link is a deep-dive escape hatch, and broad behavior changes would be too risky.
### 4) Keep the full-page Required Permissions page as the only deep-dive surface
- Decision: The assist will expose `Open full page` as a clearly secondary action that points to the existing `TenantRequiredPermissions` page via `RequiredPermissionsLinks::requiredPermissions()`.
- Rationale:
- This preserves the current product role of the page and avoids route sprawl.
- The deep dive remains available for advanced investigation while the onboarding tab stays on task.
- Alternatives considered:
- Replacing the full page with the slideover: rejected because the spec accepts that some advanced investigation still belongs on the full page.
### 5) Copy feedback should follow an explicit visible confirmation pattern
- Decision: Copy actions will only render when the relevant copy payload exists, and each copy action will provide explicit confirmation through a visible copied state, toast, or equivalent inline acknowledgement.
- Rationale:
- The spec requires clear feedback and safe handling when no copyable data exists.
- Reusing an existing clipboard feedback pattern is lower risk than inventing a new one.
- Alternatives considered:
- Silent clipboard copy: rejected because it gives poor operator feedback.
- Always-rendered disabled copy buttons for empty payloads: rejected because omitted or replaced actions are clearer in degraded states.
### 6) Extend existing onboarding verification tests instead of creating a separate test pyramid
- Decision: Add focused coverage in the existing onboarding verification feature, Livewire, and browser test areas.
- Rationale:
- The current repository already has strong seams around verification report rendering, slideover actions, stale verification continuity, and clustered checks.
- Extending those tests gives the best regression value with minimal new scaffolding.
- Alternatives considered:
- Creating a new isolated end-to-end-only suite for the assist: rejected because visibility rules and degraded-state rendering are faster and more deterministically covered in feature/Livewire tests.
## Resolved Clarifications
### How should the assist determine whether it is relevant?
- Decision: Relevance is driven by the stored verification report plus the existing permission diagnostics summary for the selected tenant.
- Rationale:
- The verification report already communicates permission-related blockers and next steps.
- The Required Permissions builder already exposes the underlying permission state needed for compact rendering.
### What should happen when permission detail is incomplete or stale?
- Decision: The assist still renders but degrades to safe fallback messaging, surfaces freshness/needs-attention context when available, and preserves rerun/deep-dive options instead of showing broken controls.
- Rationale:
- This matches the specs degraded-state requirements and keeps the recovery surface trustworthy.
### Does this feature need new backend persistence?
- Decision: No. The feature is computed entirely from existing onboarding draft state, the stored verification report, and existing permission diagnostics services.
- Rationale:
- This keeps the implementation additive and low risk.

View File

@ -0,0 +1,244 @@
# Feature Specification: Verify Access Required Permissions Assist
**Feature Branch**: `139-verify-access-permissions-assist`
**Created**: 2026-03-14
**Status**: Draft
**Input**: User description: "Add a contextual View required permissions assist inside the onboarding Verify Access experience, keep the existing full-page Required Permissions surface as a secondary deep-dive inside that assist, route permission-related report next steps into the assist first, preserve wizard continuity, provide clear copy feedback, avoid new routes, and keep the implementation additive to Spec 138."
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/admin/onboarding`
- `/admin/onboarding/{onboardingDraft}`
- `/admin/tenants/{tenant}/required-permissions`
- **Data Ownership**:
- Managed tenant onboarding drafts remain workspace-scoped workflow records.
- Verification reports, permission diagnostics, and provider connection status remain tenant-bound operational evidence already produced by existing verification flows.
- The feature is additive only and must not introduce new tables, duplicate permission datasets, or a parallel onboarding-only permissions backend.
- **RBAC**:
- Existing workspace membership and onboarding capability rules continue to govern access to the onboarding wizard.
- Existing tenant entitlement and Required Permissions authorization continue to govern whether a user may view deep-dive permission details for the current tenant.
- Non-members or actors outside the allowed workspace or tenant scope remain deny-as-not-found.
- Established in-scope members missing the relevant capability continue to receive policy-consistent denial for actions they are not allowed to use.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Recover blocked verification in place (Priority: P1)
As an onboarding operator, I want a contextual Required Permissions assist directly inside Verify Access so that I can understand permission blockers and next steps without losing my place in the wizard.
**Why this priority**: Verification recovery is the moment where operators are most likely to lose trust if the flow forces them into separate navigation or provides incomplete guidance.
**Independent Test**: Run Verify Access for a tenant with permission-related blockers, open the assist from the verification result area, review the missing permissions and remediation guidance, then close it and confirm the wizard remains on the same Verify Access state.
**Acceptance Scenarios**:
1. **Given** verification is blocked for permission-related reasons, **When** the operator reviews the Verify Access result area, **Then** a prominent but secondary `View required permissions` assist appears near the verification result and next-step guidance.
2. **Given** the operator opens the assist, **When** the in-place panel renders, **Then** it shows the relevant permission summary, any missing application permissions, any missing delegated permissions, and the available remediation actions without navigating away from the wizard.
3. **Given** the operator closes the assist without taking action, **When** the panel dismisses, **Then** the operator returns to the same Verify Access state and the current wizard step does not change.
---
### User Story 2 - Deep dive safely without breaking wizard continuity (Priority: P1)
As an operator, I want the full-page Required Permissions deep dive to remain available as a secondary action that opens safely in a new tab so that I can investigate further without replacing the onboarding tab.
**Why this priority**: Enterprise operators often need deeper investigation, but the onboarding tab must remain stable and resumable while they do it.
**Independent Test**: From a blocked Verify Access result, open the assist, launch the full-page Required Permissions experience from the assist, confirm it opens in a new tab, then continue using the onboarding wizard in the original tab.
**Acceptance Scenarios**:
1. **Given** the operator is in Verify Access with relevant permission guidance, **When** they choose the full-page deep dive, **Then** the existing Required Permissions page opens in a new tab and the onboarding tab remains on the same wizard state.
2. **Given** the verification report contains permission-related remediation steps, **When** the operator activates those controls, **Then** the in-place assist opens instead of replacing the onboarding wizard, and any deeper links exposed from the assist open in a new tab when they leave the wizard context.
3. **Given** the operator returns to the onboarding tab after opening a deep dive elsewhere, **When** they continue interacting with the wizard, **Then** normal controls and rerun verification behavior remain usable.
---
### User Story 3 - Get clear recovery cues in degraded states (Priority: P2)
As an operator, I want the in-place assist to stay understandable even when diagnostic data is incomplete so that I still know what I can do next instead of seeing a broken or misleading recovery surface.
**Why this priority**: Permission verification often fails in partially configured environments, so degraded states must still be safe and actionable.
**Independent Test**: Exercise verification results with incomplete permission detail, missing consent URL, single-type permission gaps, and non-copyable payloads, then confirm the assist shows safe fallback guidance and clear copy feedback without broken controls.
**Acceptance Scenarios**:
1. **Given** verification is blocked but the permission detail payload is incomplete, **When** the operator opens the assist, **Then** the panel still renders safely with clear fallback guidance and a path to the full-page deep dive when appropriate.
2. **Given** there are copyable missing permissions, **When** the operator uses a copy action, **Then** the interface shows clear confirmation such as a copied state, toast, or equally clear inline acknowledgement.
3. **Given** there are no copyable missing permissions or the consent URL is unavailable, **When** the assist renders, **Then** unavailable actions are omitted or replaced with an appropriate alternative next step rather than a broken control.
### Edge Cases
- Verification is ready and no permissions assist is needed.
- Verification is blocked but permission detail payload is incomplete.
- Permission data exists but there are no copyable missing permissions.
- Only delegated permissions are relevant.
- Only application permissions are relevant.
- Admin consent URL is unavailable.
- Provider connection exists but requires management or update.
- The full page is opened from the slideover while the wizard remains open.
- The user closes the slideover without taking action.
- The user reruns verification after reviewing the assist.
- Permission results become stale after provider connection changes.
- Links in the verification report include internal diagnostic pages other than Required Permissions.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature is DB-only at render time and reuses existing verification report data, Required Permissions diagnostics, link builders, and provider next-step registries. It must not introduce new Microsoft Graph calls, new routes, new long-running operations, or a parallel permissions backend. Existing verification reruns continue to use the current verification operation path and audit behavior; the assist itself is a low-risk additive recovery surface.
**Constitution alignment (OPS-UX):** The assist does not create a new `OperationRun`. If the operator reruns verification after using the assist, that rerun continues to use the existing `provider.connection.check` flow and its existing 3-surface feedback contract, service-owned lifecycle transitions, and Monitoring visibility. The assist itself must not introduce new ad-hoc operation notifications.
**Constitution alignment (RBAC-UX):** This feature operates in the workspace-admin plane under `/admin`. Existing onboarding authorization continues to gate the wizard, and existing tenant entitlement plus Required Permissions authorization continue to gate deep-dive access. Non-members or actors outside workspace or tenant scope remain `404`; in-scope members missing the relevant capability remain policy-consistent denials. The assist must not leak tenant existence or permission details to unauthorized users. Focused tests must cover both allowed and denied states.
**Constitution alignment (BADGE-001):** Any status or severity badges shown in the assist must continue using centralized verification and permission status semantics. The feature must not introduce ad-hoc badge mappings for ready, needs attention, blocked, stale, or warning states.
**Constitution alignment (UI-NAMING-001):** Operator-facing labels must use consistent domain language such as `View required permissions`, `Required permissions`, `Open full page`, `Copy missing permissions`, `Grant admin consent`, `Manage provider connection`, and `Rerun verification`. Implementation-first terms such as payload, registry, diagnostic object, or sidebar context must not become primary labels.
**Constitution alignment (Filament v5 / Livewire v4):** Livewire v4.0+ remains the compatibility target for this onboarding surface. No new panel is introduced and provider registration remains unchanged in `bootstrap/providers.php`. No new global search behavior is introduced; the Required Permissions full page keeps its existing role and remains outside onboarding step progression.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is satisfied with one explicit exemption: the Verify Access assist is a composite in-step action surface rather than a table or CRUD page, so list-table affordances do not apply. The full-page Required Permissions screen remains read-oriented and keeps its existing role.
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature extends an existing wizard step rather than introducing a new create, edit, or view page. The assist must appear in a predictable location near verification outcome and next-step guidance, with strong discoverability during blocked verification but lower visual weight than primary onboarding progression controls. The full-page deep dive remains visually secondary within the assist.
### Functional Requirements
- **FR-139-01 Contextual assist visibility**: The system must show a `View required permissions` assist within Verify Access when verification is blocked for permissions-related reasons.
- **FR-139-02 Needs-attention visibility**: The system must also show the assist when verification is not fully ready and permission guidance is relevant, even if the result is not a hard block.
- **FR-139-03 Ready-state suppression**: The system must not show the assist when verification is fully ready and no permission guidance is relevant.
- **FR-139-04 Predictable placement**: The assist must appear in a predictable location near the verification result and next-step guidance inside the Verify Access experience.
- **FR-139-05 Visual hierarchy**: The assist must be prominent enough to discover during blocked verification but must remain visually secondary to the primary onboarding progression controls.
- **FR-139-06 In-place presentation**: Activating the assist must open an in-place slideover or drawer rather than navigating to a separate page.
- **FR-139-07 Wizard continuity**: Opening or closing the assist must not change the current wizard step or replace the current onboarding tab.
- **FR-139-08 Summary rendering**: The assist must show summary metadata drawn from existing verification and permission diagnostics, including the overall state and any relevant freshness or context cues needed to interpret the result.
- **FR-139-09 Permission-type coverage**: The assist must show missing application permissions when present and missing delegated permissions when present.
- **FR-139-10 Safe empty states**: The assist must render safely when detail payloads are incomplete, empty, stale, or partially missing, and must provide fallback guidance instead of a broken panel.
- **FR-139-11 Copy availability**: Copy actions must only appear when there is copyable missing-permission content.
- **FR-139-12 Copy feedback**: Every copy action must provide clear user feedback through a copied state, toast, or equally clear inline confirmation.
- **FR-139-13 Full-page secondary action**: The existing full-page Required Permissions experience must remain available from the assist as a secondary deep-dive action.
- **FR-139-14 New-tab deep dive**: The full-page deep-dive action must open in a new tab by default from the onboarding assist.
- **FR-139-15 Assist-first report remediation**: Permission-related remediation controls rendered from the Verify Access report must open the in-place Required Permissions assist instead of navigating away from the wizard.
- **FR-139-16 External link continuity**: External remediation links that leave the wizard context from the assist must continue to open in a new tab.
- **FR-139-17 New-tab clarity**: Any assist action or deep-dive link that opens a new tab must be visually or semantically clear enough that enterprise operators understand the behavior.
- **FR-139-18 Consent fallback**: If an admin consent URL is unavailable, the assist must omit the broken primary action and show the next best available guidance.
- **FR-139-19 Connection-management fallback**: If the provider connection exists but requires management or update, the assist must expose that remediation path using existing surfaces rather than introducing a new onboarding-only path.
- **FR-139-20 Rerun continuity**: After reviewing the assist, the operator must be able to rerun verification normally from the existing wizard flow.
- **FR-139-21 Stale-result signaling**: If provider connection changes make existing permission results stale, the assist must not present those results as current and must preserve a clear path to rerun verification.
- **FR-139-22 Existing-surface reuse**: The implementation must extend the existing onboarding Verify Access step, reuse the existing full-page Required Permissions page, and reuse existing builders, registries, and link helpers.
- **FR-139-23 Route preservation**: The feature must not introduce any new routes.
- **FR-139-24 Full-page role preservation**: The feature must not repurpose the full-page Required Permissions surface into an onboarding sub-step.
- **FR-139-25 Additive-only scope**: The feature must remain additive and low-risk, limited to a wizard assist action, compact in-place detail rendering, and hardened link behavior.
- **FR-139-26 No duplicate diagnostics surface**: The feature must not create a second Required Permissions page, fork permission summary logic, or introduce a separate onboarding-only permissions backend.
- **FR-139-27 Authorization continuity**: Assist actions and deep-dive links must render only when the underlying onboarding and tenant authorization context is valid.
- **FR-139-28 Browser-level continuity coverage**: Browser validation must prove that opening the deep dive does not replace the onboarding tab, the wizard remains usable afterward, and the slideover does not break normal wizard controls.
### Non-Goals
- Turning the full-page Required Permissions screen into an onboarding step.
- Introducing a new route or separate deep-dive surface for onboarding-only permissions help.
- Replacing the full-page Required Permissions experience entirely.
- Changing Spec 138 concerns such as draft identity in the URL, resume disambiguation, or multi-draft landing behavior.
### Assumptions
- Existing verification reporting already identifies permission-related blockers and next-step relevance well enough to drive assist visibility.
- Existing permission diagnostics already provide enough data to populate a compact in-place summary without adding a new backend source.
- Existing Required Permissions authorization and workspace-tenant isolation semantics remain correct and should be reused unchanged.
- Opening the full-page deep dive in a new tab is an acceptable enterprise pattern because it preserves wizard continuity while keeping the deeper page available.
### Dependencies
- Existing onboarding Verify Access step and verification report rendering.
- Existing Required Permissions full-page experience.
- Existing permission diagnostics builders, next-step registries, and link helpers.
- Existing onboarding authorization, tenant entitlement, and browser test infrastructure.
### Relationship to Spec 138
- Spec 139 is complementary to Spec 138.
- Spec 138 hardens onboarding draft identity, resume semantics, and multi-draft determinism.
- Spec 139 hardens the permission-recovery experience within the Verify Access step of that onboarding flow.
- Spec 139 must not absorb Spec 138 concerns such as draft identity in the URL, resume disambiguation, multi-draft landing behavior, or resumable lifecycle rules.
### Risks and Tradeoffs
- The in-place assist is intentionally not a full replacement for the deep-dive page.
- The full page remains a separate surface because some advanced investigation still benefits from a broader, dedicated context.
- Opening the deep dive in a new tab adds one extra click context, but that tradeoff is accepted because it preserves wizard continuity and reduces accidental task loss.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Onboarding wizard: Verify Access step | `/admin/onboarding`, `/admin/onboarding/{onboardingDraft}` | `View required permissions` when relevant; existing `Rerun verification` remains primary recovery control | Not a table surface | None | None | None | Not applicable | Existing wizard Continue / Back flow remains unchanged | No new audit event required for opening or closing assist | Composite in-step assist surface; exemption from list-table affordances is intentional. |
| Verify Access assist slideover | In-place overlay from Verify Access | `Open full page` (secondary, new tab), `Copy missing permissions` when content exists, `Grant admin consent` when available, `Manage provider connection` when relevant | Not a table surface | None | None | Contextual fallback guidance only | Not applicable | Close returns to same wizard state | No new audit event required for read-only assistance and copy feedback | No destructive actions are introduced. New-tab behavior must be explicit in the UI. |
| Required Permissions full page | `/admin/tenants/{tenant}/required-permissions` | None added by this spec | Existing page affordances remain unchanged | None added by this spec | None added by this spec | Existing empty-state behavior remains unchanged | None added by this spec | Not applicable | No change | Product role remains unchanged; this spec only changes how onboarding links into it. |
### Key Entities *(include if feature involves data)*
- **Verification Permission Assist**: The in-place recovery surface shown within Verify Access when permission guidance is relevant.
- **Verification Report**: The existing stored verification result that determines whether permission guidance is relevant and supplies next-step context.
- **Permission Diagnostics Summary**: The existing permission status summary used to render counts, missing permission groups, freshness, and remediation relevance.
- **Required Permissions Deep Dive**: The existing full-page experience for tenant permission investigation, retained as the secondary escape hatch.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-139-01 Assist relevance coverage**: In focused regression coverage, 100% of blocked or needs-attention verification states with permission guidance display the assist, and 100% of fully ready states without relevant permission guidance do not.
- **SC-139-02 Wizard continuity**: In focused Livewire and browser coverage, 100% of assist open and close flows preserve the current Verify Access step and keep the onboarding tab usable.
- **SC-139-03 Safe deep-dive navigation**: In focused browser coverage, 100% of relevant onboarding deep-dive links open in a new tab rather than replacing the onboarding wizard tab.
- **SC-139-04 Permission detail coverage**: In focused rendering coverage, the assist correctly displays application-only gaps, delegated-only gaps, mixed gaps, and safe empty or incomplete states.
- **SC-139-05 Copy feedback clarity**: In focused interaction coverage, 100% of available copy actions provide visible confirmation and 0 unavailable copy actions render as broken controls.
- **SC-139-06 Additive architecture**: The completed implementation introduces 0 new routes, 0 duplicate Required Permissions pages, and 0 onboarding-only permission backends.
## Testing Requirements
### Core Regression Matrix
- Verify Access assist visibility when verification is blocked for permissions-related reasons.
- Verify Access assist visibility when verification needs attention and permissions guidance is relevant.
- Verify Access assist absence when verification is fully ready and no permissions guidance is relevant.
- Slideover opens successfully from Verify Access.
- Slideover shows expected summary metadata.
- Slideover shows missing application permissions when present.
- Slideover shows missing delegated permissions when present.
- Slideover handles empty or incomplete detail states safely.
- Slideover exposes the full-page deep-dive action.
- Opening the slideover does not change the current wizard step.
- Closing the slideover returns the operator to the same Verify Access state.
- Opening the full page from the slideover does not navigate away from the wizard tab.
- Rerunning verification after closing the assist behaves normally.
- Permission-related remediation controls from the verification report open the assist in place.
- External links exposed from the assist continue to render with new-tab behavior.
- No permission-related report remediation control replaces the onboarding wizard in the same tab.
- Unauthorized users cannot see or use assist surfaces beyond existing onboarding authorization.
- Assist actions render only when the underlying context is authorized.
### Browser-Level Validation
- Opening the Required Permissions deep dive from Verify Access does not replace the onboarding tab.
- The wizard remains usable after the deep dive is opened.
- The slideover does not break normal wizard controls.
## Implementation Plan
### Phase 1 - Navigation Safety Hardening
- Identify relevant internal diagnostic links in the verification report.
- Harden rendering so those links open in a new tab.
- Preserve existing external-link behavior.
### Phase 2 - Contextual Permissions Assist
- Add the `View required permissions` action to the existing Verify Access step.
- Implement the in-place slideover or drawer presentation.
- Wire the assist to existing permission diagnostics data.
- Add copy, remediation, and deep-dive actions while keeping the deep dive visually secondary.
### Phase 3 - Test Hardening
- Add feature, Livewire, and browser coverage for visibility, continuity, copy feedback, and navigation behavior.
- Verify no regressions to the existing Required Permissions full-page behavior.

View File

@ -0,0 +1,186 @@
---
description: "Task list for Spec 139 implementation"
---
# Tasks: Verify Access Required Permissions Assist
**Input**: Design documents from `/specs/139-verify-access-permissions-assist/`
**Prerequisites**: `plan.md` (required), `spec.md` (required), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Tests**: REQUIRED (Pest) for all runtime behavior changes.
**RBAC (required)**:
- Non-member / not entitled to workspace or tenant scope → 404 (deny-as-not-found)
- Member but missing capability → policy-consistent denial
- Capabilities MUST come from `App\Support\Auth\Capabilities`
**Badges (required)**:
- Reuse existing verification / permission badge domains via `BadgeCatalog` / `BadgeRenderer`
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Confirm the touched surfaces and baseline behavior before implementation.
- [X] T001 Validate the manual flow in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/139-verify-access-permissions-assist/quickstart.md against the current onboarding Verify Access and Required Permissions surfaces
- [X] T002 Capture current Verify Access link-rendering and assist-touchpoint seams in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
- [X] T003 [P] Run baseline verification coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingVerificationTest.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingVerificationClustersTest.php
- [X] T004 [P] Confirm existing Required Permissions summary/copy primitives in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/RequiredPermissionsLinksTest.php
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared primitives used by all user stories.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T005 Create the assist view-model builder in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Verification/VerificationAssistViewModelBuilder.php using the contract from /Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/139-verify-access-permissions-assist/contracts/verification-assist.view-model.json
- [X] T006 [P] Create the reusable link-classification helper in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Verification/VerificationLinkBehavior.php using the rules from /Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/139-verify-access-permissions-assist/contracts/verification-link-behavior.md
- [X] T007 [P] Add unit coverage for assist visibility, summary shaping, and copy availability in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/VerificationAssistViewModelBuilderTest.php
- [X] T008 [P] Add unit coverage for internal-vs-external deep-dive link classification, including Required Permissions and admin Provider Connection routes, in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/VerificationLinkBehaviorTest.php
- [X] T009 Add non-UI wizard helper methods for assist visibility and assist view-model access in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php without changing routes or onboarding draft persistence
**Checkpoint**: Assist view-model and link-behavior primitives are ready for story work.
---
## Phase 3: User Story 1 - Recover blocked verification in place (Priority: P1) 🎯 MVP
**Goal**: Add a contextual in-place Required Permissions assist inside Verify Access that preserves wizard continuity.
**Independent Test**: Run Verify Access for a permission-blocked tenant, open the assist, review the summary and missing permissions, close it, and confirm the wizard remains on the same Verify Access state.
### Tests (write first)
- [X] T010 [P] [US1] Add feature coverage for assist visibility in blocked, needs-attention, and ready states in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingVerificationAssistTest.php
- [X] T011 [P] [US1] Add Livewire coverage for assist open/close continuity on the Verify Access step in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingVerificationAssistTest.php
- [X] T012 [P] [US1] Add rendering coverage for summary metadata and missing application/delegated permissions in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingVerificationAssistTest.php
### Implementation
- [X] T013 [US1] Register the `View required permissions` slideover action in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php using the foundational helper methods from T009
- [X] T014 [P] [US1] Create the assist slideover Blade view in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/actions/verification-required-permissions-assist.blade.php
- [X] T015 [US1] Implement compact assist payload generation in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Verification/VerificationAssistViewModelBuilder.php by reusing /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php and existing verification-report data
- [X] T016 [US1] Render the assist trigger near verification result and next-step guidance in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
- [X] T017 [US1] Render assist summary, missing-permission groups, and safe empty-state fallback in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/actions/verification-required-permissions-assist.blade.php
- [X] T018 [US1] Preserve wizard continuity by ensuring assist open/close does not mutate draft step or verification state in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
**Checkpoint**: The Verify Access step exposes a working in-place permissions assist without leaving the wizard.
---
## Phase 4: User Story 2 - Deep dive safely without breaking wizard continuity (Priority: P1)
**Goal**: Keep the full-page Required Permissions deep dive as a secondary new-tab escape hatch and harden relevant verification-report links to stop replacing the onboarding tab.
**Independent Test**: From Verify Access, open the assist, launch the full-page deep dive, confirm it opens in a new tab, then keep using the onboarding tab normally.
### Tests (write first)
- [X] T019 [P] [US2] Add feature coverage for internal diagnostic links and external remediation links rendering with correct new-tab behavior and explicit new-tab semantics in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingVerificationClustersTest.php
- [X] T020 [P] [US2] Add browser coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Browser/OnboardingDraftVerificationResumeTest.php proving that opening the full-page deep dive does not replace the onboarding tab, the onboarding tab remains usable afterward, and the slideover does not break normal wizard controls
- [X] T021 [P] [US2] Add feature coverage for full-page deep-dive availability and secondary visual placement in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingVerificationAssistTest.php
### Implementation
- [X] T022 [US2] Implement reusable deep-dive link classification in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Verification/VerificationLinkBehavior.php for external links, Required Permissions routes, and admin Provider Connection management routes
- [X] T023 [US2] Harden Verify Access next-step link rendering to use the new classification helper and explicit operator-visible new-tab semantics in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
- [X] T024 [US2] Add full-page, admin-consent, and manage-provider-connection action shaping to /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Verification/VerificationAssistViewModelBuilder.php using /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Links/RequiredPermissionsLinks.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Providers/ProviderNextStepsRegistry.php
- [X] T025 [US2] Render the full-page deep-dive action as clearly secondary and explicitly new-tab in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/actions/verification-required-permissions-assist.blade.php
**Checkpoint**: Deep-dive actions preserve onboarding continuity and remain visually secondary to the in-place assist.
---
## Phase 5: User Story 3 - Get clear recovery cues in degraded states (Priority: P2)
**Goal**: Keep the assist safe and actionable when permission detail is incomplete, stale, single-type only, or not copyable.
**Independent Test**: Exercise incomplete detail, stale verification, no-copy payload, consent-unavailable, and single-type permission states, then verify the assist degrades safely with clear feedback.
### Tests (write first)
- [X] T026 [P] [US3] Add degraded-state rendering coverage for incomplete detail, permission-data freshness warnings, verification-run staleness after provider-connection changes, and single-type permission gaps in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingVerificationAssistTest.php
- [X] T027 [P] [US3] Add copy-feedback and no-copy-availability coverage in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingVerificationAssistTest.php
- [X] T028 [P] [US3] Add explicit authorization coverage for assist surfaces in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingVerificationAssistTest.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php, including non-member or out-of-scope `404`, in-scope member missing capability denial, and positive authorized rendering
### Implementation
- [X] T029 [US3] Implement degraded-state fallback, permission-data freshness signaling, and consent-unavailable handling in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Verification/VerificationAssistViewModelBuilder.php
- [X] T030 [US3] Render copy actions only when payloads exist and provide explicit copied-state feedback in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/actions/verification-required-permissions-assist.blade.php
- [X] T031 [US3] Surface verification-run staleness from provider-connection changes and rerun guidance consistently in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Verification/VerificationAssistViewModelBuilder.php
- [X] T032 [US3] Enforce authorization-safe assist visibility and action availability in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Verification/VerificationAssistViewModelBuilder.php using capability-registry checks and explicit `404` vs denial semantics
**Checkpoint**: The assist remains understandable, authorized, and feedback-rich across degraded states.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final formatting, regression validation, and quickstart confirmation.
- [X] T033 Run formatting on touched files with `vendor/bin/sail bin pint --dirty --format agent`
- [X] T034 Run targeted Pest coverage with `vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingVerificationAssistTest.php tests/Feature/Onboarding/OnboardingVerificationTest.php tests/Feature/Onboarding/OnboardingVerificationClustersTest.php tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php tests/Browser/OnboardingDraftVerificationResumeTest.php tests/Unit/VerificationAssistViewModelBuilderTest.php tests/Unit/VerificationLinkBehaviorTest.php`
- [X] T035 Validate the manual flow in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/139-verify-access-permissions-assist/quickstart.md and update the file if any step or expectation changed during implementation
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies.
- **Foundational (Phase 2)**: Depends on Setup; blocks all user stories.
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
- **User Story 2 (Phase 4)**: Depends on Foundational completion and is best completed after US1 because it extends the assist surface.
- **User Story 3 (Phase 5)**: Depends on Foundational completion and is best completed after US1 because degraded-state handling lives inside the assist.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: First deliverable and recommended MVP.
- **US2 (P1)**: Builds on the assist delivered in US1.
- **US3 (P2)**: Builds on the assist delivered in US1 and hardens edge cases.
### Dependency Graph (stories)
- Foundation → US1
- US1 → US2
- US1 → US3
---
## Parallel Execution Examples
### US1 parallelizable tasks
- T010, T011, and T012 can run in parallel in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingVerificationAssistTest.php as separate scenarios.
- T014 and T015 can run in parallel across /Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/actions/verification-required-permissions-assist.blade.php and /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Verification/VerificationAssistViewModelBuilder.php.
### US2 parallelizable tasks
- T019, T020, and T021 can run in parallel across feature and browser coverage.
### US3 parallelizable tasks
- T026, T027, and T028 can run in parallel across degraded-state, copy-feedback, and authorization coverage.
---
## Implementation Strategy
### MVP First (recommended)
1. Complete Phase 1 and Phase 2.
2. Implement US1 tests first and verify they fail.
3. Implement US1 code and get the assist working in place.
4. Validate with T033T035 before expanding scope.
### Incremental Delivery
1. Add US2 to harden new-tab deep-dive behavior and keep the full page secondary.
2. Add US3 to harden degraded states, copy feedback, and authorization edges.
3. Re-run the full targeted suite and quickstart at the end.

View File

@ -5,6 +5,7 @@
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantPermission;
use App\Models\User;
use App\Models\Workspace;
use App\Support\OperationRunOutcome;
@ -242,3 +243,250 @@
->assertScript($openBootstrapStep, true)
->assertSee('Started 1 bootstrap run(s).');
});
it('opens the full-page permissions deep dive in a new tab without replacing onboarding', function (): void {
[$user, $tenant] = createUserWithTenant(
role: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
$workspace = $tenant->workspace()->firstOrFail();
$configured = array_merge(
config('intune_permissions.permissions', []),
config('entra_permissions.permissions', []),
);
$permissionKeys = array_values(array_filter(array_map(static function (mixed $permission): ?string {
if (! is_array($permission)) {
return null;
}
$key = $permission['key'] ?? null;
return is_string($key) && trim($key) !== '' ? trim($key) : null;
}, $configured)));
if ($permissionKeys === []) {
test()->markTestSkipped('No configured required permissions found.');
}
$missingKey = $permissionKeys[0];
foreach (array_slice($permissionKeys, 1) as $key) {
TenantPermission::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'permission_key' => $key,
'status' => 'granted',
'details' => ['source' => 'db'],
'last_checked_at' => now(),
]);
}
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Browser assist connection',
'is_default' => true,
'status' => 'connected',
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'entra_tenant_name' => (string) $tenant->name,
],
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'permissions.admin_consent',
'title' => 'Required application permissions',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => 'provider_permission_missing',
'message' => "Missing required application permission: {$missingKey}",
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'verify',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
]);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
$page
->assertNoJavaScriptErrors()
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->click('Verify access')
->waitForText('View required permissions')
->click('View required permissions')
->waitForText('Open full page');
$page->script(<<<'JS'
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: async () => Promise.resolve(),
},
});
document.querySelector('[data-testid="verification-assist-copy-application"]')?.click();
JS);
$page
->waitForText('Copied')
->assertAttribute('[data-testid="verification-assist-full-page"]', 'target', '_blank')
->assertAttribute('[data-testid="verification-assist-full-page"]', 'rel', 'noopener noreferrer')
->click('Open full page')
->wait(1)
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->assertSee('Open full page')
->click('Close')
->click('Provider connection')
->assertSee('Select an existing connection or create a new one.');
});
it('opens the permissions assist from report remediation steps without leaving onboarding', function (): void {
[$user, $tenant] = createUserWithTenant(
role: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
$workspace = $tenant->workspace()->firstOrFail();
$configured = array_merge(
config('intune_permissions.permissions', []),
config('entra_permissions.permissions', []),
);
$permissionKeys = array_values(array_filter(array_map(static function (mixed $permission): ?string {
if (! is_array($permission)) {
return null;
}
$key = $permission['key'] ?? null;
return is_string($key) && trim($key) !== '' ? trim($key) : null;
}, $configured)));
if ($permissionKeys === []) {
test()->markTestSkipped('No configured required permissions found.');
}
foreach (array_slice($permissionKeys, 1) as $key) {
TenantPermission::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'permission_key' => $key,
'status' => 'granted',
'details' => ['source' => 'db'],
'last_checked_at' => now(),
]);
}
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Browser next-step connection',
'is_default' => true,
'status' => 'connected',
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'entra_tenant_name' => (string) $tenant->name,
],
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'provider.connection.preflight',
'title' => 'Provider connection preflight',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => 'provider_permission_missing',
'message' => 'Provider connection requires admin consent before use.',
'evidence' => [],
'next_steps' => [
[
'label' => 'Grant admin consent',
'url' => 'https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent',
],
[
'label' => 'Review platform connection',
'url' => route('filament.admin.resources.provider-connections.edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()]),
],
],
],
]),
],
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'verify',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
]);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]))
->assertNoJavaScriptErrors()
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->click('Verify access')
->waitForText('Grant admin consent')
->click('Grant admin consent')
->waitForText('Required permissions assist')
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->assertSee('Open full page')
->assertSee('Review platform connection');
});

View File

@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantPermission;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
function assistConfiguredPermissionKeys(): array
{
$configured = array_merge(
config('intune_permissions.permissions', []),
config('entra_permissions.permissions', []),
);
return array_values(array_filter(array_map(static function (mixed $permission): ?string {
if (! is_array($permission)) {
return null;
}
$key = $permission['key'] ?? null;
return is_string($key) && trim($key) !== '' ? trim($key) : null;
}, $configured)));
}
function seedAssistPermissionInventory(
Tenant $tenant,
?string $missingKey = null,
?int $staleDays = null,
array $errorKeys = [],
): void {
foreach (assistConfiguredPermissionKeys() as $key) {
if ($missingKey !== null && $key === $missingKey) {
continue;
}
TenantPermission::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'permission_key' => $key,
'status' => in_array($key, $errorKeys, true) ? 'error' : 'granted',
'details' => in_array($key, $errorKeys, true) ? ['reason_code' => 'permission_denied'] : ['source' => 'db'],
'last_checked_at' => $staleDays === null ? now() : now()->subDays($staleDays),
]);
}
}
/**
* @return array{0:User,1:Tenant,2:\App\Models\TenantOnboardingSession,3:ProviderConnection,4:OperationRun,5:?string}
*/
function createVerificationAssistDraft(
string $state = 'blocked',
string $workspaceRole = 'owner',
string $tenantRole = 'owner',
bool $staleVerificationRun = false,
): array {
[$user, $tenant] = createUserWithTenant(
role: $tenantRole,
workspaceRole: $workspaceRole,
ensureDefaultMicrosoftProviderConnection: false,
);
$workspace = $tenant->workspace()->firstOrFail();
$verifiedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Verified connection',
'is_default' => true,
'status' => 'connected',
]);
$selectedConnection = $verifiedConnection;
if ($staleVerificationRun) {
$selectedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'dummy',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Current selected connection',
'is_default' => false,
'status' => 'connected',
]);
}
$allPermissionKeys = assistConfiguredPermissionKeys();
$missingKey = $allPermissionKeys[0] ?? null;
$errorKeys = [];
$staleDays = null;
$check = [];
$outcome = OperationRunOutcome::Succeeded->value;
if ($state === 'blocked') {
seedAssistPermissionInventory($tenant, missingKey: $missingKey);
$check = [
'key' => 'permissions.admin_consent',
'title' => 'Required application permissions',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
'message' => 'Missing required Graph permissions.',
'evidence' => [],
'next_steps' => [],
];
$outcome = OperationRunOutcome::Blocked->value;
} elseif ($state === 'needs_attention') {
seedAssistPermissionInventory($tenant, staleDays: 45);
$check = [
'key' => 'permissions.admin_consent',
'title' => 'Required application permissions',
'status' => 'warn',
'severity' => 'medium',
'blocking' => false,
'reason_code' => ProviderReasonCodes::ProviderPermissionRefreshFailed,
'message' => 'Stored permission data needs review.',
'evidence' => [],
'next_steps' => [],
];
} elseif ($state === 'degraded') {
$errorKeys = array_slice($allPermissionKeys, 0, 1);
seedAssistPermissionInventory($tenant, staleDays: 45, errorKeys: $errorKeys);
$check = [
'key' => 'permissions.admin_consent',
'title' => 'Required application permissions',
'status' => 'warn',
'severity' => 'medium',
'blocking' => false,
'reason_code' => ProviderReasonCodes::ProviderPermissionRefreshFailed,
'message' => 'Stored permission data needs review.',
'evidence' => [],
'next_steps' => [],
];
} else {
seedAssistPermissionInventory($tenant);
$check = [
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => 'pass',
'severity' => 'info',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Connection is healthy.',
'evidence' => [],
'next_steps' => [],
];
}
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => $outcome,
'context' => [
'provider_connection_id' => (int) $verifiedConnection->getKey(),
'target_scope' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'entra_tenant_name' => (string) $tenant->name,
],
'verification_report' => VerificationReportWriter::build('provider.connection.check', [$check]),
],
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'verify',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $selectedConnection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
return [$user, $tenant, $draft, $selectedConnection, $run, $missingKey];
}
it('shows the assist trigger for blocked and needs-attention states and hides it when verification is ready', function (string $state, bool $shouldSeeTrigger): void {
[$user, , $draft] = createVerificationAssistDraft($state);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $user->last_workspace_id])
->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]))
->assertSuccessful()
->when(
$shouldSeeTrigger,
fn ($response) => $response->assertSee('View required permissions')->assertSee('Required permissions assist'),
fn ($response) => $response->assertDontSee('View required permissions'),
);
})->with([
'blocked' => ['blocked', true],
'needs attention' => ['needs_attention', true],
'ready' => ['ready', false],
]);
it('opens and closes the assist slideover without changing the verify step', function (): void {
[$user, , $draft] = createVerificationAssistDraft('blocked');
session()->put(WorkspaceContext::SESSION_KEY, (int) $user->last_workspace_id);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()])
->assertWizardCurrentStep(4)
->mountAction('wizardVerificationRequiredPermissionsAssist')
->assertMountedActionModalSee('Required permissions assist')
->assertMountedActionModalSee('Open full page')
->unmountAction()
->assertWizardCurrentStep(4);
});
it('renders summary metadata and missing application permissions in the assist slideover', function (): void {
[$user, , $draft, , , $missingKey] = createVerificationAssistDraft('blocked');
session()->put(WorkspaceContext::SESSION_KEY, (int) $user->last_workspace_id);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()])
->mountAction('wizardVerificationRequiredPermissionsAssist')
->assertMountedActionModalSee('Missing application permissions')
->assertMountedActionModalSee('Copy missing application permissions')
->assertMountedActionModalSee((string) $missingKey)
->assertMountedActionModalDontSee('Copy missing delegated permissions');
});
it('renders degraded fallback and hides unavailable copy actions when stored detail is incomplete', function (): void {
[$user, , $draft] = createVerificationAssistDraft('degraded', staleVerificationRun: true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $user->last_workspace_id);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()])
->mountAction('wizardVerificationRequiredPermissionsAssist')
->assertMountedActionModalSee('Verification result is stale')
->assertMountedActionModalSee('Stored permission data needs refresh')
->assertMountedActionModalSee('Compact detail is incomplete')
->assertMountedActionModalDontSee('Copy missing application permissions')
->assertMountedActionModalDontSee('Copy missing delegated permissions');
});
it('returns 404 for workspace members who are out of scope for the tenant assist surface', function (): void {
[$authorizedUser, $tenant, $draft] = createVerificationAssistDraft('blocked');
$workspace = $tenant->workspace()->firstOrFail();
$outOfScopeUser = User::factory()->create();
WorkspaceMembership::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $outOfScopeUser->getKey(),
'role' => 'owner',
]);
$this->actingAs($outOfScopeUser)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]))
->assertNotFound();
});

View File

@ -3,7 +3,9 @@
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
@ -184,3 +186,105 @@
->mountAction('wizardVerificationTechnicalDetails')
->assertSuccessful();
});
it('routes permission-related verification next steps through the required permissions assist', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
'external_id' => 'tenant-clusters-c',
'status' => 'onboarding',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
]);
$verificationReport = VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'provider.connection.preflight',
'title' => 'Required application permissions',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => 'provider_permission_missing',
'message' => 'Missing required application permissions.',
'evidence' => [],
'next_steps' => [
[
'label' => 'Grant admin consent',
'url' => 'https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent',
],
[
'label' => 'Review platform connection',
'url' => ProviderConnectionResource::getUrl(
'edit',
['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()],
panel: 'admin',
),
],
],
],
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'target_scope' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'entra_tenant_name' => (string) $tenant->name,
],
'verification_report' => $verificationReport,
],
]);
TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => (string) $tenant->tenant_id,
'current_step' => 'verify',
'state' => [
'verification_operation_run_id' => (int) $run->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user)
->followingRedirects()
->get('/admin/onboarding')
->assertSuccessful()
->assertSee('Grant admin consent')
->assertSee('Review platform connection')
->assertSee('Open in assist')
->assertSee('data-testid="verification-next-step-grant-admin-consent"', false)
->assertSee('data-testid="verification-next-step-review-platform-connection"', false)
->assertSee('wire:click="mountAction(\'wizardVerificationRequiredPermissionsAssist\')"', false)
->assertDontSee('href="https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent"', false)
->assertDontSee(ProviderConnectionResource::getUrl(
'edit',
['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()],
panel: 'admin',
), false);
});

View File

@ -7,9 +7,14 @@
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\TenantPermission;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
@ -111,4 +116,99 @@
Queue::assertNothingPushed();
});
it('returns 403 for in-scope members missing onboarding capability on assist-backed drafts', function (): void {
$configured = array_merge(
config('intune_permissions.permissions', []),
config('entra_permissions.permissions', []),
);
$permissionKeys = array_values(array_filter(array_map(static function (mixed $permission): ?string {
if (! is_array($permission)) {
return null;
}
$key = $permission['key'] ?? null;
return is_string($key) && trim($key) !== '' ? trim($key) : null;
}, $configured)));
if ($permissionKeys === []) {
test()->markTestSkipped('No configured required permissions found.');
}
[$user, $tenant] = createUserWithTenant(
role: 'operator',
workspaceRole: 'operator',
ensureDefaultMicrosoftProviderConnection: false,
);
$workspace = $tenant->workspace()->firstOrFail();
foreach ($permissionKeys as $key) {
TenantPermission::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'permission_key' => $key,
'status' => 'granted',
'details' => ['source' => 'db'],
'last_checked_at' => now()->subDays(45),
]);
}
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'is_default' => true,
'status' => 'connected',
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'entra_tenant_name' => (string) $tenant->name,
],
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'permissions.admin_consent',
'title' => 'Required application permissions',
'status' => 'warn',
'severity' => 'medium',
'blocking' => false,
'reason_code' => ProviderReasonCodes::ProviderPermissionRefreshFailed,
'message' => 'Stored permission data needs review.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
$draft = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => (string) $tenant->tenant_id,
'current_step' => 'verify',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]))
->assertForbidden();
});
});

View File

@ -0,0 +1,340 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Verification\VerificationAssistViewModelBuilder;
use App\Support\Verification\VerificationReportOverall;
use App\Support\Verification\VerificationReportWriter;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('derives blocked assist visibility and action availability from the stored diagnostics', function (): void {
$tenant = Tenant::factory()->create([
'external_id' => 'tenant-assist-blocked-a',
'name' => 'Blocked Tenant',
]);
$permissionsBuilder = Mockery::mock(TenantRequiredPermissionsViewModelBuilder::class);
$permissionsBuilder
->shouldReceive('build')
->twice()
->withArgs(function (Tenant $passedTenant, array $filters) use ($tenant): bool {
return (int) $passedTenant->getKey() === (int) $tenant->getKey()
&& ($filters['status'] ?? null) === 'all'
&& ($filters['type'] ?? null) === 'all'
&& ($filters['features'] ?? null) === []
&& ($filters['search'] ?? null) === '';
})
->andReturn([
'tenant' => [
'id' => (int) $tenant->getKey(),
'external_id' => (string) $tenant->external_id,
'name' => (string) $tenant->name,
],
'overview' => [
'overall' => VerificationReportOverall::Blocked->value,
'counts' => [
'missing_application' => 1,
'missing_delegated' => 1,
'present' => 3,
'error' => 0,
],
'freshness' => [
'last_refreshed_at' => now()->toIso8601String(),
'is_stale' => false,
],
],
'permissions' => [
[
'key' => 'DeviceManagementConfiguration.Read.All',
'type' => 'application',
'description' => 'Read device configurations',
'features' => ['inventory'],
'status' => 'missing',
'details' => null,
],
[
'key' => 'User.Read.All',
'type' => 'delegated',
'description' => 'Read users',
'features' => ['directory'],
'status' => 'missing',
'details' => null,
],
[
'key' => 'Group.Read.All',
'type' => 'application',
'description' => 'Read groups',
'features' => ['directory'],
'status' => 'granted',
'details' => null,
],
],
'copy' => [
'application' => "DeviceManagementConfiguration.Read.All\nPolicy.Read.All",
'delegated' => 'User.Read.All',
],
]);
$builder = new VerificationAssistViewModelBuilder(
$permissionsBuilder,
app(ProviderNextStepsRegistry::class),
);
$report = VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'permissions.admin_consent',
'title' => 'Required application permissions',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => ProviderReasonCodes::ProviderConsentMissing,
'message' => 'Grant admin consent before continuing.',
'evidence' => [],
'next_steps' => [],
],
]);
$visibility = $builder->visibility($tenant, $report);
$assist = $builder->build(
tenant: $tenant,
verificationReport: $report,
verificationStatus: 'blocked',
isVerificationStale: false,
staleReason: null,
canAccessProviderConnectionDiagnostics: true,
);
expect($visibility)->toMatchArray([
'is_visible' => true,
'reason' => 'permission_blocked',
]);
expect($assist)->toMatchArray([
'tenant' => [
'id' => (int) $tenant->getKey(),
'external_id' => (string) $tenant->external_id,
'name' => (string) $tenant->name,
],
'verification' => [
'overall' => VerificationReportOverall::Blocked->value,
'status' => 'blocked',
'is_stale' => false,
'stale_reason' => null,
],
'copy' => [
'application' => "DeviceManagementConfiguration.Read.All\nPolicy.Read.All",
'delegated' => 'User.Read.All',
],
]);
expect($assist['overview'])->toMatchArray([
'overall' => VerificationReportOverall::Blocked->value,
'counts' => [
'missing_application' => 1,
'missing_delegated' => 1,
'present' => 3,
'error' => 0,
],
]);
expect($assist['overview']['freshness']['is_stale'])->toBeFalse();
expect($assist['missing_permissions']['application'])->toHaveCount(1)
->and($assist['missing_permissions']['delegated'])->toHaveCount(1)
->and($assist['actions']['full_page'])->toMatchArray([
'label' => 'Open full page',
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
'opens_in_new_tab' => true,
'available' => true,
'is_secondary' => true,
])
->and($assist['actions']['copy_application'])->toMatchArray([
'label' => 'Copy missing application permissions',
'available' => true,
])
->and($assist['actions']['copy_delegated'])->toMatchArray([
'label' => 'Copy missing delegated permissions',
'available' => true,
])
->and($assist['actions']['grant_admin_consent'])->toMatchArray([
'label' => 'Grant admin consent',
'url' => RequiredPermissionsLinks::adminConsentGuideUrl(),
'opens_in_new_tab' => true,
'available' => true,
])
->and($assist['fallback'])->toMatchArray([
'has_incomplete_detail' => false,
'message' => null,
]);
expect($assist['actions']['manage_provider_connection'])->toMatchArray([
'label' => 'Manage Provider Connections',
'opens_in_new_tab' => true,
'available' => true,
]);
expect((string) $assist['actions']['manage_provider_connection']['url'])->toContain('/admin/provider-connections');
});
it('hides the assist when the verification report is ready and permission diagnostics are healthy', function (): void {
$tenant = Tenant::factory()->create([
'external_id' => 'tenant-assist-ready-a',
]);
$permissionsBuilder = Mockery::mock(TenantRequiredPermissionsViewModelBuilder::class);
$permissionsBuilder
->shouldReceive('build')
->once()
->andReturn([
'tenant' => [
'id' => (int) $tenant->getKey(),
'external_id' => (string) $tenant->external_id,
'name' => (string) $tenant->name,
],
'overview' => [
'overall' => VerificationReportOverall::Ready->value,
'counts' => [
'missing_application' => 0,
'missing_delegated' => 0,
'present' => 15,
'error' => 0,
],
'freshness' => [
'last_refreshed_at' => now()->toIso8601String(),
'is_stale' => false,
],
],
'permissions' => [],
'copy' => [
'application' => '',
'delegated' => '',
],
]);
$builder = new VerificationAssistViewModelBuilder(
$permissionsBuilder,
app(ProviderNextStepsRegistry::class),
);
$report = VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => 'pass',
'severity' => 'info',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Connection is healthy.',
'evidence' => [],
'next_steps' => [],
],
]);
expect($builder->visibility($tenant, $report))->toMatchArray([
'is_visible' => false,
'reason' => 'hidden_ready',
]);
});
it('builds degraded fallback messaging when permission detail is stale or incomplete', function (): void {
$tenant = Tenant::factory()->create([
'external_id' => 'tenant-assist-degraded-a',
]);
$staleAt = CarbonImmutable::now()->subDays(45)->toIso8601String();
$permissionsBuilder = Mockery::mock(TenantRequiredPermissionsViewModelBuilder::class);
$permissionsBuilder
->shouldReceive('build')
->twice()
->andReturn([
'tenant' => [
'id' => (int) $tenant->getKey(),
'external_id' => (string) $tenant->external_id,
'name' => (string) $tenant->name,
],
'overview' => [
'overall' => VerificationReportOverall::NeedsAttention->value,
'counts' => [
'missing_application' => 0,
'missing_delegated' => 0,
'present' => 14,
'error' => 1,
],
'freshness' => [
'last_refreshed_at' => $staleAt,
'is_stale' => true,
],
],
'permissions' => [
[
'key' => 'DeviceManagementManagedDevices.Read.All',
'type' => 'application',
'description' => 'Stored row is incomplete',
'features' => ['inventory'],
'status' => 'error',
'details' => ['reason_code' => 'permission_denied'],
],
],
'copy' => [
'application' => '',
'delegated' => '',
],
]);
$builder = new VerificationAssistViewModelBuilder(
$permissionsBuilder,
app(ProviderNextStepsRegistry::class),
);
$report = VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'permissions.admin_consent',
'title' => 'Required application permissions',
'status' => 'warn',
'severity' => 'medium',
'blocking' => false,
'reason_code' => ProviderReasonCodes::ProviderPermissionRefreshFailed,
'message' => 'Stored permission data needs review.',
'evidence' => [],
'next_steps' => [],
],
]);
$visibility = $builder->visibility($tenant, $report);
$assist = $builder->build(
tenant: $tenant,
verificationReport: $report,
verificationStatus: 'needs_attention',
isVerificationStale: true,
staleReason: 'The selected provider connection has changed since this verification run.',
canAccessProviderConnectionDiagnostics: false,
);
expect($visibility)->toMatchArray([
'is_visible' => true,
'reason' => 'permission_attention',
]);
expect($assist['verification'])->toMatchArray([
'overall' => VerificationReportOverall::NeedsAttention->value,
'status' => 'needs_attention',
'is_stale' => true,
'stale_reason' => 'The selected provider connection has changed since this verification run.',
]);
expect($assist['actions']['copy_application']['available'])->toBeFalse()
->and($assist['actions']['copy_delegated']['available'])->toBeFalse()
->and($assist['actions']['grant_admin_consent']['available'])->toBeFalse()
->and($assist['actions']['manage_provider_connection']['available'])->toBeFalse()
->and($assist['fallback']['has_incomplete_detail'])->toBeTrue()
->and($assist['fallback']['message'])->not->toBeNull();
});

View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Verification\VerificationLinkBehavior;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('classifies external remediation links as external deep dives', function (): void {
$behavior = app(VerificationLinkBehavior::class)->describe(
label: 'Grant admin consent',
url: 'https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent',
);
expect($behavior)->toMatchArray([
'label' => 'Grant admin consent',
'url' => 'https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent',
'kind' => 'external',
'opens_in_new_tab' => true,
'show_new_tab_hint' => true,
]);
});
it('classifies required permissions links as internal diagnostic deep dives', function (): void {
$tenant = Tenant::factory()->create([
'external_id' => 'tenant-required-permissions-a',
]);
$behavior = app(VerificationLinkBehavior::class)->describe(
label: 'Open required permissions',
url: RequiredPermissionsLinks::requiredPermissions($tenant),
);
expect($behavior)->toMatchArray([
'kind' => 'internal-diagnostic',
'opens_in_new_tab' => true,
'show_new_tab_hint' => true,
]);
});
it('classifies provider connection management routes as internal diagnostic deep dives', function (): void {
$tenant = Tenant::factory()->create([
'external_id' => 'tenant-provider-connections-a',
]);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
]);
$behavior = app(VerificationLinkBehavior::class)->describe(
label: 'Manage Provider Connections',
url: ProviderConnectionResource::getUrl(
'edit',
['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()],
panel: 'admin',
),
);
expect($behavior)->toMatchArray([
'kind' => 'internal-diagnostic',
'opens_in_new_tab' => true,
'show_new_tab_hint' => true,
]);
});
it('leaves inline-safe onboarding links in the current tab', function (): void {
$behavior = app(VerificationLinkBehavior::class)->describe(
label: 'Return to onboarding',
url: '/admin/onboarding',
);
expect($behavior)->toMatchArray([
'kind' => 'internal-inline-safe',
'opens_in_new_tab' => false,
'show_new_tab_hint' => false,
]);
});
it('routes permission-related verification report checks through the assist when it is available', function (): void {
$behavior = app(VerificationLinkBehavior::class);
expect($behavior->shouldRouteThroughAssist([
'key' => 'provider.connection.preflight',
'reason_code' => ProviderReasonCodes::ProviderConsentMissing,
], true))->toBeTrue()
->and($behavior->shouldRouteThroughAssist([
'key' => 'permissions.admin_consent',
'reason_code' => 'ok',
], true))->toBeTrue()
->and($behavior->shouldRouteThroughAssist([
'key' => 'provider.connection.preflight',
'reason_code' => ProviderReasonCodes::ProviderConsentMissing,
], false))->toBeFalse();
});