feat: provider connections resolution guidance v1 (spec 353) (#424)
Implemented the first version of provider readiness resolution guidance. Added the ProviderReadinessResolutionAdapter, provider readiness guidance card, and updated EnvironmentRequiredPermissions, ProviderConnectionResource, and ListProviderConnections/ViewProviderConnection. Added tests and updated the design coverage matrix. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #424
This commit is contained in:
parent
9a564d6bf2
commit
d2876af95b
@ -6,12 +6,19 @@
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\ManagedEnvironmentLinks;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||
use App\Support\ResolutionGuidance\Adapters\ProviderReadinessResolutionAdapter;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
@ -223,6 +230,65 @@ public function manageProviderConnectionUrl(): ?string
|
||||
return ManagedEnvironmentLinks::providerConnectionsUrl($tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function guidanceCase(): array
|
||||
{
|
||||
$tenant = $this->trustedScopedTenant();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return app(ProviderReadinessResolutionAdapter::class)
|
||||
->forEnvironment($tenant, ProviderReadinessResolutionAdapter::SURFACE_REQUIRED_PERMISSIONS);
|
||||
}
|
||||
|
||||
public function canRunProviderVerification(): bool
|
||||
{
|
||||
$tenant = $this->trustedScopedTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $capabilityResolver */
|
||||
$capabilityResolver = app(CapabilityResolver::class);
|
||||
|
||||
return $capabilityResolver->isMember($user, $tenant)
|
||||
&& $capabilityResolver->can($user, $tenant, Capabilities::PROVIDER_RUN);
|
||||
}
|
||||
|
||||
public function runProviderVerification(): void
|
||||
{
|
||||
$tenant = $this->trustedScopedTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$result = app(StartVerification::class)->providerConnectionCheckForTenant(
|
||||
tenant: $tenant,
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'surface' => 'required_permissions.guidance',
|
||||
],
|
||||
);
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
app(ProviderOperationStartResultPresenter::class)
|
||||
->notification(
|
||||
result: $result,
|
||||
blockedTitle: __('localization.provider_guidance.verification_blocked_notification_title'),
|
||||
runUrl: OperationRunLinks::view($result->run, $tenant),
|
||||
)
|
||||
->send();
|
||||
}
|
||||
|
||||
protected static function resolveScopedTenant(ManagedEnvironment|string|null $tenant = null): ?ManagedEnvironment
|
||||
{
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\ResolutionGuidance\Adapters\ProviderReadinessResolutionAdapter;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
@ -103,7 +104,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Edit and provider operations are grouped under "More" while clickable-row view remains primary.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Provider connections intentionally omit bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines empty-state guidance and CTA.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page keeps one primary consent CTA while shared connection actions remain grouped under "More".');
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page keeps one primary readiness CTA while shared connection actions remain grouped under "More".');
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
@ -635,6 +636,27 @@ private static function providerCapabilitiesLine(?ProviderConnection $record): s
|
||||
->implode("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function providerReadinessGuidance(
|
||||
?ProviderConnection $record,
|
||||
string $surface = ProviderReadinessResolutionAdapter::SURFACE_PROVIDER_CONNECTIONS_VIEW,
|
||||
): array {
|
||||
if (! $record instanceof ProviderConnection) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return app(ProviderReadinessResolutionAdapter::class)
|
||||
->forConnection($tenant, $record, $surface);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extra
|
||||
* @return array<string, mixed>
|
||||
@ -745,6 +767,18 @@ public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make(__('localization.provider_guidance.provider_readiness_section'))
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('provider_readiness_guidance')
|
||||
->hiddenLabel()
|
||||
->view('filament.infolists.entries.provider-readiness-guidance')
|
||||
->state(fn (ProviderConnection $record): array => static::providerReadinessGuidance(
|
||||
$record,
|
||||
ProviderReadinessResolutionAdapter::SURFACE_PROVIDER_CONNECTIONS_VIEW,
|
||||
))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Connection')
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('display_name')
|
||||
|
||||
@ -8,6 +8,8 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\ManagedEnvironmentLinks;
|
||||
use App\Support\ResolutionGuidance\Adapters\ProviderReadinessResolutionAdapter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
@ -139,6 +141,18 @@ public function content(Schema $schema): Schema
|
||||
'clearUrl' => $this->environmentFilterChip()['clear_url'] ?? null,
|
||||
])
|
||||
->visible(fn (): bool => $this->environmentFilterChip() !== null),
|
||||
View::make('filament.widgets.provider-connections.provider-readiness-guidance')
|
||||
->viewData(function (): array {
|
||||
$guidance = $this->readinessGuidance();
|
||||
|
||||
return [
|
||||
'guidance' => $guidance,
|
||||
'inlinePrimaryAction' => is_string(data_get($guidance, 'primary_action.url'))
|
||||
&& trim((string) data_get($guidance, 'primary_action.url')) !== '',
|
||||
'primaryActionMethod' => null,
|
||||
];
|
||||
})
|
||||
->visible(fn (): bool => is_array($this->readinessGuidance()) && $this->readinessGuidance() !== []),
|
||||
RenderHook::make(PanelsRenderHook::RESOURCE_PAGES_LIST_RECORDS_TABLE_BEFORE),
|
||||
EmbeddedTable::make(),
|
||||
RenderHook::make(PanelsRenderHook::RESOURCE_PAGES_LIST_RECORDS_TABLE_AFTER),
|
||||
@ -247,22 +261,12 @@ private function resolveTenantExternalIdForCreateAction(): ?string
|
||||
|
||||
private function applyRequestedEnvironmentFilter(): void
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$environment = ProviderConnectionResource::resolveRequestedEnvironment();
|
||||
$environment = $this->requestedEnvironmentInCurrentWorkspace();
|
||||
|
||||
if (! $environment instanceof ManagedEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $environment->workspace_id !== $workspaceId) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$slug = (string) $environment->slug;
|
||||
|
||||
if ($slug === '') {
|
||||
@ -278,7 +282,7 @@ private function applyRequestedEnvironmentFilter(): void
|
||||
*/
|
||||
private function environmentFilterChip(): ?array
|
||||
{
|
||||
$environment = ProviderConnectionResource::resolveRequestedEnvironment();
|
||||
$environment = $this->requestedEnvironmentInCurrentWorkspace();
|
||||
|
||||
if (! $environment instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
@ -303,13 +307,118 @@ private function resolveTenantForCreateAction(): ?ManagedEnvironment
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function readinessGuidance(): ?array
|
||||
{
|
||||
$environment = $this->currentFilteredEnvironment();
|
||||
|
||||
if (! $environment instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$guidance = app(ProviderReadinessResolutionAdapter::class)
|
||||
->forEnvironment($environment, ProviderReadinessResolutionAdapter::SURFACE_PROVIDER_CONNECTIONS_INDEX);
|
||||
|
||||
if (($guidance['key'] ?? null) === 'provider_readiness.connection_missing') {
|
||||
$guidance['primary_action'] = $this->canManageProviderConnections($environment)
|
||||
? [
|
||||
'label' => 'Create provider connection',
|
||||
'url' => ProviderConnectionResource::getUrl('create', [
|
||||
'environment_id' => (int) $environment->getKey(),
|
||||
]),
|
||||
'action_name' => null,
|
||||
'external' => false,
|
||||
'disabled' => false,
|
||||
]
|
||||
: [
|
||||
'label' => __('localization.provider_guidance.action_open_environment_dashboard'),
|
||||
'url' => ManagedEnvironmentLinks::viewUrl($environment),
|
||||
'action_name' => null,
|
||||
'external' => false,
|
||||
'disabled' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $guidance;
|
||||
}
|
||||
|
||||
private function canManageProviderConnections(?ManagedEnvironment $environment): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $environment instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $environment)
|
||||
&& $resolver->can($user, $environment, Capabilities::PROVIDER_MANAGE);
|
||||
}
|
||||
|
||||
private function currentFilteredEnvironment(): ?ManagedEnvironment
|
||||
{
|
||||
$requestedEnvironment = $this->requestedEnvironmentInCurrentWorkspace();
|
||||
|
||||
if ($requestedEnvironment instanceof ManagedEnvironment) {
|
||||
return $requestedEnvironment;
|
||||
}
|
||||
|
||||
$filterValue = data_get($this->tableFilters, 'tenant.value');
|
||||
|
||||
if (! is_string($filterValue) || $filterValue === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ManagedEnvironment::query()
|
||||
->where('slug', $filterValue)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function requestedEnvironmentInCurrentWorkspace(): ?ManagedEnvironment
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$user = auth()->user();
|
||||
|
||||
if (! is_int($workspaceId) || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$environment = ProviderConnectionResource::resolveRequestedEnvironment();
|
||||
|
||||
if (! $environment instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $environment->workspace_id !== $workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($environment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $environment;
|
||||
}
|
||||
|
||||
public function getTableEmptyStateHeading(): ?string
|
||||
{
|
||||
if ($this->currentFilteredEnvironment() instanceof ManagedEnvironment) {
|
||||
return __('localization.provider_guidance.connection_missing_title');
|
||||
}
|
||||
|
||||
return 'No provider connections found';
|
||||
}
|
||||
|
||||
public function getTableEmptyStateDescription(): ?string
|
||||
{
|
||||
if ($this->currentFilteredEnvironment() instanceof ManagedEnvironment) {
|
||||
return __('localization.provider_guidance.connection_missing_impact');
|
||||
}
|
||||
|
||||
return 'Start with a platform-managed provider connection. Dedicated overrides are handled separately with stronger authorization.';
|
||||
}
|
||||
|
||||
|
||||
@ -2,12 +2,16 @@
|
||||
|
||||
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\ManagedEnvironmentLinks;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\ResolutionGuidance\Adapters\ProviderReadinessResolutionAdapter;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
@ -18,35 +22,24 @@ class ViewProviderConnection extends ViewRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$tenant = $this->currentTenant();
|
||||
$primaryAction = $this->primaryReadinessHeaderAction($tenant);
|
||||
$primaryActionName = $primaryAction?->getName();
|
||||
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('grant_admin_consent')
|
||||
->label('Grant admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(function () use ($tenant): ?string {
|
||||
return $tenant instanceof ManagedEnvironment
|
||||
? RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant)
|
||||
: null;
|
||||
})
|
||||
->visible(fn (): bool => $tenant instanceof ManagedEnvironment)
|
||||
->openUrlInNewTab()
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
Actions\ActionGroup::make($this->sharedConnectionActions())
|
||||
return array_values(array_filter([
|
||||
$primaryAction,
|
||||
Actions\ActionGroup::make($this->sharedConnectionActions($primaryActionName))
|
||||
->label('More')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
->color('gray'),
|
||||
];
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Actions\Action>
|
||||
*/
|
||||
private function sharedConnectionActions(): array
|
||||
private function sharedConnectionActions(?string $primaryActionName = null): array
|
||||
{
|
||||
return [
|
||||
$actions = [
|
||||
ProviderConnectionResource::makeEditNavigationAction(),
|
||||
ProviderConnectionResource::makeCheckConnectionAction(),
|
||||
ProviderConnectionResource::makeInventorySyncAction(),
|
||||
@ -62,6 +55,77 @@ private function sharedConnectionActions(): array
|
||||
ProviderConnectionResource::makeEnableConnectionAction(),
|
||||
ProviderConnectionResource::makeDisableConnectionAction(),
|
||||
];
|
||||
|
||||
if (! is_string($primaryActionName) || $primaryActionName === '') {
|
||||
return $actions;
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$actions,
|
||||
static fn (Actions\Action $action): bool => $action->getName() !== $primaryActionName,
|
||||
));
|
||||
}
|
||||
|
||||
private function primaryReadinessHeaderAction(?ManagedEnvironment $tenant): ?Actions\Action
|
||||
{
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $this->record instanceof ProviderConnection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$guidance = app(ProviderReadinessResolutionAdapter::class)
|
||||
->forConnection($tenant, $this->record, ProviderReadinessResolutionAdapter::SURFACE_PROVIDER_CONNECTIONS_VIEW);
|
||||
$primaryAction = is_array($guidance['primary_action'] ?? null) ? $guidance['primary_action'] : [];
|
||||
$guidanceKey = (string) ($guidance['key'] ?? '');
|
||||
|
||||
return match ($guidanceKey) {
|
||||
'provider_readiness.admin_consent_required' => UiEnforcement::forAction(
|
||||
Actions\Action::make('grant_admin_consent')
|
||||
->label((string) ($primaryAction['label'] ?? 'Grant admin consent'))
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(function () use ($tenant): ?string {
|
||||
return RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant);
|
||||
})
|
||||
->visible(fn (): bool => true)
|
||||
->openUrlInNewTab()
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||
->apply(),
|
||||
'provider_readiness.required_permissions_missing',
|
||||
'provider_readiness.delegated_permissions_missing' => Actions\Action::make('open_required_permissions')
|
||||
->label((string) ($primaryAction['label'] ?? 'Open required permissions'))
|
||||
->icon('heroicon-o-key')
|
||||
->url($primaryAction['url'] ?? RequiredPermissionsLinks::requiredPermissions($tenant)),
|
||||
'provider_readiness.verification_required' => ProviderConnectionResource::makeCheckConnectionAction()
|
||||
->label((string) ($primaryAction['label'] ?? 'Run provider verification')),
|
||||
'provider_readiness.verification_failed' => $this->latestVerificationRun($tenant) instanceof OperationRun
|
||||
? Actions\Action::make('open_verification_operation')
|
||||
->label((string) ($primaryAction['label'] ?? 'Open verification operation'))
|
||||
->icon('heroicon-o-eye')
|
||||
->url(OperationRunLinks::view($this->latestVerificationRun($tenant), $tenant))
|
||||
: ProviderConnectionResource::makeCheckConnectionAction()
|
||||
->label('Run provider verification'),
|
||||
'provider_readiness.connection_review_required' => ProviderConnectionResource::makeEditNavigationAction()
|
||||
->label((string) ($primaryAction['label'] ?? 'Edit provider connection')),
|
||||
default => Actions\Action::make('open_environment_dashboard')
|
||||
->label((string) ($primaryAction['label'] ?? 'Open environment dashboard'))
|
||||
->icon('heroicon-o-home')
|
||||
->url(ManagedEnvironmentLinks::viewUrl($tenant)),
|
||||
};
|
||||
}
|
||||
|
||||
private function latestVerificationRun(ManagedEnvironment $tenant): ?OperationRun
|
||||
{
|
||||
if (! $this->record instanceof ProviderConnection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return OperationRun::query()
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('type', 'provider.connection.check')
|
||||
->where('context->provider_connection_id', (int) $this->record->getKey())
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function currentTenant(): ?ManagedEnvironment
|
||||
|
||||
@ -0,0 +1,764 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\ResolutionGuidance\Adapters;
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
|
||||
use App\Services\Providers\ProviderConnectionResolution;
|
||||
use App\Services\Providers\ProviderConnectionResolver;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\ManagedEnvironmentLinks;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class ProviderReadinessResolutionAdapter
|
||||
{
|
||||
public const string SURFACE_PROVIDER_CONNECTIONS_INDEX = 'provider_connections.index';
|
||||
|
||||
public const string SURFACE_PROVIDER_CONNECTIONS_VIEW = 'provider_connections.view';
|
||||
|
||||
public const string SURFACE_PROVIDER_CONNECTIONS_EDIT = 'provider_connections.edit';
|
||||
|
||||
public const string SURFACE_REQUIRED_PERMISSIONS = 'required_permissions.page';
|
||||
|
||||
public function __construct(
|
||||
private readonly ManagedEnvironmentRequiredPermissionsViewModelBuilder $requiredPermissions,
|
||||
private readonly ProviderConnectionResolver $connections,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function forEnvironment(
|
||||
ManagedEnvironment $environment,
|
||||
string $surface = self::SURFACE_PROVIDER_CONNECTIONS_INDEX,
|
||||
): array {
|
||||
$resolution = $this->connections->resolveDefault($environment, 'microsoft');
|
||||
$connection = $resolution->connection;
|
||||
|
||||
return $this->buildCase(
|
||||
environment: $environment,
|
||||
connection: $connection,
|
||||
resolution: $resolution,
|
||||
permissions: $this->permissionSignals($environment),
|
||||
latestVerificationRun: $connection instanceof ProviderConnection
|
||||
? $this->latestVerificationRun($environment, $connection)
|
||||
: null,
|
||||
surface: $surface,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function forConnection(
|
||||
ManagedEnvironment $environment,
|
||||
ProviderConnection $connection,
|
||||
string $surface = self::SURFACE_PROVIDER_CONNECTIONS_VIEW,
|
||||
): array {
|
||||
$provider = trim((string) $connection->provider) !== ''
|
||||
? trim((string) $connection->provider)
|
||||
: 'microsoft';
|
||||
|
||||
return $this->buildCase(
|
||||
environment: $environment,
|
||||
connection: $connection,
|
||||
resolution: $this->connections->validateConnection($environment, $provider, $connection),
|
||||
permissions: $this->permissionSignals($environment),
|
||||
latestVerificationRun: $this->latestVerificationRun($environment, $connection),
|
||||
surface: $surface,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
|
||||
* freshness: array{last_refreshed_at:?string,is_stale:bool}
|
||||
* } $permissions
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildCase(
|
||||
ManagedEnvironment $environment,
|
||||
?ProviderConnection $connection,
|
||||
ProviderConnectionResolution $resolution,
|
||||
array $permissions,
|
||||
?OperationRun $latestVerificationRun,
|
||||
string $surface,
|
||||
): array {
|
||||
$counts = $permissions['counts'];
|
||||
$freshness = $permissions['freshness'];
|
||||
$missingApplication = (int) ($counts['missing_application'] ?? 0);
|
||||
$missingDelegated = (int) ($counts['missing_delegated'] ?? 0);
|
||||
$errorCount = (int) ($counts['error'] ?? 0);
|
||||
|
||||
if ($resolution->effectiveReasonCode() === ProviderReasonCodes::ProviderConnectionMissing) {
|
||||
return $this->case(
|
||||
key: 'provider_readiness.connection_missing',
|
||||
severity: 'danger',
|
||||
status: __('localization.provider_guidance.status_blocked'),
|
||||
title: __('localization.provider_guidance.connection_missing_title'),
|
||||
reason: __('localization.provider_guidance.connection_missing_reason'),
|
||||
impact: __('localization.provider_guidance.connection_missing_impact'),
|
||||
primaryAction: $this->providerConnectionsAction($environment),
|
||||
secondaryActions: [
|
||||
$this->environmentDashboardAction($environment),
|
||||
],
|
||||
technicalDetails: [
|
||||
$this->technicalDetail(
|
||||
__('localization.provider_guidance.detail_provider_label'),
|
||||
__('localization.provider_guidance.detail_provider_value'),
|
||||
),
|
||||
$this->technicalDetail(
|
||||
__('localization.provider_guidance.detail_verification_state_label'),
|
||||
__('localization.provider_guidance.verification_not_run_detail'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->isConsentBlocker($resolution->effectiveReasonCode())) {
|
||||
return $this->case(
|
||||
key: 'provider_readiness.admin_consent_required',
|
||||
severity: 'danger',
|
||||
status: __('localization.provider_guidance.status_blocked'),
|
||||
title: __('localization.provider_guidance.admin_consent_required_title'),
|
||||
reason: $this->consentReason($resolution->effectiveReasonCode()),
|
||||
impact: __('localization.provider_guidance.admin_consent_required_impact'),
|
||||
primaryAction: $this->adminConsentAction($environment),
|
||||
secondaryActions: $this->secondaryActionsFor(
|
||||
environment: $environment,
|
||||
connection: $connection,
|
||||
latestVerificationRun: $latestVerificationRun,
|
||||
surface: $surface,
|
||||
includeRequiredPermissions: true,
|
||||
),
|
||||
technicalDetails: $this->technicalDetails(
|
||||
connection: $connection,
|
||||
permissions: $permissions,
|
||||
latestVerificationRun: $latestVerificationRun,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (! $resolution->resolved) {
|
||||
return $this->case(
|
||||
key: 'provider_readiness.connection_review_required',
|
||||
severity: 'danger',
|
||||
status: __('localization.provider_guidance.status_blocked'),
|
||||
title: $connection instanceof ProviderConnection && ! (bool) $connection->is_enabled
|
||||
? __('localization.provider_guidance.connection_disabled_title')
|
||||
: __('localization.provider_guidance.connection_review_title'),
|
||||
reason: $connection instanceof ProviderConnection && ! (bool) $connection->is_enabled
|
||||
? __('localization.provider_guidance.connection_disabled_reason')
|
||||
: (trim((string) ($resolution->message ?? '')) !== ''
|
||||
? trim((string) $resolution->message)
|
||||
: __('localization.provider_guidance.connection_review_reason')),
|
||||
impact: $connection instanceof ProviderConnection && ! (bool) $connection->is_enabled
|
||||
? __('localization.provider_guidance.connection_disabled_impact')
|
||||
: __('localization.provider_guidance.connection_review_impact'),
|
||||
primaryAction: $this->providerConnectionReviewAction($environment, $connection, $surface),
|
||||
secondaryActions: $this->secondaryActionsFor(
|
||||
environment: $environment,
|
||||
connection: $connection,
|
||||
latestVerificationRun: $latestVerificationRun,
|
||||
surface: $surface,
|
||||
includeRequiredPermissions: true,
|
||||
),
|
||||
technicalDetails: $this->technicalDetails(
|
||||
connection: $connection,
|
||||
permissions: $permissions,
|
||||
latestVerificationRun: $latestVerificationRun,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ($missingApplication > 0) {
|
||||
return $this->case(
|
||||
key: 'provider_readiness.required_permissions_missing',
|
||||
severity: 'danger',
|
||||
status: __('localization.provider_guidance.status_blocked'),
|
||||
title: $surface === self::SURFACE_REQUIRED_PERMISSIONS
|
||||
? __('localization.provider_guidance.required_permissions_missing_title')
|
||||
: __('localization.provider_guidance.provider_readiness_blocked_title'),
|
||||
reason: __('localization.provider_guidance.required_application_permissions_reason'),
|
||||
impact: __('localization.provider_guidance.required_application_permissions_impact'),
|
||||
primaryAction: $surface === self::SURFACE_REQUIRED_PERMISSIONS
|
||||
? $this->adminConsentAction($environment)
|
||||
: $this->requiredPermissionsAction($environment),
|
||||
secondaryActions: $this->secondaryActionsFor(
|
||||
environment: $environment,
|
||||
connection: $connection,
|
||||
latestVerificationRun: $latestVerificationRun,
|
||||
surface: $surface,
|
||||
includeAdminConsent: $surface !== self::SURFACE_REQUIRED_PERMISSIONS,
|
||||
),
|
||||
technicalDetails: $this->technicalDetails(
|
||||
connection: $connection,
|
||||
permissions: $permissions,
|
||||
latestVerificationRun: $latestVerificationRun,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ($missingDelegated > 0) {
|
||||
return $this->case(
|
||||
key: 'provider_readiness.delegated_permissions_missing',
|
||||
severity: 'warning',
|
||||
status: __('localization.provider_guidance.status_action_required'),
|
||||
title: $surface === self::SURFACE_REQUIRED_PERMISSIONS
|
||||
? __('localization.provider_guidance.required_permissions_missing_title')
|
||||
: __('localization.provider_guidance.provider_readiness_attention_title'),
|
||||
reason: __('localization.provider_guidance.required_delegated_permissions_reason'),
|
||||
impact: __('localization.provider_guidance.required_delegated_permissions_impact'),
|
||||
primaryAction: $surface === self::SURFACE_REQUIRED_PERMISSIONS
|
||||
? $this->adminConsentAction($environment)
|
||||
: $this->requiredPermissionsAction($environment),
|
||||
secondaryActions: $this->secondaryActionsFor(
|
||||
environment: $environment,
|
||||
connection: $connection,
|
||||
latestVerificationRun: $latestVerificationRun,
|
||||
surface: $surface,
|
||||
includeAdminConsent: $surface !== self::SURFACE_REQUIRED_PERMISSIONS,
|
||||
),
|
||||
technicalDetails: $this->technicalDetails(
|
||||
connection: $connection,
|
||||
permissions: $permissions,
|
||||
latestVerificationRun: $latestVerificationRun,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
$verificationState = $this->verificationState($connection);
|
||||
|
||||
if ($errorCount > 0 || in_array($verificationState, [
|
||||
ProviderVerificationStatus::Error->value,
|
||||
ProviderVerificationStatus::Blocked->value,
|
||||
ProviderVerificationStatus::Degraded->value,
|
||||
ProviderVerificationStatus::Pending->value,
|
||||
], true)) {
|
||||
return $this->case(
|
||||
key: 'provider_readiness.verification_failed',
|
||||
severity: $verificationState === ProviderVerificationStatus::Pending->value ? 'warning' : 'danger',
|
||||
status: $verificationState === ProviderVerificationStatus::Pending->value
|
||||
? __('localization.provider_guidance.status_action_required')
|
||||
: __('localization.provider_guidance.status_blocked'),
|
||||
title: $verificationState === ProviderVerificationStatus::Pending->value
|
||||
? __('localization.provider_guidance.verification_in_progress_title')
|
||||
: __('localization.provider_guidance.verification_failed_title'),
|
||||
reason: $this->verificationFailureReason(
|
||||
verificationState: $verificationState,
|
||||
connection: $connection,
|
||||
errorCount: $errorCount,
|
||||
),
|
||||
impact: __('localization.provider_guidance.verification_failed_impact'),
|
||||
primaryAction: $latestVerificationRun instanceof OperationRun
|
||||
? $this->verificationOperationAction($latestVerificationRun, $environment)
|
||||
: $this->providerConnectionReviewAction($environment, $connection, $surface),
|
||||
secondaryActions: $this->secondaryActionsFor(
|
||||
environment: $environment,
|
||||
connection: $connection,
|
||||
latestVerificationRun: $latestVerificationRun,
|
||||
surface: $surface,
|
||||
includeRequiredPermissions: true,
|
||||
includeRunVerification: $surface === self::SURFACE_REQUIRED_PERMISSIONS,
|
||||
),
|
||||
technicalDetails: $this->technicalDetails(
|
||||
connection: $connection,
|
||||
permissions: $permissions,
|
||||
latestVerificationRun: $latestVerificationRun,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
$stalePermissionSnapshot = (bool) ($freshness['is_stale'] ?? true);
|
||||
$lastHealthCheckAt = $connection?->last_health_check_at instanceof Carbon
|
||||
? $connection->last_health_check_at
|
||||
: ($connection?->last_health_check_at !== null ? Carbon::parse((string) $connection?->last_health_check_at) : null);
|
||||
$verificationNotRun = $verificationState === ProviderVerificationStatus::Unknown->value || $lastHealthCheckAt === null;
|
||||
$verificationStale = $lastHealthCheckAt instanceof Carbon && $lastHealthCheckAt->lt(now()->subDays(30));
|
||||
|
||||
if ($verificationNotRun || $verificationStale || $stalePermissionSnapshot) {
|
||||
return $this->case(
|
||||
key: 'provider_readiness.verification_required',
|
||||
severity: 'warning',
|
||||
status: __('localization.provider_guidance.status_action_required'),
|
||||
title: __('localization.provider_guidance.verification_required_title'),
|
||||
reason: $this->verificationRequiredReason(
|
||||
verificationNotRun: $verificationNotRun,
|
||||
verificationStale: $verificationStale,
|
||||
stalePermissionSnapshot: $stalePermissionSnapshot,
|
||||
),
|
||||
impact: __('localization.provider_guidance.verification_required_impact'),
|
||||
primaryAction: $this->verificationPrimaryAction(
|
||||
environment: $environment,
|
||||
connection: $connection,
|
||||
surface: $surface,
|
||||
),
|
||||
secondaryActions: $this->secondaryActionsFor(
|
||||
environment: $environment,
|
||||
connection: $connection,
|
||||
latestVerificationRun: $latestVerificationRun,
|
||||
surface: $surface,
|
||||
includeRequiredPermissions: true,
|
||||
),
|
||||
technicalDetails: $this->technicalDetails(
|
||||
connection: $connection,
|
||||
permissions: $permissions,
|
||||
latestVerificationRun: $latestVerificationRun,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->case(
|
||||
key: 'provider_readiness.ready',
|
||||
severity: 'success',
|
||||
status: __('localization.provider_guidance.status_ready'),
|
||||
title: __('localization.provider_guidance.ready_title'),
|
||||
reason: __('localization.provider_guidance.ready_reason'),
|
||||
impact: __('localization.provider_guidance.ready_impact'),
|
||||
primaryAction: $this->environmentDashboardAction($environment),
|
||||
secondaryActions: array_values(array_filter([
|
||||
$surface === self::SURFACE_REQUIRED_PERMISSIONS ? null : $this->requiredPermissionsAction($environment),
|
||||
$connection instanceof ProviderConnection ? $this->providerConnectionAction($environment, $connection, $surface) : null,
|
||||
])),
|
||||
technicalDetails: $this->technicalDetails(
|
||||
connection: $connection,
|
||||
permissions: $permissions,
|
||||
latestVerificationRun: $latestVerificationRun,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
|
||||
* freshness: array{last_refreshed_at:?string,is_stale:bool}
|
||||
* }
|
||||
*/
|
||||
private function permissionSignals(ManagedEnvironment $environment): array
|
||||
{
|
||||
$viewModel = $this->requiredPermissions->build($environment);
|
||||
$overview = is_array($viewModel['overview'] ?? null) ? $viewModel['overview'] : [];
|
||||
|
||||
return [
|
||||
'counts' => array_replace([
|
||||
'missing_application' => 0,
|
||||
'missing_delegated' => 0,
|
||||
'present' => 0,
|
||||
'error' => 0,
|
||||
], is_array($overview['counts'] ?? null) ? $overview['counts'] : []),
|
||||
'freshness' => array_replace([
|
||||
'last_refreshed_at' => null,
|
||||
'is_stale' => true,
|
||||
], is_array($overview['freshness'] ?? null) ? $overview['freshness'] : []),
|
||||
];
|
||||
}
|
||||
|
||||
private function latestVerificationRun(ManagedEnvironment $environment, ProviderConnection $connection): ?OperationRun
|
||||
{
|
||||
return OperationRun::query()
|
||||
->where('workspace_id', (int) $environment->workspace_id)
|
||||
->where('managed_environment_id', (int) $environment->getKey())
|
||||
->where('type', 'provider.connection.check')
|
||||
->where('context->provider_connection_id', (int) $connection->getKey())
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function verificationState(?ProviderConnection $connection): string
|
||||
{
|
||||
$state = $connection?->verification_status;
|
||||
|
||||
if ($state instanceof ProviderVerificationStatus) {
|
||||
return $state->value;
|
||||
}
|
||||
|
||||
if (is_string($state) && trim($state) !== '') {
|
||||
return trim($state);
|
||||
}
|
||||
|
||||
return ProviderVerificationStatus::Unknown->value;
|
||||
}
|
||||
|
||||
private function isConsentBlocker(string $reasonCode): bool
|
||||
{
|
||||
return in_array($reasonCode, [
|
||||
ProviderReasonCodes::ProviderConsentMissing,
|
||||
ProviderReasonCodes::ProviderConsentFailed,
|
||||
ProviderReasonCodes::ProviderConsentRevoked,
|
||||
], true);
|
||||
}
|
||||
|
||||
private function consentReason(string $reasonCode): string
|
||||
{
|
||||
return match ($reasonCode) {
|
||||
ProviderReasonCodes::ProviderConsentFailed => __('localization.provider_guidance.admin_consent_failed_reason'),
|
||||
ProviderReasonCodes::ProviderConsentRevoked => __('localization.provider_guidance.admin_consent_revoked_reason'),
|
||||
default => __('localization.provider_guidance.admin_consent_required_reason'),
|
||||
};
|
||||
}
|
||||
|
||||
private function verificationFailureReason(
|
||||
string $verificationState,
|
||||
?ProviderConnection $connection,
|
||||
int $errorCount,
|
||||
): string {
|
||||
if ($verificationState === ProviderVerificationStatus::Pending->value) {
|
||||
return __('localization.provider_guidance.verification_in_progress_reason');
|
||||
}
|
||||
|
||||
if ($verificationState === ProviderVerificationStatus::Degraded->value) {
|
||||
return __('localization.provider_guidance.verification_degraded_reason');
|
||||
}
|
||||
|
||||
if ($errorCount > 0) {
|
||||
return __('localization.provider_guidance.verification_errors_reason', ['count' => $errorCount]);
|
||||
}
|
||||
|
||||
$message = trim((string) ($connection?->last_error_message ?? ''));
|
||||
|
||||
if ($message !== '') {
|
||||
return Str::limit($message, 180);
|
||||
}
|
||||
|
||||
return __('localization.provider_guidance.verification_failed_reason');
|
||||
}
|
||||
|
||||
private function verificationRequiredReason(
|
||||
bool $verificationNotRun,
|
||||
bool $verificationStale,
|
||||
bool $stalePermissionSnapshot,
|
||||
): string {
|
||||
return match (true) {
|
||||
$verificationNotRun => __('localization.provider_guidance.verification_not_run_reason'),
|
||||
$verificationStale, $stalePermissionSnapshot => __('localization.provider_guidance.verification_stale_reason'),
|
||||
default => __('localization.provider_guidance.verification_not_run_reason'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function verificationPrimaryAction(
|
||||
ManagedEnvironment $environment,
|
||||
?ProviderConnection $connection,
|
||||
string $surface,
|
||||
): array {
|
||||
if ($surface === self::SURFACE_REQUIRED_PERMISSIONS) {
|
||||
return [
|
||||
'label' => __('localization.provider_guidance.action_run_provider_verification'),
|
||||
'url' => null,
|
||||
'action_name' => 'runProviderVerification',
|
||||
'external' => false,
|
||||
'disabled' => false,
|
||||
];
|
||||
}
|
||||
|
||||
if ($surface === self::SURFACE_PROVIDER_CONNECTIONS_VIEW || $surface === self::SURFACE_PROVIDER_CONNECTIONS_EDIT) {
|
||||
return [
|
||||
'label' => __('localization.provider_guidance.action_run_provider_verification'),
|
||||
'url' => null,
|
||||
'action_name' => 'runProviderVerification',
|
||||
'external' => false,
|
||||
'disabled' => false,
|
||||
];
|
||||
}
|
||||
|
||||
if ($connection instanceof ProviderConnection) {
|
||||
return $this->providerConnectionAction($environment, $connection, $surface, __('localization.provider_guidance.action_open_provider_connection'));
|
||||
}
|
||||
|
||||
return $this->providerConnectionsAction($environment);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function secondaryActionsFor(
|
||||
ManagedEnvironment $environment,
|
||||
?ProviderConnection $connection,
|
||||
?OperationRun $latestVerificationRun,
|
||||
string $surface,
|
||||
bool $includeRequiredPermissions = false,
|
||||
bool $includeAdminConsent = false,
|
||||
bool $includeRunVerification = false,
|
||||
): array {
|
||||
$actions = [];
|
||||
|
||||
if ($includeRequiredPermissions && $surface !== self::SURFACE_REQUIRED_PERMISSIONS) {
|
||||
$actions[] = $this->requiredPermissionsAction($environment);
|
||||
}
|
||||
|
||||
if ($includeAdminConsent) {
|
||||
$actions[] = $this->adminConsentAction($environment);
|
||||
}
|
||||
|
||||
if ($latestVerificationRun instanceof OperationRun) {
|
||||
$actions[] = $this->verificationOperationAction($latestVerificationRun, $environment);
|
||||
}
|
||||
|
||||
if ($includeRunVerification && $surface === self::SURFACE_REQUIRED_PERMISSIONS) {
|
||||
$actions[] = [
|
||||
'label' => __('localization.provider_guidance.action_run_provider_verification'),
|
||||
'url' => null,
|
||||
'action_name' => 'runProviderVerification',
|
||||
'external' => false,
|
||||
'disabled' => false,
|
||||
];
|
||||
}
|
||||
|
||||
if ($connection instanceof ProviderConnection && ! in_array($surface, [
|
||||
self::SURFACE_PROVIDER_CONNECTIONS_VIEW,
|
||||
self::SURFACE_PROVIDER_CONNECTIONS_EDIT,
|
||||
], true)) {
|
||||
$actions[] = $this->providerConnectionAction($environment, $connection, $surface);
|
||||
}
|
||||
|
||||
$actions[] = $this->environmentDashboardAction($environment);
|
||||
|
||||
return array_values(array_filter($actions, static fn (array $action): bool => ($action['url'] ?? null) !== null || ($action['action_name'] ?? null) !== null));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
|
||||
* freshness: array{last_refreshed_at:?string,is_stale:bool}
|
||||
* } $permissions
|
||||
* @return list<array{label:string,value:string}>
|
||||
*/
|
||||
private function technicalDetails(
|
||||
?ProviderConnection $connection,
|
||||
array $permissions,
|
||||
?OperationRun $latestVerificationRun,
|
||||
): array {
|
||||
$counts = $permissions['counts'];
|
||||
$freshness = $permissions['freshness'];
|
||||
$details = [
|
||||
$this->technicalDetail(
|
||||
__('localization.provider_guidance.detail_missing_application_permissions_label'),
|
||||
(string) ((int) ($counts['missing_application'] ?? 0)),
|
||||
),
|
||||
$this->technicalDetail(
|
||||
__('localization.provider_guidance.detail_missing_delegated_permissions_label'),
|
||||
(string) ((int) ($counts['missing_delegated'] ?? 0)),
|
||||
),
|
||||
$this->technicalDetail(
|
||||
__('localization.provider_guidance.detail_verification_state_label'),
|
||||
$this->verificationStateLabel($connection),
|
||||
),
|
||||
];
|
||||
|
||||
if (is_string($freshness['last_refreshed_at'] ?? null) && trim((string) $freshness['last_refreshed_at']) !== '') {
|
||||
$details[] = $this->technicalDetail(
|
||||
__('localization.provider_guidance.detail_permission_evidence_label'),
|
||||
Carbon::parse((string) $freshness['last_refreshed_at'])->diffForHumans(),
|
||||
);
|
||||
} else {
|
||||
$details[] = $this->technicalDetail(
|
||||
__('localization.provider_guidance.detail_permission_evidence_label'),
|
||||
__('localization.provider_guidance.verification_not_run_detail'),
|
||||
);
|
||||
}
|
||||
|
||||
if ($connection instanceof ProviderConnection) {
|
||||
$consent = $connection->consent_status;
|
||||
|
||||
$details[] = $this->technicalDetail(
|
||||
__('localization.provider_guidance.detail_consent_state_label'),
|
||||
$consent instanceof ProviderConsentStatus
|
||||
? Str::headline($consent->value)
|
||||
: Str::headline(trim((string) $consent)),
|
||||
);
|
||||
}
|
||||
|
||||
if ($latestVerificationRun instanceof OperationRun) {
|
||||
$details[] = $this->technicalDetail(
|
||||
__('localization.provider_guidance.detail_last_verification_operation_label'),
|
||||
OperationRunLinks::identifier($latestVerificationRun),
|
||||
);
|
||||
}
|
||||
|
||||
return $details;
|
||||
}
|
||||
|
||||
private function verificationStateLabel(?ProviderConnection $connection): string
|
||||
{
|
||||
return match ($this->verificationState($connection)) {
|
||||
ProviderVerificationStatus::Healthy->value => __('localization.provider_guidance.verification_ready_detail'),
|
||||
ProviderVerificationStatus::Degraded->value => __('localization.provider_guidance.verification_degraded_detail'),
|
||||
ProviderVerificationStatus::Blocked->value => __('localization.provider_guidance.verification_blocked_detail'),
|
||||
ProviderVerificationStatus::Error->value => __('localization.provider_guidance.verification_failed_detail'),
|
||||
ProviderVerificationStatus::Pending->value => __('localization.provider_guidance.verification_in_progress_detail'),
|
||||
default => __('localization.provider_guidance.verification_not_run_detail'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function requiredPermissionsAction(ManagedEnvironment $environment): array
|
||||
{
|
||||
return [
|
||||
'label' => __('localization.provider_guidance.action_open_required_permissions'),
|
||||
'url' => RequiredPermissionsLinks::requiredPermissions($environment),
|
||||
'action_name' => null,
|
||||
'external' => false,
|
||||
'disabled' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function adminConsentAction(ManagedEnvironment $environment): array
|
||||
{
|
||||
$url = RequiredPermissionsLinks::adminConsentPrimaryUrl($environment);
|
||||
$directConsent = RequiredPermissionsLinks::adminConsentUrl($environment) !== null;
|
||||
|
||||
return [
|
||||
'label' => $directConsent
|
||||
? __('localization.provider_guidance.action_open_admin_consent')
|
||||
: __('localization.provider_guidance.action_open_admin_consent_guide'),
|
||||
'url' => $url,
|
||||
'action_name' => null,
|
||||
'external' => true,
|
||||
'disabled' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function providerConnectionsAction(ManagedEnvironment $environment): array
|
||||
{
|
||||
return [
|
||||
'label' => __('localization.provider_guidance.action_open_provider_connections'),
|
||||
'url' => ManagedEnvironmentLinks::providerConnectionsUrl($environment),
|
||||
'action_name' => null,
|
||||
'external' => false,
|
||||
'disabled' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function providerConnectionReviewAction(
|
||||
ManagedEnvironment $environment,
|
||||
?ProviderConnection $connection,
|
||||
string $surface,
|
||||
): array {
|
||||
if ($connection instanceof ProviderConnection) {
|
||||
return $this->providerConnectionAction(
|
||||
environment: $environment,
|
||||
connection: $connection,
|
||||
surface: $surface,
|
||||
label: in_array($surface, [self::SURFACE_PROVIDER_CONNECTIONS_VIEW, self::SURFACE_PROVIDER_CONNECTIONS_EDIT], true)
|
||||
? __('localization.provider_guidance.action_edit_provider_connection')
|
||||
: __('localization.provider_guidance.action_open_provider_connection'),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->providerConnectionsAction($environment);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function providerConnectionAction(
|
||||
ManagedEnvironment $environment,
|
||||
ProviderConnection $connection,
|
||||
string $surface,
|
||||
?string $label = null,
|
||||
): array {
|
||||
$page = $surface === self::SURFACE_PROVIDER_CONNECTIONS_EDIT ? 'view' : 'edit';
|
||||
|
||||
if ($surface === self::SURFACE_PROVIDER_CONNECTIONS_INDEX || $surface === self::SURFACE_REQUIRED_PERMISSIONS) {
|
||||
$page = 'view';
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => $label ?? __('localization.provider_guidance.action_open_provider_connection'),
|
||||
'url' => ManagedEnvironmentLinks::providerConnectionUrl($connection, $page, $environment),
|
||||
'action_name' => null,
|
||||
'external' => false,
|
||||
'disabled' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function verificationOperationAction(OperationRun $run, ManagedEnvironment $environment): array
|
||||
{
|
||||
return [
|
||||
'label' => __('localization.provider_guidance.action_open_verification_operation'),
|
||||
'url' => OperationRunLinks::view($run, $environment),
|
||||
'action_name' => null,
|
||||
'external' => false,
|
||||
'disabled' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function environmentDashboardAction(ManagedEnvironment $environment): array
|
||||
{
|
||||
return [
|
||||
'label' => __('localization.provider_guidance.action_open_environment_dashboard'),
|
||||
'url' => ManagedEnvironmentLinks::viewUrl($environment),
|
||||
'action_name' => null,
|
||||
'external' => false,
|
||||
'disabled' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{label:string,value:string}> $technicalDetails
|
||||
* @param list<array<string, mixed>> $secondaryActions
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function case(
|
||||
string $key,
|
||||
string $severity,
|
||||
string $status,
|
||||
string $title,
|
||||
string $reason,
|
||||
string $impact,
|
||||
array $primaryAction,
|
||||
array $secondaryActions,
|
||||
array $technicalDetails,
|
||||
): array {
|
||||
return [
|
||||
'key' => $key,
|
||||
'severity' => $severity,
|
||||
'status' => $status,
|
||||
'title' => $title,
|
||||
'reason' => $reason,
|
||||
'impact' => $impact,
|
||||
'primary_action' => $primaryAction,
|
||||
'secondary_actions' => array_values($secondaryActions),
|
||||
'technical_details' => array_values($technicalDetails),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label:string,value:string}
|
||||
*/
|
||||
private function technicalDetail(string $label, string $value): array
|
||||
{
|
||||
return [
|
||||
'label' => $label,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -354,6 +354,76 @@
|
||||
'recent_operation_fallback_summary' => 'Aktueller Vorgangskontext für diese Umgebung.',
|
||||
],
|
||||
],
|
||||
'provider_guidance' => [
|
||||
'status_blocked' => 'Blockiert',
|
||||
'status_action_required' => 'Aktion erforderlich',
|
||||
'status_ready' => 'Bereit',
|
||||
'reason_label' => 'Grund',
|
||||
'impact_label' => 'Auswirkung',
|
||||
'primary_action_label' => 'Empfohlener nächster Schritt',
|
||||
'secondary_actions_label' => 'Weitere Aktionen',
|
||||
'details_label' => 'Details',
|
||||
'provider_readiness_blocked_title' => 'Provider-Bereitschaft blockiert',
|
||||
'provider_readiness_attention_title' => 'Provider-Bereitschaft benötigt Aufmerksamkeit',
|
||||
'required_permissions_missing_title' => 'Erforderliche Berechtigungen fehlen',
|
||||
'connection_missing_title' => 'Provider-Verbindung erforderlich',
|
||||
'connection_missing_reason' => 'Für diese Umgebung ist noch keine Provider-Verbindung konfiguriert.',
|
||||
'connection_missing_impact' => 'Evidence-Aktualisierung, Berechtigungsstatus, Inventarisierung und Review-Bereitschaft können erst als bereit gelten, wenn eine Provider-Verbindung vorhanden ist.',
|
||||
'connection_review_title' => 'Provider-Verbindung muss geprüft werden',
|
||||
'connection_review_reason' => 'Diese Provider-Verbindung ist derzeit nicht nutzbar.',
|
||||
'connection_review_impact' => 'TenantPilot kann diese Provider-Verbindung erst nach Prüfung des protokollierten Problems verifizieren oder verwenden.',
|
||||
'connection_disabled_title' => 'Provider-Verbindung deaktiviert',
|
||||
'connection_disabled_reason' => 'Diese Provider-Verbindung ist deaktiviert.',
|
||||
'connection_disabled_impact' => 'Provider-gestützte Evidence-Aktualisierung, Inventarisierung und Bereitschaftsprüfung bleiben blockiert, solange die Verbindung deaktiviert ist.',
|
||||
'admin_consent_required_title' => 'Provider-Zustimmung erforderlich',
|
||||
'admin_consent_required_reason' => 'Für diese Provider-Verbindung fehlt die Admin-Zustimmung.',
|
||||
'admin_consent_failed_reason' => 'TenantPilot konnte die Admin-Zustimmung für diese Provider-Verbindung nicht bestätigen.',
|
||||
'admin_consent_revoked_reason' => 'Eine zuvor erteilte Admin-Zustimmung ist für diese Provider-Verbindung nicht mehr gültig.',
|
||||
'admin_consent_required_impact' => 'TenantPilot kann die Provider-Verbindung erst wieder für Evidence-Aktualisierung, Inventarisierung und Review-Bereitschaft verwenden, wenn die Zustimmung wiederhergestellt ist.',
|
||||
'required_application_permissions_reason' => 'Erforderliche Anwendungsberechtigungen fehlen.',
|
||||
'required_application_permissions_impact' => 'TenantPilot kann Evidence, Berechtigungsstatus, Inventarisierung und Review-Outputs erst zuverlässig aktualisieren, wenn die fehlenden Anwendungsberechtigungen erteilt wurden.',
|
||||
'required_delegated_permissions_reason' => 'Erforderliche delegierte Berechtigungen fehlen.',
|
||||
'required_delegated_permissions_impact' => 'Provider-gestützte Workflows bleiben möglicherweise nur teilweise bereit, bis der delegierte Zugriff geprüft und bei Bedarf erteilt wurde.',
|
||||
'verification_required_title' => 'Provider-Verifikation erforderlich',
|
||||
'verification_required_impact' => 'TenantPilot kann erst nach einer erneuten Verifikation bestätigen, ob die erforderlichen Berechtigungen und der Provider-Zugriff derzeit nutzbar sind.',
|
||||
'verification_not_run_reason' => 'Diese Provider-Verbindung wurde noch nicht verifiziert.',
|
||||
'verification_stale_reason' => 'Gespeicherte Provider-Verifikationsdaten sind veraltet und sollten aktualisiert werden.',
|
||||
'verification_failed_title' => 'Provider-Verifikation fehlgeschlagen',
|
||||
'verification_failed_reason' => 'Die letzte Provider-Verifikation wurde nicht erfolgreich abgeschlossen.',
|
||||
'verification_failed_impact' => 'Provider-gestützte Vorgänge können fehlschlagen, bis das Problem geprüft wurde.',
|
||||
'verification_errors_reason' => ':count Berechtigungszeile(n) befinden sich in einem unbekannten oder fehlerhaften Zustand und benötigen Nachverfolgung.',
|
||||
'verification_degraded_reason' => 'Die letzte Provider-Verifikation wurde mit Warnungen abgeschlossen und sollte geprüft werden.',
|
||||
'verification_in_progress_title' => 'Provider-Verifikation läuft',
|
||||
'verification_in_progress_reason' => 'Für diese Verbindung läuft bereits eine Provider-Verifikation.',
|
||||
'verification_blocked_notification_title' => 'Provider-Verifikation blockiert',
|
||||
'ready_title' => 'Provider-Verbindung bereit',
|
||||
'ready_reason' => 'Erforderliche Provider-Berechtigungen und Verifikationsprüfungen sind aktuell erfüllt.',
|
||||
'ready_impact' => 'Derzeit ist keine dringende Aktion zur Provider-Bereitschaft erforderlich.',
|
||||
'action_open_required_permissions' => 'Erforderliche Berechtigungen öffnen',
|
||||
'action_open_admin_consent' => 'Admin-Zustimmung öffnen',
|
||||
'action_open_admin_consent_guide' => 'Leitfaden zur Admin-Zustimmung öffnen',
|
||||
'action_open_provider_connections' => 'Provider-Verbindungen öffnen',
|
||||
'action_open_provider_connection' => 'Provider-Verbindung öffnen',
|
||||
'action_edit_provider_connection' => 'Provider-Verbindung bearbeiten',
|
||||
'action_run_provider_verification' => 'Provider-Verifikation starten',
|
||||
'action_open_verification_operation' => 'Verifikationsvorgang öffnen',
|
||||
'action_open_environment_dashboard' => 'Umgebungs-Dashboard öffnen',
|
||||
'provider_readiness_section' => 'Provider-Bereitschaft',
|
||||
'detail_provider_label' => 'Provider',
|
||||
'detail_provider_value' => 'Microsoft',
|
||||
'detail_missing_application_permissions_label' => 'Fehlende Anwendungsberechtigungen',
|
||||
'detail_missing_delegated_permissions_label' => 'Fehlende delegierte Berechtigungen',
|
||||
'detail_verification_state_label' => 'Verifikationsstatus',
|
||||
'detail_permission_evidence_label' => 'Gespeicherte Berechtigungsevidenz',
|
||||
'detail_consent_state_label' => 'Zustimmungsstatus',
|
||||
'detail_last_verification_operation_label' => 'Letzter Verifikationsvorgang',
|
||||
'verification_ready_detail' => 'Bereit',
|
||||
'verification_degraded_detail' => 'Beeinträchtigt',
|
||||
'verification_blocked_detail' => 'Blockiert',
|
||||
'verification_failed_detail' => 'Fehlgeschlagen',
|
||||
'verification_in_progress_detail' => 'Läuft',
|
||||
'verification_not_run_detail' => 'Nicht ausgeführt',
|
||||
],
|
||||
'review' => [
|
||||
'reporting' => 'Berichte',
|
||||
'customer_reviews' => 'Kundenreviews',
|
||||
|
||||
@ -354,6 +354,76 @@
|
||||
'recent_operation_fallback_summary' => 'Recent operation context for this environment.',
|
||||
],
|
||||
],
|
||||
'provider_guidance' => [
|
||||
'status_blocked' => 'Blocked',
|
||||
'status_action_required' => 'Action required',
|
||||
'status_ready' => 'Ready',
|
||||
'reason_label' => 'Reason',
|
||||
'impact_label' => 'Impact',
|
||||
'primary_action_label' => 'Recommended next action',
|
||||
'secondary_actions_label' => 'Secondary',
|
||||
'details_label' => 'Details',
|
||||
'provider_readiness_blocked_title' => 'Provider readiness blocked',
|
||||
'provider_readiness_attention_title' => 'Provider readiness needs attention',
|
||||
'required_permissions_missing_title' => 'Required permissions missing',
|
||||
'connection_missing_title' => 'Provider connection required',
|
||||
'connection_missing_reason' => 'No provider connection is configured for this environment.',
|
||||
'connection_missing_impact' => 'Evidence refresh, permission posture, inventory sync, and review readiness cannot be treated as ready until a provider connection exists.',
|
||||
'connection_review_title' => 'Provider connection needs review',
|
||||
'connection_review_reason' => 'This provider connection is not currently usable.',
|
||||
'connection_review_impact' => 'TenantPilot cannot verify or use this provider connection until the recorded issue is reviewed.',
|
||||
'connection_disabled_title' => 'Provider connection disabled',
|
||||
'connection_disabled_reason' => 'This provider connection is disabled.',
|
||||
'connection_disabled_impact' => 'Provider-backed evidence refresh, inventory sync, and readiness verification stay blocked while the connection is disabled.',
|
||||
'admin_consent_required_title' => 'Provider consent required',
|
||||
'admin_consent_required_reason' => 'Admin consent is missing for this provider connection.',
|
||||
'admin_consent_failed_reason' => 'TenantPilot could not confirm admin consent for this provider connection.',
|
||||
'admin_consent_revoked_reason' => 'Previously granted admin consent is no longer valid for this provider connection.',
|
||||
'admin_consent_required_impact' => 'TenantPilot cannot use the provider connection for evidence refresh, inventory, or review readiness until consent is restored.',
|
||||
'required_application_permissions_reason' => 'Required application permissions are missing.',
|
||||
'required_application_permissions_impact' => 'TenantPilot cannot refresh evidence, permission posture, inventory, or review outputs reliably until the missing application permissions are granted.',
|
||||
'required_delegated_permissions_reason' => 'Required delegated permissions are missing.',
|
||||
'required_delegated_permissions_impact' => 'Provider-backed workflows may remain partially ready until delegated access is reviewed and granted where needed.',
|
||||
'verification_required_title' => 'Provider verification required',
|
||||
'verification_required_impact' => 'TenantPilot cannot confirm whether required permissions and provider access are currently usable until verification runs again.',
|
||||
'verification_not_run_reason' => 'This provider connection has not been verified yet.',
|
||||
'verification_stale_reason' => 'Stored provider verification evidence is stale and should be refreshed.',
|
||||
'verification_failed_title' => 'Provider verification failed',
|
||||
'verification_failed_reason' => 'The last provider verification did not complete successfully.',
|
||||
'verification_failed_impact' => 'Provider-backed operations may fail until the issue is reviewed.',
|
||||
'verification_errors_reason' => ':count permission row(s) are in an unknown or error state and require follow-up.',
|
||||
'verification_degraded_reason' => 'The latest provider verification completed with warnings and should be reviewed.',
|
||||
'verification_in_progress_title' => 'Provider verification in progress',
|
||||
'verification_in_progress_reason' => 'A provider verification is already running for this connection.',
|
||||
'verification_blocked_notification_title' => 'Provider verification blocked',
|
||||
'ready_title' => 'Provider connection ready',
|
||||
'ready_reason' => 'Required provider permissions and verification checks are currently satisfied.',
|
||||
'ready_impact' => 'No urgent provider readiness action is currently required.',
|
||||
'action_open_required_permissions' => 'Open required permissions',
|
||||
'action_open_admin_consent' => 'Open admin consent',
|
||||
'action_open_admin_consent_guide' => 'Open admin consent guide',
|
||||
'action_open_provider_connections' => 'Open provider connections',
|
||||
'action_open_provider_connection' => 'Open provider connection',
|
||||
'action_edit_provider_connection' => 'Edit provider connection',
|
||||
'action_run_provider_verification' => 'Run provider verification',
|
||||
'action_open_verification_operation' => 'Open verification operation',
|
||||
'action_open_environment_dashboard' => 'Open environment dashboard',
|
||||
'provider_readiness_section' => 'Provider readiness',
|
||||
'detail_provider_label' => 'Provider',
|
||||
'detail_provider_value' => 'Microsoft',
|
||||
'detail_missing_application_permissions_label' => 'Missing application permissions',
|
||||
'detail_missing_delegated_permissions_label' => 'Missing delegated permissions',
|
||||
'detail_verification_state_label' => 'Verification state',
|
||||
'detail_permission_evidence_label' => 'Stored permission evidence',
|
||||
'detail_consent_state_label' => 'Consent state',
|
||||
'detail_last_verification_operation_label' => 'Last verification operation',
|
||||
'verification_ready_detail' => 'Ready',
|
||||
'verification_degraded_detail' => 'Degraded',
|
||||
'verification_blocked_detail' => 'Blocked',
|
||||
'verification_failed_detail' => 'Failed',
|
||||
'verification_in_progress_detail' => 'In progress',
|
||||
'verification_not_run_detail' => 'Not run',
|
||||
],
|
||||
'review' => [
|
||||
'reporting' => 'Reporting',
|
||||
'customer_reviews' => 'Customer reviews',
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
@php
|
||||
$guidance = $getState();
|
||||
$guidance = is_array($guidance) ? $guidance : [];
|
||||
@endphp
|
||||
|
||||
@include('filament.partials.provider-readiness-guidance-card', [
|
||||
'guidance' => $guidance,
|
||||
'inlinePrimaryAction' => false,
|
||||
'primaryActionMethod' => null,
|
||||
])
|
||||
@ -38,6 +38,11 @@
|
||||
|
||||
$reRunUrl = $this->reRunVerificationUrl();
|
||||
$manageProviderConnectionUrl = $this->manageProviderConnectionUrl();
|
||||
$guidance = $this->guidanceCase();
|
||||
$guidancePrimaryAction = is_array($guidance['primary_action'] ?? null) ? $guidance['primary_action'] : [];
|
||||
$canRunProviderVerification = $this->canRunProviderVerification();
|
||||
$showGuidancePrimaryAction = (is_string($guidancePrimaryAction['url'] ?? null) && $guidancePrimaryAction['url'] !== '')
|
||||
|| ($canRunProviderVerification && ($guidancePrimaryAction['action_name'] ?? null) === 'runProviderVerification');
|
||||
$lastRefreshedAt = is_string($freshness['last_refreshed_at'] ?? null) ? (string) $freshness['last_refreshed_at'] : null;
|
||||
$lastRefreshedLabel = $lastRefreshedAt ? Carbon::parse($lastRefreshedAt)->diffForHumans() : 'Unknown';
|
||||
$isStale = (bool) ($freshness['is_stale'] ?? true);
|
||||
@ -53,7 +58,7 @@
|
||||
'links' => array_values(array_filter([
|
||||
['label' => $adminConsentLabel, 'url' => $adminConsentPrimaryUrl, 'external' => true],
|
||||
$manageProviderConnectionUrl ? ['label' => 'Manage provider connection', 'url' => $manageProviderConnectionUrl, 'external' => false] : null,
|
||||
['label' => 'Re-run verification', 'url' => $reRunUrl, 'external' => false],
|
||||
['label' => 'Open environment dashboard', 'url' => $reRunUrl, 'external' => false],
|
||||
])),
|
||||
];
|
||||
}
|
||||
@ -65,7 +70,7 @@
|
||||
'description' => "{$missingDelegated} delegated permission(s) are missing.",
|
||||
'links' => [
|
||||
['label' => $adminConsentLabel, 'url' => $adminConsentPrimaryUrl, 'external' => true],
|
||||
['label' => 'Re-run verification', 'url' => $reRunUrl, 'external' => false],
|
||||
['label' => 'Open provider connection', 'url' => $manageProviderConnectionUrl ?? $reRunUrl, 'external' => false],
|
||||
],
|
||||
];
|
||||
}
|
||||
@ -76,7 +81,7 @@
|
||||
'title' => 'Verification results need review',
|
||||
'description' => "{$errorCount} permission row(s) are in an unknown/error state and require follow-up.",
|
||||
'links' => [
|
||||
['label' => 'Re-run verification', 'url' => $reRunUrl, 'external' => false],
|
||||
['label' => 'Open provider connection', 'url' => $manageProviderConnectionUrl ?? $reRunUrl, 'external' => false],
|
||||
$manageProviderConnectionUrl ? ['label' => 'Manage provider connection', 'url' => $manageProviderConnectionUrl, 'external' => false] : ['label' => 'Admin consent guide', 'url' => RequiredPermissionsLinks::adminConsentGuideUrl(), 'external' => true],
|
||||
],
|
||||
];
|
||||
@ -90,7 +95,7 @@
|
||||
? "Permission data is older than 30 days (last refresh {$lastRefreshedLabel})."
|
||||
: 'No stored verification data is available yet.',
|
||||
'links' => [
|
||||
['label' => 'Start verification', 'url' => $reRunUrl, 'external' => false],
|
||||
['label' => 'Open provider connection', 'url' => $manageProviderConnectionUrl ?? $reRunUrl, 'external' => false],
|
||||
],
|
||||
];
|
||||
}
|
||||
@ -100,36 +105,33 @@
|
||||
<div class="space-y-6">
|
||||
<x-filament::section heading="Summary">
|
||||
<div class="flex flex-col gap-4" x-data="{ showCopyApplication: false, showCopyDelegated: false }">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Review what’s missing for this environment and copy the missing permissions for admin consent.
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Stored-data view only. Last refreshed: {{ $lastRefreshedLabel }}{{ $isStale ? ' (stale)' : '' }}.
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($overallSpec)
|
||||
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
||||
{{ $overallSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Stored data · refreshed {{ $lastRefreshedLabel }}{{ $isStale ? ' · stale' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="grid w-full grid-cols-2 gap-2 sm:w-auto sm:grid-cols-4">
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Missing (app)</div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['missing_application'] ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Missing (delegated)</div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['missing_delegated'] ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Present</div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['present'] ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Errors</div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ (int) ($counts['error'] ?? 0) }}</div>
|
||||
</div>
|
||||
@ -141,11 +143,31 @@
|
||||
<div class="font-semibold">No data available</div>
|
||||
<div class="mt-1">
|
||||
No stored verification data is available for this environment.
|
||||
<a href="{{ $reRunUrl }}" class="font-medium underline">Start verification</a>.
|
||||
@if ($canRunProviderVerification)
|
||||
<button
|
||||
type="button"
|
||||
wire:click="runProviderVerification"
|
||||
class="font-medium underline"
|
||||
>
|
||||
Run provider verification
|
||||
</button>.
|
||||
@elseif ($manageProviderConnectionUrl)
|
||||
<a href="{{ $manageProviderConnectionUrl }}" class="font-medium underline">Open provider connection</a>.
|
||||
@else
|
||||
<a href="{{ $reRunUrl }}" class="font-medium underline">Open environment dashboard</a>.
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (is_array($guidance) && $guidance !== [])
|
||||
@include('filament.partials.provider-readiness-guidance-card', [
|
||||
'guidance' => $guidance,
|
||||
'inlinePrimaryAction' => $showGuidancePrimaryAction,
|
||||
'primaryActionMethod' => 'runProviderVerification',
|
||||
])
|
||||
@endif
|
||||
|
||||
@if ($capabilityGroups !== [])
|
||||
<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">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
@ -162,7 +184,7 @@
|
||||
$primarySpec = BadgeRenderer::spec(BadgeDomain::ProviderCapabilityStatus, $primaryStatus);
|
||||
@endphp
|
||||
|
||||
<x-filament::badge :color="$primarySpec->color" :icon="$primarySpec->icon">
|
||||
<x-filament::badge :color="$primarySpec->color" :icon="$primarySpec->icon" class="max-w-full whitespace-normal text-left">
|
||||
{{ (string) ($primaryCapabilityGroup['label'] ?? 'Provider capability') }}: {{ $primarySpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@ -185,7 +207,7 @@
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $capabilityLabel }}
|
||||
@ -195,7 +217,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-filament::badge :color="$capabilitySpec->color" :icon="$capabilitySpec->icon" size="sm">
|
||||
<x-filament::badge :color="$capabilitySpec->color" :icon="$capabilitySpec->icon" size="sm" class="w-fit max-w-full whitespace-normal text-left">
|
||||
{{ $capabilitySpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@ -210,7 +232,7 @@
|
||||
@endif
|
||||
|
||||
<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">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">Guidance</div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">Permission handoff</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
<div>
|
||||
<span class="font-medium">Who can fix this?</span>
|
||||
@ -227,14 +249,25 @@ class="text-primary-600 hover:underline dark:text-primary-400"
|
||||
{{ $adminConsentLabel }}
|
||||
</a>
|
||||
</div>
|
||||
@if ($reRunUrl)
|
||||
@if ($canRunProviderVerification)
|
||||
<div>
|
||||
<span class="font-medium">After granting consent:</span>
|
||||
<button
|
||||
type="button"
|
||||
wire:click="runProviderVerification"
|
||||
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Run provider verification
|
||||
</button>
|
||||
</div>
|
||||
@elseif ($manageProviderConnectionUrl)
|
||||
<div>
|
||||
<span class="font-medium">After granting consent:</span>
|
||||
<a
|
||||
href="{{ $reRunUrl }}"
|
||||
href="{{ $manageProviderConnectionUrl }}"
|
||||
class="text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Re-run verification
|
||||
Open provider connection to run verification
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@ -0,0 +1,257 @@
|
||||
@php
|
||||
$guidance = is_array($guidance ?? null) ? $guidance : [];
|
||||
$primaryAction = is_array($guidance['primary_action'] ?? null) ? $guidance['primary_action'] : [];
|
||||
$secondaryActions = is_array($guidance['secondary_actions'] ?? null) ? $guidance['secondary_actions'] : [];
|
||||
$technicalDetails = is_array($guidance['technical_details'] ?? null) ? $guidance['technical_details'] : [];
|
||||
|
||||
$inlinePrimaryAction = (bool) ($inlinePrimaryAction ?? false);
|
||||
$severity = (string) ($guidance['severity'] ?? 'warning');
|
||||
$status = (string) ($guidance['status'] ?? '');
|
||||
$title = (string) ($guidance['title'] ?? '');
|
||||
$reason = (string) ($guidance['reason'] ?? '');
|
||||
$impact = (string) ($guidance['impact'] ?? '');
|
||||
$actionName = is_string($primaryAction['action_name'] ?? null) ? (string) $primaryAction['action_name'] : null;
|
||||
$primaryActionUrl = is_string($primaryAction['url'] ?? null) ? (string) $primaryAction['url'] : null;
|
||||
$primaryActionLabel = (string) ($primaryAction['label'] ?? '');
|
||||
$primaryActionExternal = (bool) ($primaryAction['external'] ?? false);
|
||||
$primaryActionDisabled = (bool) ($primaryAction['disabled'] ?? false);
|
||||
$primaryActionMethod = is_string($primaryActionMethod ?? null) ? (string) $primaryActionMethod : $actionName;
|
||||
$canRunPrimaryActionMethod = $inlinePrimaryAction
|
||||
&& $primaryActionMethod !== null
|
||||
&& $actionName === $primaryActionMethod;
|
||||
|
||||
[$badgeColor, $accentClasses] = match ($severity) {
|
||||
'success' => [
|
||||
'success',
|
||||
'border-l-success-500 dark:border-l-success-400',
|
||||
],
|
||||
'danger' => [
|
||||
'danger',
|
||||
'border-l-danger-500 dark:border-l-danger-400',
|
||||
],
|
||||
default => [
|
||||
'warning',
|
||||
'border-l-warning-500 dark:border-l-warning-400',
|
||||
],
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-l-4 border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900 sm:p-5 {{ $accentClasses }}"
|
||||
data-testid="provider-readiness-guidance-card"
|
||||
data-guidance-key="{{ $guidance['key'] ?? '' }}"
|
||||
>
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_18rem] lg:items-start">
|
||||
<div class="min-w-0 space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($status !== '')
|
||||
<x-filament::badge
|
||||
:color="$badgeColor"
|
||||
size="sm"
|
||||
data-testid="provider-readiness-status"
|
||||
>
|
||||
{{ $status }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($title !== '')
|
||||
<h2 class="text-sm font-semibold text-gray-950 dark:text-white sm:text-base" data-testid="provider-readiness-title">
|
||||
{{ $title }}
|
||||
</h2>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 lg:hidden">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ __('localization.provider_guidance.primary_action_label') }}
|
||||
</div>
|
||||
|
||||
@if ($canRunPrimaryActionMethod)
|
||||
<x-filament::button
|
||||
color="primary"
|
||||
wire:click="{{ $primaryActionMethod }}"
|
||||
wire:loading.attr="disabled"
|
||||
class="w-full justify-center whitespace-normal text-center"
|
||||
:disabled="$primaryActionDisabled"
|
||||
>
|
||||
{{ $primaryActionLabel }}
|
||||
</x-filament::button>
|
||||
@elseif ($inlinePrimaryAction && $primaryActionUrl !== null)
|
||||
<x-filament::button
|
||||
color="primary"
|
||||
tag="a"
|
||||
href="{{ $primaryActionUrl }}"
|
||||
class="w-full justify-center whitespace-normal text-center"
|
||||
:target="$primaryActionExternal ? '_blank' : null"
|
||||
:rel="$primaryActionExternal ? 'noreferrer' : null"
|
||||
:disabled="$primaryActionDisabled"
|
||||
>
|
||||
{{ $primaryActionLabel }}
|
||||
</x-filament::button>
|
||||
@elseif ($primaryActionLabel !== '')
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm font-medium text-gray-900 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100">
|
||||
{{ $primaryActionLabel }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($secondaryActions !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ __('localization.provider_guidance.secondary_actions_label') }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach ($secondaryActions as $secondaryAction)
|
||||
@php
|
||||
if (! is_array($secondaryAction)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$secondaryUrl = is_string($secondaryAction['url'] ?? null) ? (string) $secondaryAction['url'] : null;
|
||||
$secondaryLabel = (string) ($secondaryAction['label'] ?? '');
|
||||
$secondaryExternal = (bool) ($secondaryAction['external'] ?? false);
|
||||
@endphp
|
||||
|
||||
@if ($secondaryUrl !== null && $secondaryLabel !== '')
|
||||
<x-filament::button
|
||||
color="gray"
|
||||
size="sm"
|
||||
tag="a"
|
||||
href="{{ $secondaryUrl }}"
|
||||
class="max-w-full whitespace-normal text-center"
|
||||
:target="$secondaryExternal ? '_blank' : null"
|
||||
:rel="$secondaryExternal ? 'noreferrer' : null"
|
||||
>
|
||||
{{ $secondaryLabel }}
|
||||
</x-filament::button>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($reason !== '')
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ __('localization.provider_guidance.reason_label') }}
|
||||
</div>
|
||||
<p class="text-sm text-gray-800 dark:text-gray-100" data-testid="provider-readiness-reason">
|
||||
{{ $reason }}
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($impact !== '')
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ __('localization.provider_guidance.impact_label') }}
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-200" data-testid="provider-readiness-impact">
|
||||
{{ $impact }}
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="hidden w-full space-y-3 lg:block">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ __('localization.provider_guidance.primary_action_label') }}
|
||||
</div>
|
||||
|
||||
@if ($canRunPrimaryActionMethod)
|
||||
<x-filament::button
|
||||
color="primary"
|
||||
wire:click="{{ $primaryActionMethod }}"
|
||||
wire:loading.attr="disabled"
|
||||
class="w-full justify-center whitespace-normal text-center"
|
||||
:disabled="$primaryActionDisabled"
|
||||
data-testid="provider-readiness-primary-action"
|
||||
>
|
||||
{{ $primaryActionLabel }}
|
||||
</x-filament::button>
|
||||
@elseif ($inlinePrimaryAction && $primaryActionUrl !== null)
|
||||
<x-filament::button
|
||||
color="primary"
|
||||
tag="a"
|
||||
href="{{ $primaryActionUrl }}"
|
||||
class="w-full justify-center whitespace-normal text-center"
|
||||
:target="$primaryActionExternal ? '_blank' : null"
|
||||
:rel="$primaryActionExternal ? 'noreferrer' : null"
|
||||
:disabled="$primaryActionDisabled"
|
||||
data-testid="provider-readiness-primary-action"
|
||||
>
|
||||
{{ $primaryActionLabel }}
|
||||
</x-filament::button>
|
||||
@elseif ($primaryActionLabel !== '')
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm font-medium text-gray-900 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100">
|
||||
{{ $primaryActionLabel }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($secondaryActions !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ __('localization.provider_guidance.secondary_actions_label') }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach ($secondaryActions as $secondaryAction)
|
||||
@php
|
||||
if (! is_array($secondaryAction)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$secondaryUrl = is_string($secondaryAction['url'] ?? null) ? (string) $secondaryAction['url'] : null;
|
||||
$secondaryLabel = (string) ($secondaryAction['label'] ?? '');
|
||||
$secondaryExternal = (bool) ($secondaryAction['external'] ?? false);
|
||||
@endphp
|
||||
|
||||
@if ($secondaryUrl !== null && $secondaryLabel !== '')
|
||||
<x-filament::button
|
||||
color="gray"
|
||||
size="sm"
|
||||
tag="a"
|
||||
href="{{ $secondaryUrl }}"
|
||||
class="max-w-full whitespace-normal text-center"
|
||||
:target="$secondaryExternal ? '_blank' : null"
|
||||
:rel="$secondaryExternal ? 'noreferrer' : null"
|
||||
data-testid="provider-readiness-secondary-action"
|
||||
>
|
||||
{{ $secondaryLabel }}
|
||||
</x-filament::button>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($technicalDetails !== [])
|
||||
<details class="mt-4 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950" data-testid="provider-readiness-details">
|
||||
<summary class="cursor-pointer text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ __('localization.provider_guidance.details_label') }}
|
||||
</summary>
|
||||
|
||||
<dl class="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
@foreach ($technicalDetails as $detail)
|
||||
@php
|
||||
if (! is_array($detail)) {
|
||||
continue;
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ (string) ($detail['label'] ?? '') }}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
{{ (string) ($detail['value'] ?? '') }}
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
</details>
|
||||
@endif
|
||||
</div>
|
||||
@ -0,0 +1,7 @@
|
||||
@if (is_array($guidance ?? null) && $guidance !== [])
|
||||
@include('filament.partials.provider-readiness-guidance-card', [
|
||||
'guidance' => $guidance,
|
||||
'inlinePrimaryAction' => $inlinePrimaryAction ?? false,
|
||||
'primaryActionMethod' => $primaryActionMethod ?? null,
|
||||
])
|
||||
@endif
|
||||
@ -85,8 +85,10 @@
|
||||
visit(ManagedEnvironmentLinks::viewUrl($tenant))
|
||||
->waitForText('Provider Health')
|
||||
->assertScript("window.location.pathname === '{$tenantViewPath}'", true)
|
||||
->assertSee('Spec 281 Browser Connection')
|
||||
->assertSee('Spec 281 Browser Environment')
|
||||
->assertSee('Healthy')
|
||||
->assertSee('Granted')
|
||||
->assertSee('Open Provider Connections')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
|
||||
@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\EnvironmentDashboard;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentPermission;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\ManagedEnvironmentLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
pest()->browser()->timeout(40_000);
|
||||
|
||||
beforeEach(function (): void {
|
||||
config()->set('graph.client_id', 'spec353-platform-client');
|
||||
config()->set('graph.client_secret', 'spec353-platform-secret');
|
||||
config()->set('graph.managed_environment_id', 'organizations');
|
||||
});
|
||||
|
||||
function spec353BrowserApplicationPermissionKey(): string
|
||||
{
|
||||
$permission = collect(spec283ConfiguredPermissionRows())
|
||||
->first(static fn (mixed $row): bool => is_array($row) && ($row['type'] ?? null) === 'application');
|
||||
|
||||
expect($permission)->not->toBeNull();
|
||||
|
||||
return (string) $permission['key'];
|
||||
}
|
||||
|
||||
function spec353BrowserSeedPermissionRows(
|
||||
ManagedEnvironment $environment,
|
||||
array $missingKeys = [],
|
||||
array $errorKeys = [],
|
||||
?\DateTimeInterface $lastCheckedAt = null,
|
||||
): void {
|
||||
foreach (spec283ConfiguredPermissionRows() as $permission) {
|
||||
if (! is_array($permission)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$permissionKey = (string) ($permission['key'] ?? '');
|
||||
|
||||
if ($permissionKey === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
ManagedEnvironmentPermission::query()->updateOrCreate(
|
||||
[
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'permission_key' => $permissionKey,
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
],
|
||||
[
|
||||
'status' => in_array($permissionKey, $errorKeys, true)
|
||||
? 'error'
|
||||
: (in_array($permissionKey, $missingKeys, true) ? 'missing' : 'granted'),
|
||||
'details' => ['source' => 'spec353-browser-test'],
|
||||
'last_checked_at' => $lastCheckedAt ?? now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* user: User,
|
||||
* workspace: Workspace,
|
||||
* blockedEnvironment: ManagedEnvironment,
|
||||
* blockedConnection: ProviderConnection,
|
||||
* failedEnvironment: ManagedEnvironment,
|
||||
* failedConnection: ProviderConnection,
|
||||
* readyEnvironment: ManagedEnvironment,
|
||||
* readyConnection: ProviderConnection,
|
||||
* }
|
||||
*/
|
||||
function spec353BrowserFixture(): array
|
||||
{
|
||||
[$user, $blockedEnvironment] = createUserWithTenant(
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$workspace = $blockedEnvironment->workspace()->firstOrFail();
|
||||
|
||||
$failedEnvironment = ManagedEnvironment::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Spec353 Verification Failed',
|
||||
]);
|
||||
$readyEnvironment = ManagedEnvironment::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Spec353 Provider Ready',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
(int) $failedEnvironment->getKey() => ['role' => 'owner'],
|
||||
(int) $readyEnvironment->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$blockedConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||
'managed_environment_id' => (int) $blockedEnvironment->getKey(),
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'display_name' => 'Spec353 Blocked Connection',
|
||||
'is_default' => true,
|
||||
]);
|
||||
$failedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'managed_environment_id' => (int) $failedEnvironment->getKey(),
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'display_name' => 'Spec353 Failed Connection',
|
||||
'is_default' => true,
|
||||
'verification_status' => ProviderVerificationStatus::Error->value,
|
||||
'last_error_message' => 'Spec353 verification failed during health check.',
|
||||
]);
|
||||
$readyConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||
'managed_environment_id' => (int) $readyEnvironment->getKey(),
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'display_name' => 'Spec353 Ready Connection',
|
||||
'is_default' => true,
|
||||
'last_health_check_at' => now(),
|
||||
]);
|
||||
|
||||
$missingPermissionKey = spec353BrowserApplicationPermissionKey();
|
||||
spec353BrowserSeedPermissionRows($blockedEnvironment, missingKeys: [$missingPermissionKey]);
|
||||
spec353BrowserSeedPermissionRows($failedEnvironment);
|
||||
spec353BrowserSeedPermissionRows($readyEnvironment);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'managed_environment_id' => (int) $failedEnvironment->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $failedConnection->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
return [
|
||||
'user' => $user,
|
||||
'workspace' => $workspace,
|
||||
'blockedEnvironment' => $blockedEnvironment,
|
||||
'blockedConnection' => $blockedConnection,
|
||||
'failedEnvironment' => $failedEnvironment,
|
||||
'failedConnection' => $failedConnection,
|
||||
'readyEnvironment' => $readyEnvironment,
|
||||
'readyConnection' => $readyConnection,
|
||||
];
|
||||
}
|
||||
|
||||
function spec353BrowserActAs(User $user, Workspace $workspace, ManagedEnvironment $environment): void
|
||||
{
|
||||
test()->actingAs($user)->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
|
||||
(string) $workspace->getKey() => (int) $environment->getKey(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function spec353BrowserScreenshot(string $name): string
|
||||
{
|
||||
return 'spec353-'.$name;
|
||||
}
|
||||
|
||||
function spec353CopyBrowserScreenshot(string $name): void
|
||||
{
|
||||
$filename = spec353BrowserScreenshot($name).'.png';
|
||||
$source = \Pest\Browser\Support\Screenshot::path($filename);
|
||||
$targetDirectory = repo_path('specs/353-provider-connections-resolution-guidance-v1/artifacts/screenshots');
|
||||
|
||||
if (! is_dir($targetDirectory)) {
|
||||
@mkdir($targetDirectory, 0755, true);
|
||||
}
|
||||
|
||||
if (! is_dir($targetDirectory) || ! is_writable($targetDirectory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_file($source)) {
|
||||
@copy($source, $targetDirectory.DIRECTORY_SEPARATOR.$filename);
|
||||
}
|
||||
}
|
||||
|
||||
function spec353BrowserTextContainsScript(string $selector, string $text): string
|
||||
{
|
||||
$encodedSelector = json_encode($selector);
|
||||
$encodedText = json_encode($text);
|
||||
|
||||
return <<<JS
|
||||
(() => {
|
||||
const element = document.querySelector($encodedSelector);
|
||||
|
||||
if (! element) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return element.textContent.replace(/\\s+/g, ' ').trim().includes($encodedText);
|
||||
})()
|
||||
JS;
|
||||
}
|
||||
|
||||
it('smokes provider readiness guidance across dashboard, required permissions, provider connections, and screenshots', function (): void {
|
||||
$fixture = spec353BrowserFixture();
|
||||
|
||||
spec353BrowserActAs($fixture['user'], $fixture['workspace'], $fixture['blockedEnvironment']);
|
||||
|
||||
$blockedPermissionsPage = visit(ManagedEnvironmentLinks::requiredPermissionsUrl($fixture['blockedEnvironment']))
|
||||
->resize(1440, 1100)
|
||||
->waitForText(__('localization.provider_guidance.required_permissions_missing_title'))
|
||||
->assertScript(
|
||||
spec353BrowserTextContainsScript(
|
||||
'[data-testid="provider-readiness-reason"]',
|
||||
__('localization.provider_guidance.required_application_permissions_reason'),
|
||||
),
|
||||
true,
|
||||
)
|
||||
->assertSee(__('localization.provider_guidance.action_open_admin_consent'))
|
||||
->assertScript('document.querySelector("[data-testid=\"provider-readiness-details\"]")?.open === false', true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->screenshot(true, spec353BrowserScreenshot('01-required-permissions-blocked'));
|
||||
|
||||
spec353CopyBrowserScreenshot('01-required-permissions-blocked');
|
||||
|
||||
$blockedPermissionsPage->resize(900, 1100)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
visit(EnvironmentDashboard::getUrl(panel: 'admin', tenant: $fixture['blockedEnvironment']))
|
||||
->waitForText('Provider readiness blocks evidence refresh')
|
||||
->click('[data-testid="tenant-dashboard-primary-next-action"] a')
|
||||
->waitForText(__('localization.provider_guidance.required_permissions_missing_title'))
|
||||
->assertScript(
|
||||
spec353BrowserTextContainsScript(
|
||||
'[data-testid="provider-readiness-reason"]',
|
||||
__('localization.provider_guidance.required_application_permissions_reason'),
|
||||
),
|
||||
true,
|
||||
)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
visit(ManagedEnvironmentLinks::providerConnectionUrl($fixture['failedConnection'], 'view', $fixture['failedEnvironment']))
|
||||
->resize(1440, 1100)
|
||||
->waitForText(__('localization.provider_guidance.verification_failed_title'))
|
||||
->assertSee(__('localization.provider_guidance.action_open_verification_operation'))
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->screenshot(true, spec353BrowserScreenshot('02-provider-connection-verification-failed'));
|
||||
|
||||
spec353CopyBrowserScreenshot('02-provider-connection-verification-failed');
|
||||
|
||||
visit(ManagedEnvironmentLinks::providerConnectionUrl($fixture['readyConnection'], 'view', $fixture['readyEnvironment']))
|
||||
->resize(1440, 1100)
|
||||
->waitForText(__('localization.provider_guidance.ready_title'))
|
||||
->assertSee(__('localization.provider_guidance.ready_reason'))
|
||||
->assertSee(__('localization.provider_guidance.action_open_environment_dashboard'))
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->screenshot(true, spec353BrowserScreenshot('03-provider-connection-ready'));
|
||||
|
||||
spec353CopyBrowserScreenshot('03-provider-connection-ready');
|
||||
});
|
||||
@ -17,7 +17,7 @@
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
test('unauthorized tenant filter yields an empty list without leaking metadata', function () {
|
||||
test('unauthorized provider-connections route scope resolves deny-as-not-found without leaking metadata', function () {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
$otherTenant = ManagedEnvironment::factory()->create();
|
||||
|
||||
@ -31,8 +31,7 @@
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ProviderConnectionResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertDontSee('Unauthorized ManagedEnvironment Connection');
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
test('non-members cannot reach provider connection detail target-scope metadata', function (): void {
|
||||
|
||||
@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\EnvironmentRequiredPermissions;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentPermission;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\User;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
config()->set('graph.client_id', 'spec353-platform-client');
|
||||
config()->set('graph.client_secret', 'spec353-platform-secret');
|
||||
config()->set('graph.managed_environment_id', 'organizations');
|
||||
});
|
||||
|
||||
function spec353RequiredPermissionsApplicationPermissionKey(): string
|
||||
{
|
||||
$permission = collect(spec283ConfiguredPermissionRows())
|
||||
->first(static fn (mixed $row): bool => is_array($row) && ($row['type'] ?? null) === 'application');
|
||||
|
||||
expect($permission)->not->toBeNull();
|
||||
|
||||
return (string) $permission['key'];
|
||||
}
|
||||
|
||||
function spec353RequiredPermissionsSeedRows(
|
||||
ManagedEnvironment $environment,
|
||||
array $missingKeys = [],
|
||||
array $errorKeys = [],
|
||||
?string $lastCheckedAt = null,
|
||||
): void {
|
||||
foreach (spec283ConfiguredPermissionRows() as $permission) {
|
||||
if (! is_array($permission)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$permissionKey = (string) ($permission['key'] ?? '');
|
||||
|
||||
if ($permissionKey === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
ManagedEnvironmentPermission::query()->updateOrCreate(
|
||||
[
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'permission_key' => $permissionKey,
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
],
|
||||
[
|
||||
'status' => in_array($permissionKey, $errorKeys, true)
|
||||
? 'error'
|
||||
: (in_array($permissionKey, $missingKeys, true) ? 'missing' : 'granted'),
|
||||
'details' => ['source' => 'spec353-required-permissions-test'],
|
||||
'last_checked_at' => $lastCheckedAt ? Carbon::parse($lastCheckedAt) : now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function spec353RequiredPermissionsComponent(User $user, ManagedEnvironment $environment, array $query = [])
|
||||
{
|
||||
test()->actingAs($user);
|
||||
setAdminPanelContext($environment);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $environment->workspace_id);
|
||||
|
||||
return Livewire::withQueryParams($query)->test(EnvironmentRequiredPermissions::class, [
|
||||
'environment' => $environment,
|
||||
]);
|
||||
}
|
||||
|
||||
it('renders guidance before the raw permissions matrix on the required-permissions page', function (): void {
|
||||
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$missingPermissionKey = spec353RequiredPermissionsApplicationPermissionKey();
|
||||
spec353RequiredPermissionsSeedRows($environment, missingKeys: [$missingPermissionKey]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id,
|
||||
])
|
||||
->get(RequiredPermissionsLinks::requiredPermissions($environment))
|
||||
->assertSuccessful();
|
||||
|
||||
$content = $response->getContent();
|
||||
$primaryActionUrl = RequiredPermissionsLinks::adminConsentPrimaryUrl($environment);
|
||||
|
||||
expect($content)->toContain(__('localization.provider_guidance.required_permissions_missing_title'))
|
||||
->and($content)->toContain($missingPermissionKey)
|
||||
->and(strpos($content, 'data-testid="provider-readiness-guidance-card"'))->toBeLessThan(strpos($content, $missingPermissionKey))
|
||||
->and($content)->toContain('data-testid="provider-readiness-primary-action"')
|
||||
->and($content)->toContain('href="'.e($primaryActionUrl).'"');
|
||||
});
|
||||
|
||||
it('shows run-verification guidance when stored verification evidence is stale or absent', function (): void {
|
||||
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'is_default' => true,
|
||||
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
||||
'last_health_check_at' => null,
|
||||
]);
|
||||
|
||||
spec353RequiredPermissionsSeedRows(
|
||||
$environment,
|
||||
lastCheckedAt: now()->subDays(45)->toIso8601String(),
|
||||
);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id,
|
||||
])
|
||||
->get(RequiredPermissionsLinks::requiredPermissions($environment))
|
||||
->assertSuccessful()
|
||||
->assertSee(__('localization.provider_guidance.verification_required_title'))
|
||||
->assertSee(__('localization.provider_guidance.action_run_provider_verification'))
|
||||
->assertSee('wire:click="runProviderVerification"', false)
|
||||
->assertDontSee('Start verification');
|
||||
});
|
||||
|
||||
it('renders required-permissions guidance without Graph, outbound http, or queue dispatches', function (): void {
|
||||
bindFailHardGraphClient();
|
||||
|
||||
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
spec353RequiredPermissionsSeedRows($environment);
|
||||
Queue::fake();
|
||||
|
||||
assertNoOutboundHttp(function () use ($user, $environment): void {
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id,
|
||||
])
|
||||
->get(RequiredPermissionsLinks::requiredPermissions($environment))
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
@ -2,13 +2,12 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\EnvironmentRequiredPermissions;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentPermission;
|
||||
use App\Models\User;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@ -53,74 +52,56 @@ function seedEnvironmentRequiredPermissionsFixture(ManagedEnvironment $tenant):
|
||||
]);
|
||||
}
|
||||
|
||||
function tenantRequiredPermissionsComponent(User $user, ManagedEnvironment $tenant, array $query = [])
|
||||
{
|
||||
test()->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$query = array_merge([
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
], $query);
|
||||
|
||||
return Livewire::withQueryParams($query)->test(EnvironmentRequiredPermissions::class);
|
||||
}
|
||||
|
||||
it('uses native table filters and search while keeping summary state aligned with visible rows', function (): void {
|
||||
it('uses route-seeded filters and search while keeping the visible permission matrix aligned', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
seedEnvironmentRequiredPermissionsFixture($tenant);
|
||||
|
||||
$component = tenantRequiredPermissionsComponent($user, $tenant)
|
||||
->assertTableFilterExists('status')
|
||||
->assertTableFilterExists('type')
|
||||
->assertTableFilterExists('features')
|
||||
->assertCanSeeTableRecords([
|
||||
'DeviceManagementApps.Read.All',
|
||||
'Group.Read.All',
|
||||
])
|
||||
->assertCanNotSeeTableRecords(['Reports.Read.All'])
|
||||
->assertSee('Missing application permissions')
|
||||
->assertSee('Guidance');
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(RequiredPermissionsLinks::requiredPermissions($tenant))
|
||||
->assertSuccessful()
|
||||
->assertSee('DeviceManagementApps.Read.All')
|
||||
->assertSee('Group.Read.All')
|
||||
->assertDontSee('Reports.Read.All')
|
||||
->assertSee('data-testid="provider-readiness-guidance-card"', false);
|
||||
|
||||
$component
|
||||
->filterTable('status', 'present')
|
||||
->filterTable('type', 'application')
|
||||
->searchTable('Reports')
|
||||
->assertCountTableRecords(1)
|
||||
->assertCanSeeTableRecords(['Reports.Read.All'])
|
||||
->assertCanNotSeeTableRecords([
|
||||
'DeviceManagementApps.Read.All',
|
||||
'Group.Read.All',
|
||||
]);
|
||||
|
||||
$viewModel = $component->instance()->viewModel();
|
||||
|
||||
expect($viewModel['overview']['counts'])->toBe([
|
||||
'missing_application' => 0,
|
||||
'missing_delegated' => 0,
|
||||
'present' => 1,
|
||||
'error' => 0,
|
||||
])
|
||||
->and(array_column($viewModel['permissions'], 'key'))->toBe(['Reports.Read.All'])
|
||||
->and($viewModel['copy']['application'])->toBe('DeviceManagementApps.Read.All');
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
||||
'status' => 'present',
|
||||
'type' => 'application',
|
||||
'search' => 'Reports',
|
||||
]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Reports.Read.All')
|
||||
->assertSee('1 permission(s) currently pass.');
|
||||
});
|
||||
|
||||
it('keeps copy payloads feature-scoped and shows the native no-matches state', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
seedEnvironmentRequiredPermissionsFixture($tenant);
|
||||
|
||||
$component = tenantRequiredPermissionsComponent($user, $tenant)
|
||||
->set('tableFilters.features.values', ['backup'])
|
||||
->assertSet('tableFilters.features.values', ['backup']);
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
||||
'status' => 'all',
|
||||
'features' => ['backup'],
|
||||
]))
|
||||
->assertSuccessful()
|
||||
->assertSee('DeviceManagementApps.Read.All')
|
||||
->assertSee('Group.Read.All')
|
||||
->assertDontSee('Reports.Read.All')
|
||||
->assertSee('Copy missing application permissions')
|
||||
->assertSee('Copy missing delegated permissions');
|
||||
|
||||
$viewModel = $component->instance()->viewModel();
|
||||
|
||||
expect($viewModel['copy']['application'])->toBe('DeviceManagementApps.Read.All')
|
||||
->and($viewModel['copy']['delegated'])->toBe('Group.Read.All');
|
||||
|
||||
$component
|
||||
->searchTable('no-such-permission')
|
||||
->assertCountTableRecords(0)
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
||||
'status' => 'all',
|
||||
'features' => ['backup'],
|
||||
'search' => 'no-such-permission',
|
||||
]))
|
||||
->assertSuccessful()
|
||||
->assertSee('No matches')
|
||||
->assertTableEmptyStateActionsExistInOrder(['clear_filters']);
|
||||
->assertSee('Clear filters');
|
||||
});
|
||||
|
||||
@ -1765,7 +1765,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
||||
$response->assertOk()
|
||||
->assertSee('Copy missing application permissions')
|
||||
->assertSee('Copy missing delegated permissions')
|
||||
->assertSee('Re-run verification')
|
||||
->assertSee('Permission handoff')
|
||||
->assertSee('Start verification');
|
||||
|
||||
$declaration = EnvironmentRequiredPermissions::actionSurfaceDeclaration();
|
||||
@ -2196,7 +2196,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(ViewProviderConnection::class, ['record' => $connection->getKey()])
|
||||
->assertActionVisible('grant_admin_consent');
|
||||
->assertActionVisible('open_required_permissions');
|
||||
|
||||
$instance = $component->instance();
|
||||
|
||||
@ -2219,7 +2219,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($primaryHeaderActions)->toEqual(['grant_admin_consent'])
|
||||
expect($primaryHeaderActions)->toEqual(['open_required_permissions'])
|
||||
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
||||
->and($moreGroup?->getLabel())->toBe('More')
|
||||
->and($moreActionNames)->toEqualCanonicalizing([
|
||||
|
||||
@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ViewProviderConnection;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentPermission;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\User;
|
||||
use App\Support\ManagedEnvironmentLinks;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
config()->set('graph.client_id', 'spec353-platform-client');
|
||||
config()->set('graph.client_secret', 'spec353-platform-secret');
|
||||
config()->set('graph.managed_environment_id', 'organizations');
|
||||
});
|
||||
|
||||
function spec353FeatureApplicationPermissionKey(): string
|
||||
{
|
||||
$permission = collect(spec283ConfiguredPermissionRows())
|
||||
->first(static fn (mixed $row): bool => is_array($row) && ($row['type'] ?? null) === 'application');
|
||||
|
||||
expect($permission)->not->toBeNull();
|
||||
|
||||
return (string) $permission['key'];
|
||||
}
|
||||
|
||||
function spec353FeatureSeedPermissionRows(
|
||||
ManagedEnvironment $environment,
|
||||
array $missingKeys = [],
|
||||
array $errorKeys = [],
|
||||
): void {
|
||||
foreach (spec283ConfiguredPermissionRows() as $permission) {
|
||||
if (! is_array($permission)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$permissionKey = (string) ($permission['key'] ?? '');
|
||||
|
||||
if ($permissionKey === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
ManagedEnvironmentPermission::query()->updateOrCreate(
|
||||
[
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'permission_key' => $permissionKey,
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
],
|
||||
[
|
||||
'status' => in_array($permissionKey, $errorKeys, true)
|
||||
? 'error'
|
||||
: (in_array($permissionKey, $missingKeys, true) ? 'missing' : 'granted'),
|
||||
'details' => ['source' => 'spec353-feature-test'],
|
||||
'last_checked_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function spec353ProviderConnectionDetailComponent(User $user, ManagedEnvironment $environment, ProviderConnection $connection)
|
||||
{
|
||||
test()->actingAs($user);
|
||||
setAdminPanelContext($environment);
|
||||
Filament::setTenant($environment, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $environment->workspace_id);
|
||||
|
||||
return Livewire::test(ViewProviderConnection::class, ['record' => $connection->getKey()]);
|
||||
}
|
||||
|
||||
it('shows decision-first guidance on the provider-connections list when no connection exists', function (): void {
|
||||
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id,
|
||||
])
|
||||
->get(ManagedEnvironmentLinks::providerConnectionsUrl($environment))
|
||||
->assertSuccessful()
|
||||
->assertSee(__('localization.provider_guidance.connection_missing_title'))
|
||||
->assertSee('Create provider connection')
|
||||
->assertSee('data-testid="provider-readiness-primary-action"', false)
|
||||
->assertSee(ProviderConnectionResource::getUrl('create', [
|
||||
'environment_id' => (int) $environment->getKey(),
|
||||
], panel: 'admin'))
|
||||
->assertDontSee('Fix provider')
|
||||
->assertDontSee('Grant permissions automatically');
|
||||
});
|
||||
|
||||
it('shows required-permissions guidance on provider-connection detail when application permissions are missing', function (): void {
|
||||
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
spec353FeatureSeedPermissionRows($environment, missingKeys: [spec353FeatureApplicationPermissionKey()]);
|
||||
|
||||
spec353ProviderConnectionDetailComponent($user, $environment, $connection)
|
||||
->assertSee(__('localization.provider_guidance.provider_readiness_blocked_title'))
|
||||
->assertSee(__('localization.provider_guidance.required_application_permissions_reason'))
|
||||
->assertActionVisible('open_required_permissions')
|
||||
->assertDontSee('Fix provider')
|
||||
->assertDontSee('Grant permissions automatically');
|
||||
});
|
||||
|
||||
it('shows verification-operation guidance on provider-connection detail when verification failed', function (): void {
|
||||
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'is_default' => true,
|
||||
'verification_status' => ProviderVerificationStatus::Error->value,
|
||||
'last_error_message' => 'Verification job failed for this provider connection.',
|
||||
]);
|
||||
|
||||
spec353FeatureSeedPermissionRows($environment);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
spec353ProviderConnectionDetailComponent($user, $environment, $connection)
|
||||
->assertSee(__('localization.provider_guidance.verification_failed_title'))
|
||||
->assertActionVisible('open_verification_operation');
|
||||
});
|
||||
|
||||
it('renders provider connection guidance without Graph, outbound http, or queue work on the render path', function (): void {
|
||||
bindFailHardGraphClient();
|
||||
|
||||
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
spec353FeatureSeedPermissionRows($environment);
|
||||
Queue::fake();
|
||||
|
||||
assertNoOutboundHttp(function () use ($user, $environment, $connection): void {
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id,
|
||||
])
|
||||
->get(ManagedEnvironmentLinks::providerConnectionsUrl($environment))
|
||||
->assertSuccessful();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id,
|
||||
])
|
||||
->get(ProviderConnectionResource::getUrl('view', [
|
||||
'record' => $connection,
|
||||
'environment_id' => (int) $environment->getKey(),
|
||||
], panel: 'admin'))
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
@ -10,7 +10,6 @@
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('keeps the route tenant authoritative when tenant-like query values are present', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
@ -127,24 +126,18 @@
|
||||
'last_checked_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$component = Livewire::withQueryParams([
|
||||
'tenant' => $tenant->external_id,
|
||||
'managed_environment_id' => (string) $otherTenant->getKey(),
|
||||
'status' => 'present',
|
||||
'type' => 'application',
|
||||
'features' => ['backup'],
|
||||
'search' => 'ManagedEnvironment',
|
||||
])->test(EnvironmentRequiredPermissions::class);
|
||||
|
||||
$component
|
||||
->assertSet('tableFilters.status.value', 'present')
|
||||
->assertSet('tableFilters.type.value', 'application')
|
||||
->assertSet('tableFilters.features.values', ['backup'])
|
||||
->assertSet('tableSearch', 'ManagedEnvironment');
|
||||
|
||||
expect($component->instance()->currentTenant()?->is($tenant))->toBeTrue();
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
||||
'tenant' => $tenant->external_id,
|
||||
'managed_environment_id' => (string) $otherTenant->getKey(),
|
||||
'status' => 'present',
|
||||
'type' => 'application',
|
||||
'features' => ['backup'],
|
||||
'search' => 'ManagedEnvironment',
|
||||
]))
|
||||
->assertSuccessful()
|
||||
->assertSee($tenant->getFilamentName())
|
||||
->assertDontSee($otherTenant->name)
|
||||
->assertSee('ManagedEnvironment.Read.All');
|
||||
});
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
|
||||
it('renders guidance, admin consent link, re-run verification, and copy actions on the required permissions page', function (): void {
|
||||
it('renders provider guidance, admin consent handoff, and copy actions on the required permissions page', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create([
|
||||
'external_id' => 'tenant-copy-actions-a',
|
||||
'app_client_id' => null,
|
||||
@ -16,11 +16,13 @@
|
||||
$this->actingAs($user)
|
||||
->get(RequiredPermissionsLinks::requiredPermissions($tenant))
|
||||
->assertSuccessful()
|
||||
->assertSee('Guidance')
|
||||
->assertSee('Permission handoff')
|
||||
->assertSee('Who can fix this?', false)
|
||||
->assertSee('Admin consent guide')
|
||||
->assertSee('learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent', false)
|
||||
->assertSee('Re-run verification')
|
||||
->assertDontSee('Start verification')
|
||||
->assertSee('Open provider connection')
|
||||
->assertSee('Open provider connection to run verification')
|
||||
->assertSee('Copy missing application permissions')
|
||||
->assertSee('Copy missing delegated permissions');
|
||||
});
|
||||
|
||||
@ -2,18 +2,19 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ManagedEnvironmentResource;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\ManagedEnvironmentLinks;
|
||||
|
||||
it('renders the no-data state with a canonical start verification link when no stored permission data exists', function (): void {
|
||||
it('renders the no-data state with a canonical provider connection link when no stored permission data exists', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$expectedUrl = ManagedEnvironmentResource::getUrl('view', ['record' => $tenant]);
|
||||
$expectedUrl = ManagedEnvironmentLinks::providerConnectionsUrl($tenant);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(RequiredPermissionsLinks::requiredPermissions($tenant))
|
||||
->assertSuccessful()
|
||||
->assertSee('No data available')
|
||||
->assertSee($expectedUrl, false)
|
||||
->assertSee('Start verification');
|
||||
->assertSee('Open provider connection')
|
||||
->assertDontSee('Start verification');
|
||||
});
|
||||
|
||||
@ -2,10 +2,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\EnvironmentRequiredPermissions;
|
||||
use App\Models\ManagedEnvironmentPermission;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('narrows required permissions results using filters and search', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
@ -60,67 +59,45 @@
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
])
|
||||
->test(EnvironmentRequiredPermissions::class)
|
||||
->assertSet('tableFilters.status.value', 'missing')
|
||||
$this->get(RequiredPermissionsLinks::requiredPermissions($tenant))
|
||||
->assertSuccessful()
|
||||
->assertSee('All required permissions are present')
|
||||
->assertCanNotSeeTableRecords([
|
||||
'Alpha.Read.All',
|
||||
'Beta.Read.All',
|
||||
'Gamma.Manage.All',
|
||||
]);
|
||||
->assertDontSee('Alpha.Read.All')
|
||||
->assertDontSee('Beta.Read.All')
|
||||
->assertDontSee('Gamma.Manage.All');
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
$this->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
||||
'status' => 'present',
|
||||
])
|
||||
->test(EnvironmentRequiredPermissions::class)
|
||||
->assertSet('tableFilters.status.value', 'present')
|
||||
->assertCanSeeTableRecords([
|
||||
'Alpha.Read.All',
|
||||
'Beta.Read.All',
|
||||
'Gamma.Manage.All',
|
||||
]);
|
||||
]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Alpha.Read.All')
|
||||
->assertSee('Beta.Read.All')
|
||||
->assertSee('Gamma.Manage.All');
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
$this->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
||||
'status' => 'present',
|
||||
'type' => 'delegated',
|
||||
])
|
||||
->test(EnvironmentRequiredPermissions::class)
|
||||
->assertSet('tableFilters.status.value', 'present')
|
||||
->assertSet('tableFilters.type.value', 'delegated')
|
||||
->assertCanSeeTableRecords(['Beta.Read.All'])
|
||||
->assertCanNotSeeTableRecords([
|
||||
'Alpha.Read.All',
|
||||
'Gamma.Manage.All',
|
||||
]);
|
||||
]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Beta.Read.All')
|
||||
->assertDontSee('Alpha.Read.All')
|
||||
->assertDontSee('Gamma.Manage.All');
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
$this->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
||||
'status' => 'all',
|
||||
'features' => ['backup'],
|
||||
])
|
||||
->test(EnvironmentRequiredPermissions::class)
|
||||
->assertSet('tableFilters.features.values', ['backup'])
|
||||
->assertCanSeeTableRecords([
|
||||
'Alpha.Read.All',
|
||||
'Gamma.Manage.All',
|
||||
])
|
||||
->assertCanNotSeeTableRecords(['Beta.Read.All']);
|
||||
]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Alpha.Read.All')
|
||||
->assertSee('Gamma.Manage.All')
|
||||
->assertDontSee('Beta.Read.All');
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
$this->get(RequiredPermissionsLinks::requiredPermissions($tenant, [
|
||||
'status' => 'all',
|
||||
'search' => 'delegated',
|
||||
])
|
||||
->test(EnvironmentRequiredPermissions::class)
|
||||
->assertSet('tableSearch', 'delegated')
|
||||
->assertCanSeeTableRecords(['Beta.Read.All'])
|
||||
->assertCanNotSeeTableRecords([
|
||||
'Alpha.Read.All',
|
||||
'Gamma.Manage.All',
|
||||
]);
|
||||
]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Beta.Read.All')
|
||||
->assertDontSee('Alpha.Read.All')
|
||||
->assertDontSee('Gamma.Manage.All');
|
||||
});
|
||||
|
||||
@ -2,19 +2,21 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\ManagedEnvironmentResource;
|
||||
use App\Support\ManagedEnvironmentLinks;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
|
||||
it('renders re-run verification and next-step links using canonical manage surfaces only', function (): void {
|
||||
it('renders no-data navigation and next-step links using canonical manage surfaces only', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$expectedUrl = ManagedEnvironmentResource::getUrl('view', ['record' => $tenant]);
|
||||
$expectedUrl = ManagedEnvironmentLinks::providerConnectionsUrl($tenant);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(RequiredPermissionsLinks::requiredPermissions($tenant))
|
||||
->assertSuccessful()
|
||||
->assertSee('Re-run verification')
|
||||
->assertSee('Open provider connection')
|
||||
->assertSee($expectedUrl, false)
|
||||
->assertDontSee('Start verification')
|
||||
->assertDontSee('wire:click="runProviderVerification"', false)
|
||||
->assertDontSee('/admin/t/', false);
|
||||
});
|
||||
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
->assertSee('Provider Health');
|
||||
});
|
||||
|
||||
it('renders the environment dashboard provider health summary from the default connection', function (): void {
|
||||
it('renders the environment dashboard provider health summary from the default connection posture', function (): void {
|
||||
$tenant = \App\Models\ManagedEnvironment::factory()->active()->create();
|
||||
[$user, $tenant] = createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
@ -63,7 +63,6 @@
|
||||
->get(ManagedEnvironmentResource::getUrl('view', ['record' => $tenant], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Provider Health')
|
||||
->assertSee('Canonical Summary Connection')
|
||||
->assertSee('Disabled')
|
||||
->assertSee('Healthy')
|
||||
->assertDontSee('Connected')
|
||||
|
||||
@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
|
||||
use App\Support\ManagedEnvironmentLinks;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\ResolutionGuidance\Adapters\ProviderReadinessResolutionAdapter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
use function Pest\Laravel\mock;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
config()->set('graph.client_id', 'spec353-platform-client');
|
||||
config()->set('graph.client_secret', 'spec353-platform-secret');
|
||||
config()->set('graph.managed_environment_id', 'organizations');
|
||||
});
|
||||
|
||||
function mockSpec353PermissionOverview(array $overview = []): void
|
||||
{
|
||||
mock(ManagedEnvironmentRequiredPermissionsViewModelBuilder::class, function ($mock) use ($overview): void {
|
||||
$mock->shouldReceive('build')->andReturn([
|
||||
'overview' => array_replace_recursive([
|
||||
'counts' => [
|
||||
'missing_application' => 0,
|
||||
'missing_delegated' => 0,
|
||||
'present' => 12,
|
||||
'error' => 0,
|
||||
],
|
||||
'freshness' => [
|
||||
'last_refreshed_at' => now()->toIso8601String(),
|
||||
'is_stale' => false,
|
||||
],
|
||||
], $overview),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
it('returns connection-missing guidance when no default provider connection exists', function (): void {
|
||||
[, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
mockSpec353PermissionOverview();
|
||||
|
||||
$case = app(ProviderReadinessResolutionAdapter::class)
|
||||
->forEnvironment($environment, ProviderReadinessResolutionAdapter::SURFACE_PROVIDER_CONNECTIONS_INDEX);
|
||||
|
||||
expect($case['key'])->toBe('provider_readiness.connection_missing')
|
||||
->and(data_get($case, 'primary_action.url'))->toBe(ManagedEnvironmentLinks::providerConnectionsUrl($environment))
|
||||
->and((string) data_get($case, 'status'))->toBe(__('localization.provider_guidance.status_blocked'));
|
||||
});
|
||||
|
||||
it('distinguishes admin-consent blockers from missing-permission blockers', function (): void {
|
||||
[, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'is_default' => true,
|
||||
'consent_status' => ProviderConsentStatus::Required->value,
|
||||
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
||||
]);
|
||||
|
||||
mockSpec353PermissionOverview([
|
||||
'counts' => [
|
||||
'missing_application' => 3,
|
||||
],
|
||||
]);
|
||||
|
||||
$case = app(ProviderReadinessResolutionAdapter::class)
|
||||
->forConnection($environment, $connection, ProviderReadinessResolutionAdapter::SURFACE_PROVIDER_CONNECTIONS_VIEW);
|
||||
|
||||
expect($case['key'])->toBe('provider_readiness.admin_consent_required')
|
||||
->and((string) data_get($case, 'primary_action.label'))->toBe(__('localization.provider_guidance.action_open_admin_consent'))
|
||||
->and((string) data_get($case, 'reason'))->toBe(__('localization.provider_guidance.admin_consent_required_reason'));
|
||||
});
|
||||
|
||||
it('returns required-permissions guidance when application permissions are missing', function (): void {
|
||||
[, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->verifiedHealthy()->create([
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
mockSpec353PermissionOverview([
|
||||
'counts' => [
|
||||
'missing_application' => 2,
|
||||
'present' => 10,
|
||||
],
|
||||
]);
|
||||
|
||||
$case = app(ProviderReadinessResolutionAdapter::class)
|
||||
->forConnection($environment, $connection, ProviderReadinessResolutionAdapter::SURFACE_PROVIDER_CONNECTIONS_VIEW);
|
||||
|
||||
expect($case['key'])->toBe('provider_readiness.required_permissions_missing')
|
||||
->and((string) data_get($case, 'primary_action.label'))->toBe(__('localization.provider_guidance.action_open_required_permissions'))
|
||||
->and((string) data_get($case, 'reason'))->toBe(__('localization.provider_guidance.required_application_permissions_reason'));
|
||||
});
|
||||
|
||||
it('returns verification-required guidance when verification has not run yet', function (): void {
|
||||
[, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'is_default' => true,
|
||||
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
||||
'last_health_check_at' => null,
|
||||
]);
|
||||
|
||||
mockSpec353PermissionOverview([
|
||||
'freshness' => [
|
||||
'last_refreshed_at' => null,
|
||||
'is_stale' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$case = app(ProviderReadinessResolutionAdapter::class)
|
||||
->forConnection($environment, $connection, ProviderReadinessResolutionAdapter::SURFACE_REQUIRED_PERMISSIONS);
|
||||
|
||||
expect($case['key'])->toBe('provider_readiness.verification_required')
|
||||
->and((string) data_get($case, 'primary_action.action_name'))->toBe('runProviderVerification')
|
||||
->and((string) data_get($case, 'primary_action.label'))->toBe(__('localization.provider_guidance.action_run_provider_verification'));
|
||||
});
|
||||
|
||||
it('returns verification-failed guidance with an operation link when a failed run exists', function (): void {
|
||||
[, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'is_default' => true,
|
||||
'verification_status' => ProviderVerificationStatus::Error->value,
|
||||
'last_error_message' => 'Graph verification failed for this connection.',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
mockSpec353PermissionOverview();
|
||||
|
||||
$case = app(ProviderReadinessResolutionAdapter::class)
|
||||
->forConnection($environment, $connection, ProviderReadinessResolutionAdapter::SURFACE_PROVIDER_CONNECTIONS_VIEW);
|
||||
|
||||
expect($case['key'])->toBe('provider_readiness.verification_failed')
|
||||
->and((string) data_get($case, 'primary_action.url'))->toBe(OperationRunLinks::view($run, $environment))
|
||||
->and((string) data_get($case, 'reason'))->toContain('Graph verification failed');
|
||||
});
|
||||
|
||||
it('returns a ready case when provider signals are satisfied', function (): void {
|
||||
[, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'is_default' => true,
|
||||
'last_health_check_at' => now(),
|
||||
]);
|
||||
|
||||
mockSpec353PermissionOverview();
|
||||
|
||||
$case = app(ProviderReadinessResolutionAdapter::class)
|
||||
->forConnection($environment, $connection, ProviderReadinessResolutionAdapter::SURFACE_PROVIDER_CONNECTIONS_VIEW);
|
||||
|
||||
expect($case['key'])->toBe('provider_readiness.ready')
|
||||
->and((string) data_get($case, 'primary_action.url'))->toBe(ManagedEnvironmentLinks::viewUrl($environment))
|
||||
->and((string) data_get($case, 'status'))->toBe(__('localization.provider_guidance.status_ready'));
|
||||
});
|
||||
@ -7,8 +7,8 @@ ## Summary
|
||||
| Metric | Count | Notes |
|
||||
| --- | ---: | --- |
|
||||
| UI route/page inventory rows | 98 | Includes dynamic route families and utility/auth endpoints. |
|
||||
| Unique page reports | 16 | `page-reports/*.md`; two inventory rows share existing reports where routes resolve to the same surface. |
|
||||
| Desktop screenshots | 15 | 12 rendered product pages and 3 blocker evidence screenshots. |
|
||||
| Unique page reports | 18 | `page-reports/*.md`; some inventory rows intentionally share existing reports where routes resolve to the same surface. |
|
||||
| Desktop screenshots | 15 | Route-inventory-linked desktop evidence, including strategic runtime captures and blocker evidence screenshots. |
|
||||
| Tablet screenshots | 0 | Deferred to later strategic mockup/implementation specs. |
|
||||
| Mobile screenshots | 0 | Deferred to later strategic mockup/implementation specs. |
|
||||
| Strategic Surface rows | 44 | Individual target treatment or explicit product decision required. |
|
||||
@ -56,7 +56,7 @@ ## Coverage By Area
|
||||
| Reviews | 6 | Review register and customer workspace captured; review pack/detail routes remain unresolved. |
|
||||
| Backup / restore | 6 | High-risk area; backup sets and restore runs were blocked by fixture capability. |
|
||||
| Settings / admin | 5 | Workspace and environment access are RBAC-sensitive and need later review. |
|
||||
| Provider / integration | 5 | Provider connections captured; create/edit/onboarding remain high-risk unresolved surfaces. |
|
||||
| Provider / integration | 5 | Provider connections and required permissions are captured; create/edit/onboarding remain high-risk unresolved surfaces. |
|
||||
| Findings | 5 | Queue/inbox patterns captured; finding detail needs individual triage target. |
|
||||
| Auth/access | 4 | Mostly flow/guard surfaces; copy and denial states should be pattern-reviewed. |
|
||||
| App shell | 4 | Workspace overview captured; chooser/context routes need domain pattern pass. |
|
||||
|
||||
@ -8,41 +8,49 @@ # UI-009 Provider Connections
|
||||
| Archetype | Provider / Integration |
|
||||
| Design depth | Strategic Surface |
|
||||
| Repo truth | repo-verified |
|
||||
| Screenshot | `../screenshots/desktop/ui-009-provider-connections.png` |
|
||||
| Browser status | Reached through workspace route. |
|
||||
| Screenshot | `specs/353-provider-connections-resolution-guidance-v1/artifacts/screenshots/ui-072-provider-connections.png` |
|
||||
| Browser status | Guidance-integrated; desktop screenshot saved under the Spec 353 artifact path. |
|
||||
|
||||
## First Five Seconds
|
||||
|
||||
The surface is the main integration authority. It should make connection health, scope, credentials/consent state, and safe next action legible without exposing secrets or raw provider errors by default.
|
||||
The surface now leads with one provider-readiness case, one dominant primary action, and only then the existing provider truth and safe secondary actions.
|
||||
|
||||
## Productization Review
|
||||
|
||||
- Decision-first: medium; table needs stronger next-action state.
|
||||
- Evidence-first: provider health and verification can support decisions.
|
||||
- Decision-first: strong; list and detail now explain the primary blocker before secondary operator actions.
|
||||
- Evidence-first: provider health and verification remain visible, but subordinate to the blocker and next step.
|
||||
- Context: workspace-owned provider connection surface.
|
||||
- Customer/auditor safety: internal/operator only.
|
||||
- Diagnostics: raw provider details must stay hidden or support-gated.
|
||||
- Diagnostics: technical details remain available without taking over the first-screen hierarchy.
|
||||
|
||||
## Information Inventory
|
||||
|
||||
Default content should include provider, connection type, target scope, health, permissions/consent, last verification, and next action. Diagnostic details should explain missing policy/scopes without raw secrets.
|
||||
Default content now includes:
|
||||
|
||||
- one dominant provider-readiness case
|
||||
- one primary action
|
||||
- provider, target scope, consent, verification, and capability truth
|
||||
- grouped secondary actions under `More`
|
||||
- technical details on demand
|
||||
|
||||
Diagnostic detail continues to explain missing permission, consent, and verification context without exposing raw provider payloads or secrets.
|
||||
|
||||
## Dangerous Actions
|
||||
|
||||
Credential rotation, disconnect/disable, reverify, and delete are high-impact. Target design must include authorization, confirmation, audit, and recovery guidance.
|
||||
Credential rotation, disable/enable, revert, and secret mutations remain grouped and capability-gated. The new guidance layer does not introduce auto-fix or auto-consent actions.
|
||||
|
||||
## Scores
|
||||
|
||||
| IA | Density | User Clarity | Sellability | Disclosure | Hierarchy | DS Fit | A11y | Responsive | Components | UX Writing | Perf |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 3 | 4 | 3 | 4 | 3 | 3 | 4 | 3 | 3 | 4 | 3 | 4 |
|
||||
| 4 | 4 | 4 | 5 | 4 | 5 | 4 | 4 | 4 | 4 | 4 | 4 |
|
||||
|
||||
## Top Issues
|
||||
|
||||
1. Needs stronger health/permission summary over raw integration detail.
|
||||
2. Dangerous provider actions require target confirmation and audit treatment.
|
||||
3. Provider-specific terminology should not leak into platform-core copy.
|
||||
1. Edit-page guidance is intentionally deferred; the primary operator journey is list/detail first.
|
||||
2. Existing `More` action density remains high, even though the primary readiness CTA is now clearer.
|
||||
3. Provider capability/detail language still assumes operator familiarity with Microsoft-style readiness concepts.
|
||||
|
||||
## Target Direction
|
||||
|
||||
P0 individual target mockup. This is a trust-critical setup and recovery surface.
|
||||
Implemented in Spec 353 as a bounded operator-guidance layer over existing provider readiness truth. Follow-up should focus on secondary-action density, not on another provider state framework.
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
# UI-077 Required Permissions
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Route | `/admin/workspaces/{workspace}/environments/{environment}/required-permissions` |
|
||||
| Source | `EnvironmentRequiredPermissions` |
|
||||
| Area / scope | Provider readiness / permission posture / environment |
|
||||
| Archetype | Provider / Integration |
|
||||
| Design depth | Domain Pattern Surface |
|
||||
| Repo truth | repo-verified |
|
||||
| Screenshot | `specs/353-provider-connections-resolution-guidance-v1/artifacts/screenshots/ui-077-required-permissions.png` |
|
||||
| Browser status | Guidance-integrated; desktop screenshot saved under the Spec 353 artifact path. |
|
||||
|
||||
## First Five Seconds
|
||||
|
||||
The page now answers the operator case first: what blocks readiness, why it matters, and what the safest next step is before the raw matrix appears.
|
||||
|
||||
## Productization Review
|
||||
|
||||
- Decision-first: strong; provider-readiness guidance and permission handoff sit above the matrix.
|
||||
- Evidence-first: counts, capability groups, freshness, and copy payloads still support the operator after the primary decision.
|
||||
- Context: environment-owned; route scope stays authoritative.
|
||||
- Customer/auditor safety: operator-only diagnostics, no fake remediation.
|
||||
- Diagnostics: raw permission detail stays secondary and technical details stay collapsed by default.
|
||||
|
||||
## Information Inventory
|
||||
|
||||
Default content now includes:
|
||||
|
||||
- one dominant provider-readiness case
|
||||
- one primary action
|
||||
- permission handoff guidance
|
||||
- permission counts and freshness
|
||||
- capability-group posture
|
||||
- copy payload actions
|
||||
- the native permission matrix
|
||||
- collapsed technical details
|
||||
|
||||
## Dangerous Actions
|
||||
|
||||
No direct consent execution or automatic repair is introduced. Verification starts only through the existing safe path and only when capability-gated.
|
||||
|
||||
## Scores
|
||||
|
||||
| IA | Density | User Clarity | Sellability | Disclosure | Hierarchy | DS Fit | A11y | Responsive | Components | UX Writing | Perf |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 4 | 4 | 5 | 5 | 4 | 5 | 4 | 4 | 4 | 4 | 4 | 4 |
|
||||
|
||||
## Top Issues
|
||||
|
||||
1. Summary and issues sections still carry some duplicate readiness language under the new top guidance.
|
||||
2. The page still asks the operator to understand Microsoft permission semantics in the detailed matrix.
|
||||
3. Browser artifacts should remain part of the spec evidence path because this page is highly hierarchy-sensitive.
|
||||
|
||||
## Target Direction
|
||||
|
||||
Implemented in Spec 353 as a bounded readiness-guidance layer over the existing permission-truth builder. Future work should refine copy and density, not rebuild the permission model.
|
||||
@ -77,12 +77,12 @@ # Route Inventory
|
||||
| UI-069 | `/admin/workspaces/{workspace}/environments/{environment}/policy-versions/{record}` | resource | Policy Version Detail | Inventory | environment record | route exists | environment + record entitlement | Drift / Diff | Evidence / Audit | Strategic Surface | repo-verified | - | - | Snapshot/diff detail, high evidence value. |
|
||||
| UI-070 | `/admin/workspaces/{workspace}/environments/{environment}/entra-groups` | resource | Entra Groups | Directory | environment-bound | route exists | environment entitlement | Inventory | Provider / Integration | Domain Pattern Surface | repo-verified | - | - | Provider-bound directory cache list. |
|
||||
| UI-071 | `/admin/workspaces/{workspace}/environments/{environment}/entra-groups/{record}` | resource | Entra Group Detail | Directory | environment record | route exists | environment + record entitlement | Inventory | Provider / Integration | Design-System Cleanup Surface | repo-verified | - | - | Detail page likely pattern-covered. |
|
||||
| UI-072 | `/admin/provider-connections` | resource | Provider Connections | Provider / integration | workspace hub | reachable | workspace provider capability | Provider / Integration | Settings / Admin | Strategic Surface | repo-verified | [desktop](screenshots/desktop/ui-009-provider-connections.png) | [report](page-reports/ui-009-provider-connections.md) | Critical integration and credential surface. |
|
||||
| UI-072 | `/admin/provider-connections` | resource | Provider Connections | Provider / integration | workspace hub | reachable | workspace provider capability | Provider / Integration | Settings / Admin | Strategic Surface | repo-verified | [desktop](../../specs/353-provider-connections-resolution-guidance-v1/artifacts/screenshots/ui-072-provider-connections.png) | [report](page-reports/ui-009-provider-connections.md) | Critical integration and credential surface. |
|
||||
| UI-073 | `/admin/provider-connections/create` | resource | Create Provider Connection | Provider / integration | workspace | route exists | provider manage capability | Provider / Integration | Settings / Admin | Strategic Surface | repo-verified | - | - | Credential/consent flow; individual review needed. |
|
||||
| UI-074 | `/admin/provider-connections/{record}` and `/edit` | resource | Provider Connection Detail/Edit | Provider / integration | workspace record | route exists | provider capability | Provider / Integration | Support / Diagnostics | Strategic Surface | repo-verified | - | - | Health/permission details must avoid raw-first UX. |
|
||||
| UI-075 | `/admin/settings/workspace` | page | Workspace Settings | Settings / admin | workspace hub | route exists | workspace settings view/manage capability | Settings / Admin | Commercial / Entitlements | Domain Pattern Surface | repo-verified | - | - | Workspace settings and lifecycle copy. |
|
||||
| UI-076 | `/admin/cross-environment-compare` | page | Cross Environment Compare | Governance | workspace analysis | route exists | workspace member + environment access | Drift / Diff | Workspace / Tenant Context | Strategic Surface | repo-verified | - | - | Portfolio comparison/promotion workflow. |
|
||||
| UI-077 | `/admin/workspaces/{workspace}/environments/{environment}/required-permissions` | page | Required Permissions | Provider / integration | environment-bound | route exists | environment entitlement | Provider / Integration | Support / Diagnostics | Domain Pattern Surface | repo-verified | - | - | Permission explanation/assist surface. |
|
||||
| UI-077 | `/admin/workspaces/{workspace}/environments/{environment}/required-permissions` | page | Required Permissions | Provider / integration | environment-bound | route exists | environment entitlement | Provider / Integration | Support / Diagnostics | Domain Pattern Surface | repo-verified | [desktop](../../specs/353-provider-connections-resolution-guidance-v1/artifacts/screenshots/ui-077-required-permissions.png) | [report](page-reports/ui-077-required-permissions.md) | Permission explanation and readiness-handoff surface. |
|
||||
| UI-078 | `/admin/consent/start` and `/admin/consent/callback` | controller/view | Admin Consent Flow | Provider / integration | workspace/onboarding | route exists | auth/onboarding state | Provider / Integration | Auth / Access | Domain Pattern Surface | repo-verified | - | - | External Microsoft consent handshake; not a normal product page. |
|
||||
| UI-079 | `/admin/rbac/start` and `/admin/rbac/callback` | controller | RBAC Delegated Auth Flow | Auth/access | workspace/onboarding | route exists | auth/RBAC state | Auth / Access | Provider / Integration | Domain Pattern Surface | repo-verified | - | - | External auth handshake. |
|
||||
| UI-080 | `BreakGlassRecovery` page class | file discovery | Break-glass Recovery | Support | admin/internal | hidden/unregistered in provider list | privileged use only | Support / Diagnostics | Utility / Internal | Manual Review Required | plausible-existing | - | - | File exists; no confirmed route in current route list. |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
@ -0,0 +1,36 @@
|
||||
# Requirements Checklist: Spec 353 - Provider Connections Resolution Guidance v1
|
||||
|
||||
Purpose: Validate preparation readiness only. This checklist does not certify implementation, runtime tests, or browser proof.
|
||||
|
||||
## Candidate And Guardrail
|
||||
|
||||
- [x] CHK001 The candidate source is explicit: direct user draft plus repo-real provider-readiness follow-up materials.
|
||||
- [x] CHK002 No completed spec package is being reopened or normalized back to preparation state.
|
||||
- [x] CHK003 The selected slice is narrower than a broad provider/onboarding redesign and fits the post-Spec-352 follow-through need.
|
||||
|
||||
## Repo Truth Alignment
|
||||
|
||||
- [x] CHK004 The prep records the exact current provider/runtime surfaces instead of inventing non-existent files.
|
||||
- [x] CHK005 The absence of `EnvironmentProviderHealth.php` is documented as a repo-truth deviation, not silently papered over.
|
||||
- [x] CHK006 The absence of an existing `ui-077-required-permissions.md` file is documented and handled as create-during-implementation work.
|
||||
- [x] CHK007 Existing provider-readiness signals are inventoried in `contracts/provider-readiness-signal-map.md`.
|
||||
|
||||
## Constitution And Scope
|
||||
|
||||
- [x] CHK008 The spec forbids new persistence, a new provider framework, a new permission model, and an onboarding rewrite.
|
||||
- [x] CHK009 Provider/platform boundary handling is explicit and keeps shared copy provider-neutral where possible.
|
||||
- [x] CHK010 Existing capability, audit, and `OperationRun` ownership remain explicit.
|
||||
- [x] CHK011 UI/Productization coverage is explicit for Provider Connections and Required Permissions.
|
||||
|
||||
## Test Governance And Readiness
|
||||
|
||||
- [x] CHK012 Unit, Feature/Livewire, and Browser coverage are named in the narrowest honest mix.
|
||||
- [x] CHK013 The plan names concrete runtime seams and likely touched files instead of relying on vague architecture intent.
|
||||
- [x] CHK014 The tasks are ordered, verifiable, and scoped to this slice only.
|
||||
- [x] CHK015 No open question blocks a bounded implementation loop.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- [x] Ready for implementation prep handoff.
|
||||
- [x] Main caveat recorded: Required Permissions needs a new page-report file because the referenced `ui-077` report is not yet present in repo truth.
|
||||
- [x] This checklist validates preparation only. No application implementation, runtime test execution, or browser smoke has been performed in this prep step.
|
||||
@ -0,0 +1,43 @@
|
||||
# Provider Readiness Signal Map: Spec 353
|
||||
|
||||
## Purpose
|
||||
|
||||
Inventory the existing repo-backed signals that can feed Provider Connections Resolution Guidance v1 without adding new provider truth or live render-time calls.
|
||||
|
||||
## Signal Inventory
|
||||
|
||||
| Signal | Source file / class | Repo-backed? | Scope | Current UI consumer | Possible guidance case | Possible action | Mutating? | Capability / audit / OperationRun behavior |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| No provider connection | `ManagedEnvironmentResource::providerConnectionState()`, `ProviderConnectionResolver`-adjacent usage, provider-connection queries | yes | environment -> workspace | dashboard/provider state helpers, create/list empty-state paths | `provider.connection_missing` | Open Provider Connections | no | navigation only; existing surface auth stays authoritative |
|
||||
| Connection disabled | `ProviderConnection.is_enabled`, `ProviderConnectionSurfaceSummary::readinessSummary()` | yes | record | Provider Connections list/detail, provider-state helper | `provider.connection_disabled` | Open Provider Connection | no | existing enable/disable mutations are confirmed, audited, capability-gated |
|
||||
| Consent not granted / required / failed / revoked | `ProviderConnection.consent_status`, `RequiredPermissionsLinks::adminConsentPrimaryUrl()`, `ProviderReasonTranslator` | yes | record + environment | Provider Connections, Required Permissions, blocked verification reports | `provider.admin_consent_required` | Grant admin consent / open required permissions | no | existing consent navigation only; no inline mutation |
|
||||
| Verification status unknown / pending / healthy / degraded / blocked / error | `ProviderConnection.verification_status`, `ProviderVerificationStatus`, `ProviderConnectionSurfaceSummary` | yes | record | Provider Connections, dashboard readiness cards | `provider.verification_required`, `provider.verification_failed`, `provider.ready` | Run verification / open last check run | no | existing run start uses `StartVerification` + `OperationRun` |
|
||||
| Last check timestamp | `ProviderConnection.last_health_check_at` | yes | record | Provider Connections list/detail | stale / recently checked nuance | Open last check run or re-run verification | no | proof-only; no mutation |
|
||||
| Last error reason code | `ProviderConnection.last_error_reason_code`, `ProviderReasonTranslator` | yes | record | Provider Connections diagnostics, verification reports | consent missing, credential missing, permission missing, auth failed, etc. | Open required permissions / provider connection / re-run verification | no | translated to next-step options; existing proof links only |
|
||||
| Last error message (sanitized) | `ProviderConnection.last_error_message`, sanitizers in resource | yes | record | Provider Connections diagnostics | secondary detail only | none primary; open proof if needed | no | must stay secondary and redacted |
|
||||
| Provider capability groups | `ManagedEnvironmentRequiredPermissionsViewModelBuilder::deriveCapabilityGroups()` | yes | environment | Required Permissions summary/cards | capability missing / at risk / supported | Open required permissions / re-run verification | no | derived from stored permission comparison |
|
||||
| Primary capability group | `ManagedEnvironmentRequiredPermissionsViewModelBuilder::primaryCapabilityGroup()` | yes | environment | Required Permissions summary | dominant missing capability | Review permissions / re-run verification | no | derived only |
|
||||
| Missing application permission count | `ManagedEnvironmentRequiredPermissionsViewModelBuilder::deriveCounts()` | yes | environment | Required Permissions summary/issues, dashboard required-permissions action | `provider.required_permissions_missing` | Open required permissions / admin consent | no | derived only |
|
||||
| Missing delegated permission count | `ManagedEnvironmentRequiredPermissionsViewModelBuilder::deriveCounts()` | yes | environment | Required Permissions summary/issues, dashboard delegated-permissions action | `provider.delegated_permissions_missing` | Open required permissions / re-run verification | no | derived only |
|
||||
| Freshness / stale permission evidence | `ManagedEnvironmentRequiredPermissionsViewModelBuilder::deriveFreshness()` | yes | environment | Required Permissions summary/issues | `provider.verification_stale` | Start / re-run verification | no | derived from stored timestamps only |
|
||||
| Overall permission posture | `ManagedEnvironmentRequiredPermissionsViewModelBuilder::deriveOverallStatus()` | yes | environment | Required Permissions badge | blocked / needs attention / ready | route to dominant provider action | no | derived only |
|
||||
| Existing dashboard provider blocker | `EnvironmentDashboardSummaryBuilder::providerOperatorGuidance()` | yes | environment | Environment Dashboard | continuity source only | Open required permissions | no | current dashboard selection layer only |
|
||||
| Last provider verification run | `OperationRun` rows of type `provider.connection.check`, `EditProviderConnection::view_last_check_run` | yes | environment + record | Edit Provider Connection, verification reports | `provider.verification_failed` / proof continuity | Open last check run | no | existing proof-only deep link |
|
||||
| Verification run start | `StartVerification`, `ProviderOperationStartResultPresenter` | yes | environment + record | existing provider actions | `provider.verification_required` | Run verification | yes | capability-gated, queued/deduped/blocked via existing `OperationRun` contract |
|
||||
| Required Permissions route and admin-consent URL | `RequiredPermissionsLinks` | yes | environment | dashboard/provider/detail links | permissions blocker remediation | Open required permissions / open admin consent | no | link-only helper; no render-time remote call should be introduced |
|
||||
|
||||
## Observed Gaps To Avoid Inventing Around
|
||||
|
||||
- There is no single repo-real `ProviderReadinessPresenter` yet.
|
||||
- There is no repo-real standalone Required Permissions page-report file today.
|
||||
- There is no repo-real dedicated provider-health page class to reuse.
|
||||
|
||||
These are implementation-shape gaps, not reasons to create new provider truth.
|
||||
|
||||
## Guidance-Shaping Rules Derived From The Map
|
||||
|
||||
1. Prefer stored counts, statuses, capability groups, freshness, and last-run proof over raw diagnostic detail.
|
||||
2. Use `ProviderReasonTranslator` and `VerificationLinkBehavior` for safe next-step phrasing before adding another local mapping table.
|
||||
3. Treat admin-consent navigation and verification-run start as existing safe actions; do not invent auto-remediation.
|
||||
4. Keep raw permission rows, copy payloads, and sanitized error messages secondary.
|
||||
5. Keep the guidance request-scoped and DB-local.
|
||||
317
specs/353-provider-connections-resolution-guidance-v1/plan.md
Normal file
317
specs/353-provider-connections-resolution-guidance-v1/plan.md
Normal file
@ -0,0 +1,317 @@
|
||||
# Implementation Plan: Spec 353 - Provider Connections Resolution Guidance v1
|
||||
|
||||
- Branch: `353-provider-connections-resolution-guidance-v1`
|
||||
- Date: 2026-06-04
|
||||
- Spec: `specs/353-provider-connections-resolution-guidance-v1/spec.md`
|
||||
- Input: Spec 353 + repo inspection of Provider Connections, Environment Required Permissions, provider verification/permission helpers, and Environment Dashboard provider blocker guidance.
|
||||
|
||||
## Summary
|
||||
|
||||
Add one derived provider-readiness guidance layer to the existing Provider Connections and Required Permissions surfaces, and make the Environment Dashboard provider CTA land on a target page that explains the same blocker clearly.
|
||||
|
||||
The implementation stays narrow:
|
||||
|
||||
- reuse existing provider connection, permission, capability-group, verification, and last-operation truth
|
||||
- keep provider actions navigation-first and repo-backed
|
||||
- preserve onboarding, verification execution, permission calculation, and audit ownership
|
||||
- avoid live provider calls during render
|
||||
|
||||
## Technical Context
|
||||
|
||||
- Language/Version: PHP 8.4.15, Laravel 12.52.x
|
||||
- UI stack: Filament 5.2.x, Livewire 4.x
|
||||
- Database: PostgreSQL, no schema change planned
|
||||
- Testing: Pest unit + feature/Livewire + one strategic browser smoke
|
||||
- Validation lanes: fast-feedback + confidence + browser
|
||||
- Local runtime posture: Sail-first
|
||||
- Deployment/runtime impact: no expected env, migration, queue-family, scheduler, storage, or panel/provider change
|
||||
- Global search: unchanged; `ProviderConnectionResource` remains not globally searchable
|
||||
|
||||
## Current Repo Truth That Constrains The Slice
|
||||
|
||||
- `ProviderConnectionResource` already exposes provider readiness signals as columns/infolist entries:
|
||||
- consent status
|
||||
- verification status
|
||||
- provider capability
|
||||
- last health check
|
||||
- last error reason/message
|
||||
- `ViewProviderConnection` already keeps one primary `Grant admin consent` header action and groups the rest under `More`.
|
||||
- `EditProviderConnection` already surfaces `View last check run` and the existing provider-operation actions, but it is still an action-centered maintenance surface rather than a first-screen readiness-guidance surface.
|
||||
- `EnvironmentRequiredPermissions` already renders a summary, capability-group overview, issue cards, copy payloads, and a technical-details section around a native permission matrix.
|
||||
- `ManagedEnvironmentRequiredPermissionsViewModelBuilder` already derives:
|
||||
- overall readiness (`blocked` / `needs_attention` / `ready`)
|
||||
- counts
|
||||
- feature impacts
|
||||
- capability groups
|
||||
- freshness
|
||||
- `EnvironmentDashboardSummaryBuilder` already elevates `required_permissions` / `delegated_permissions` into `operatorGuidance`.
|
||||
- There is no current `EnvironmentProviderHealth` page class to reuse; provider-health truth is distributed across existing provider/verification helpers.
|
||||
|
||||
## Domain / Model Implications
|
||||
|
||||
- No schema or migration change is planned.
|
||||
- No new persisted readiness entity, status column, enum family, or provider registry is allowed in this slice.
|
||||
- The narrowest acceptable implementation shape is one derived provider-guidance adapter over existing `ProviderConnectionResolver`, `ManagedEnvironmentRequiredPermissionsViewModelBuilder`, and `OperationRun` proof truth.
|
||||
- Existing ownership boundaries remain unchanged:
|
||||
- provider connection truth stays provider/resource owned
|
||||
- required-permissions truth stays builder/view-model owned
|
||||
- verification proof stays `OperationRun` owned
|
||||
|
||||
## UI / Filament / Livewire Implications
|
||||
|
||||
- Filament v5 continues to run on Livewire v4.x; no version or API drift is permitted.
|
||||
- No panel/provider registration change is allowed; `apps/platform/bootstrap/providers.php` remains untouched.
|
||||
- `ProviderConnectionResource` stays not globally searchable.
|
||||
- Existing destructive or high-risk actions must keep their current confirmation, authorization, notification, and audit posture.
|
||||
- No new asset registration is planned, so there is no expected `filament:assets` deployment change for this spec.
|
||||
|
||||
## RBAC / Policy Implications
|
||||
|
||||
- Workspace membership and environment entitlement remain the only scope authorities.
|
||||
- Existing provider capabilities continue to decide whether actions are visible, disabled, or executable.
|
||||
- Guidance selection itself must remain safe for unauthorized users by operating only on already-authorized page state.
|
||||
|
||||
## Audit / Logging / Evidence Implications
|
||||
|
||||
- Existing provider verification start/result handling and `OperationRun` proof links are reused unchanged.
|
||||
- No new audit stream, notification family, or evidence artifact is planned.
|
||||
- The implementation must keep provider-readiness render paths read-only and side-effect free.
|
||||
|
||||
## Data / Migration Implications
|
||||
|
||||
- No database migration, backfill, or persisted projection is planned.
|
||||
- All derived readiness output must be request-local and DB-backed from already stored truth.
|
||||
- Compatibility shims are not justified because no data shape replacement is proposed in this prep slice.
|
||||
|
||||
## Rollout Considerations
|
||||
|
||||
- No feature flag is expected because the slice is a bounded presentation improvement over existing repo truth.
|
||||
- Staging validation should still prove three operator states explicitly:
|
||||
- missing permissions
|
||||
- verification follow-up
|
||||
- calm ready state
|
||||
- Production risk is limited to UI guidance hierarchy and wrong-link regressions, so tests and browser smoke remain the main rollout controls.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**:
|
||||
- Provider Connections list and view, plus edit-page action/proof continuity
|
||||
- Environment Required Permissions
|
||||
- dashboard target continuity only
|
||||
- **Affected surfaces**:
|
||||
- `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`
|
||||
- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`
|
||||
- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`
|
||||
- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`
|
||||
- `apps/platform/app/Filament/Pages/EnvironmentRequiredPermissions.php`
|
||||
- `apps/platform/resources/views/filament/pages/environment-required-permissions.blade.php`
|
||||
- `apps/platform/app/Support/EnvironmentDashboard/EnvironmentDashboardSummaryBuilder.php`
|
||||
- **Native vs custom**:
|
||||
- preserve native Filament list/detail/page ownership
|
||||
- avoid new custom page families
|
||||
- allow one bounded derived guidance presenter/adapter if necessary
|
||||
- **Shared-family relevance**:
|
||||
- status messaging
|
||||
- next-action guidance
|
||||
- operation proof links
|
||||
- dashboard-to-detail continuity
|
||||
- **Required tests / smoke**:
|
||||
- focused unit tests for derived guidance selection
|
||||
- feature tests for Provider Connections and Required Permissions
|
||||
- one bounded browser smoke for priority and target continuity
|
||||
- **UI/Productization coverage**:
|
||||
- update `ui-009-provider-connections.md`
|
||||
- create/update `ui-077-required-permissions.md`
|
||||
- close the current UI-audit registry for UI-072/UI-077 in `route-inventory.md`
|
||||
- update `design-coverage-matrix.md` only if route-inventory counts or classification change
|
||||
- update `strategic-surfaces.md` only if UI-077 is intentionally promoted from its current registry classification
|
||||
- use `unresolved-pages.md` if browser or screenshot evidence cannot be durably stored under the Spec 353 artifact path
|
||||
- save screenshots under the Spec 353 artifact path, or document the host-visible artifact blocker honestly
|
||||
|
||||
## Shared Pattern And System Fit
|
||||
|
||||
- **Preferred reuse path**:
|
||||
- current dashboard `operatorGuidance` structure
|
||||
- current `ManagedEnvironmentRequiredPermissionsViewModelBuilder` truth
|
||||
- current `ProviderConnectionResolver` truth
|
||||
- current `OperationRunLinks` / verification-run start UX
|
||||
- **Likely implementation shape**:
|
||||
- one bounded derived adapter under a current provider/guidance support path
|
||||
- adjacent seams such as `ProviderReasonTranslator` / `VerificationLinkBehavior` may stay contextual helpers, but they do not need to become the primary runtime path
|
||||
- **Avoid**:
|
||||
- new provider framework
|
||||
- new persisted readiness state
|
||||
- new route/query contract unless absolutely required for continuity
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
Spec 353 does not create a new `OperationRun` type. It reuses the existing provider verification path:
|
||||
|
||||
- `StartVerification`
|
||||
- `ProviderOperationStartResultPresenter`
|
||||
- `OperationRunLinks`
|
||||
|
||||
Implementation responsibility is limited to when these existing links/actions become the primary or secondary action for a derived blocker.
|
||||
|
||||
## Likely Runtime Files
|
||||
|
||||
| Area | Repo-real files |
|
||||
|---|---|
|
||||
| Provider Connections runtime | `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `Pages/ListProviderConnections.php`, `Pages/ViewProviderConnection.php`, `Pages/EditProviderConnection.php` |
|
||||
| Required Permissions runtime | `apps/platform/app/Filament/Pages/EnvironmentRequiredPermissions.php`, `apps/platform/resources/views/filament/pages/environment-required-permissions.blade.php` |
|
||||
| Permission truth | `apps/platform/app/Services/Intune/ManagedEnvironmentRequiredPermissionsViewModelBuilder.php` |
|
||||
| Provider readiness truth | `apps/platform/app/Support/ResolutionGuidance/Adapters/ProviderReadinessResolutionAdapter.php`, `apps/platform/app/Services/Providers/ProviderConnectionResolver.php`, existing `OperationRun` proof, with `ProviderReasonTranslator.php` / `VerificationLinkBehavior.php` only where directly helpful |
|
||||
| Dashboard continuity | `apps/platform/app/Support/EnvironmentDashboard/EnvironmentDashboardSummaryBuilder.php` |
|
||||
| Links/helpers | `apps/platform/app/Support/Links/RequiredPermissionsLinks.php`, existing `ManagedEnvironmentLinks` / `OperationRunLinks` |
|
||||
| UI audit docs | `docs/ui-ux-enterprise-audit/page-reports/ui-009-provider-connections.md`, `docs/ui-ux-enterprise-audit/page-reports/ui-077-required-permissions.md` |
|
||||
|
||||
## Likely Test Files
|
||||
|
||||
| Layer | Planned file |
|
||||
|---|---|
|
||||
| Unit | `apps/platform/tests/Unit/ResolutionGuidance/Spec353ProviderReadinessResolutionAdapterTest.php` |
|
||||
| Feature | `apps/platform/tests/Feature/ProviderConnections/Spec353ProviderConnectionGuidanceTest.php` |
|
||||
| Feature/Filament | `apps/platform/tests/Feature/Filament/Spec353RequiredPermissionsGuidanceTest.php` |
|
||||
| Browser | `apps/platform/tests/Browser/Spec353ProviderReadinessGuidanceSmokeTest.php` |
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### Phase 0 - Repo Truth Gate
|
||||
|
||||
1. Re-read `spec.md`, `plan.md`, `tasks.md`, `repo-truth-map.md`, `checklists/requirements.md`, and `contracts/provider-readiness-signal-map.md`.
|
||||
2. Re-verify the current runtime truth in the provider/resource/page/helper files listed above.
|
||||
3. Keep draft mismatches explicit:
|
||||
- no `EnvironmentProviderHealth.php`
|
||||
- no existing `ui-077-required-permissions.md`
|
||||
4. Confirm no migration, package, env var, queue-family, storage, panel/provider, or global-search change is required.
|
||||
|
||||
### Phase 1 - Tests First
|
||||
|
||||
1. Add unit coverage for deterministic guidance selection:
|
||||
- no provider connection
|
||||
- disabled/unusable connection
|
||||
- missing application permissions
|
||||
- missing delegated permissions
|
||||
- verification blocked/error
|
||||
- verification stale/unknown
|
||||
- ready/no urgent action
|
||||
2. Add feature coverage for Provider Connections:
|
||||
- one dominant case
|
||||
- one primary action
|
||||
- secondary proof links only when repo-backed
|
||||
- no fake remediation buttons
|
||||
3. Add feature coverage for Required Permissions:
|
||||
- blocker guidance renders before the raw matrix
|
||||
- application/delegated/stale cases map to the expected action hierarchy
|
||||
- copy payloads and technical details remain secondary
|
||||
4. Add one browser smoke:
|
||||
- dashboard -> target continuity
|
||||
- permissions-missing state
|
||||
- verification follow-up state
|
||||
- calm ready state
|
||||
|
||||
### Phase 2 - Derived Guidance Contract
|
||||
|
||||
1. Choose the narrowest implementation shape:
|
||||
- prefer one bounded provider-guidance adapter/presenter
|
||||
- avoid broadening `ReviewPackOutputResolutionAdapter` into a provider super-framework
|
||||
2. Build one derived provider-readiness payload with:
|
||||
- key
|
||||
- title
|
||||
- status
|
||||
- tone
|
||||
- reason
|
||||
- impact
|
||||
- primary action
|
||||
- secondary actions
|
||||
- details
|
||||
- source metadata
|
||||
3. Consume only stored truth:
|
||||
- `ProviderConnection` state
|
||||
- permission counts and capability groups
|
||||
- freshness
|
||||
- last `provider.connection.check` run
|
||||
- reason translation / next-step mapping
|
||||
4. Keep priority ordering explicit and narrow.
|
||||
|
||||
### Phase 3 - Provider Connections Integration
|
||||
|
||||
1. Add a top guidance presentation to Provider Connections list/view without removing the existing table columns, infolist truth, or safe action groups.
|
||||
2. Reuse existing repo-backed targets:
|
||||
- `Grant admin consent`
|
||||
- `View last check run`
|
||||
- `Run verification`
|
||||
- `Open required permissions`
|
||||
3. Keep destructive/high-impact actions unchanged:
|
||||
- confirmation
|
||||
- authorization
|
||||
- audit
|
||||
- notifications
|
||||
4. Do not enable provider actions merely because guidance makes them more visible.
|
||||
|
||||
### Phase 4 - Required Permissions Integration
|
||||
|
||||
1. Replace the current guidance-first-but-generic summary with one explicit blocker case.
|
||||
2. Reuse the current capability-group and counts truth before inventing any new provider state.
|
||||
3. Keep copy payloads and the raw matrix secondary.
|
||||
4. If verification or last-operation proof is stronger than the raw permission count, the page may surface that as the primary blocker only when repo truth clearly supports it.
|
||||
|
||||
### Phase 5 - Dashboard Target Continuity
|
||||
|
||||
1. Keep the Environment Dashboard provider guidance selection logic narrow.
|
||||
2. Adjust continuity only if required so the linked target surface shows the same blocker class clearly.
|
||||
3. Avoid adding a fragile dashboard-only query contract when current route state is enough.
|
||||
|
||||
### Phase 6 - Copy, Audit, And Browser Proof
|
||||
|
||||
1. Update or add only the copy required for:
|
||||
- provider readiness blocked
|
||||
- required permissions missing
|
||||
- provider verification required
|
||||
- provider verification failed
|
||||
- provider connection ready
|
||||
2. Update `ui-009-provider-connections.md`.
|
||||
3. Create/update `ui-077-required-permissions.md`.
|
||||
4. Close the UI-audit registry around UI-072/UI-077:
|
||||
- update `route-inventory.md` report/screenshot references
|
||||
- update `design-coverage-matrix.md` only if classification/counts change
|
||||
- update `strategic-surfaces.md` only if UI-077 is intentionally promoted from its current registry classification
|
||||
- use `unresolved-pages.md` if browser evidence cannot be durably stored
|
||||
5. Save screenshots under `specs/353-provider-connections-resolution-guidance-v1/artifacts/screenshots/` or record the container-local artifact blocker explicitly during close-out if Pest Browser cannot persist host-visible files.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- **Unit**:
|
||||
- deterministic blocker ranking and payload mapping
|
||||
- **Feature/Livewire**:
|
||||
- Provider Connections list/detail integration
|
||||
- Required Permissions page integration
|
||||
- dashboard target continuity if feature-level proof is cheaper than browser-only proof
|
||||
- **Browser**:
|
||||
- one small smoke proving real hierarchy and continuity on the rendered UI
|
||||
- **Render-path guard**:
|
||||
- fail if provider guidance rendering pulls live provider or Graph work into the page request
|
||||
|
||||
## Risk Controls
|
||||
|
||||
- **Onboarding overlap**: preserve onboarding links as secondary and do not rewrite onboarding ownership.
|
||||
- **UI overload**: keep one dominant case and collapse technical detail.
|
||||
- **Fake remediation**: do not add unsupported buttons or pretend provider fixes can happen inline.
|
||||
- **Render-time remote calls**: limit inputs to existing DB-backed/provider-state helpers only.
|
||||
- **Audit coverage drift**: keep existing provider action audit posture unchanged and document any unavoidable copy-only discrepancy.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
- Inventory-first / stored-truth-first: PASS
|
||||
- Read/write separation: PASS
|
||||
- No live provider calls during render: PASS target
|
||||
- Workspace/environment isolation: PASS target
|
||||
- Shared pattern first: PASS if current dashboard/operator-guidance shape is reused
|
||||
- Provider boundary neutrality: PASS if shared copy stays provider-neutral and details keep provider-specific labels
|
||||
- UI/Productization coverage: PASS once `ui-009`, `ui-077`, and the required UI-audit registry close-out around UI-072/UI-077 are handled
|
||||
- Test governance: PASS with explicit Unit + Feature + Browser split
|
||||
- Proportionality / anti-bloat: PASS if the guidance layer remains derived-only and no new persisted truth or framework is added
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None blocking prep readiness. The main repo-truth caveat is the missing existing `ui-077-required-permissions.md`, which is handled as a create-during-implementation requirement rather than an unresolved product decision.
|
||||
@ -0,0 +1,100 @@
|
||||
# Repo Truth Map: Spec 353 - Provider Connections Resolution Guidance v1
|
||||
|
||||
Status: draft / prep-ready
|
||||
Branch: `353-provider-connections-resolution-guidance-v1`
|
||||
Date: 2026-06-04
|
||||
Baseline commit before prep branch: `9a564d6b` (`feat: environment dashboard operator guidance consolidation (spec 352) (#423)`)
|
||||
|
||||
## Branch And Working-Tree Safety
|
||||
|
||||
- Starting branch before prep: `platform-dev`
|
||||
- Initial `git status --short --branch`: clean
|
||||
- Initial `git diff --stat`: empty
|
||||
- Spec Kit branch created via repo script:
|
||||
- `./.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name 'provider-connections-resolution-guidance-v1' --number 353 'Provider Connections Resolution Guidance v1'`
|
||||
- Current branch after setup: `353-provider-connections-resolution-guidance-v1`
|
||||
- Current uncommitted change before writing prep artifacts: only `specs/353-provider-connections-resolution-guidance-v1/`
|
||||
|
||||
## Why 353 Was Selected
|
||||
|
||||
- Spec 352 intentionally made provider blockers the dominant Environment Dashboard guidance case.
|
||||
- The dashboard now links operators into Provider Connections / Required Permissions, but those destination surfaces still read diagnostics-first.
|
||||
- Provider readiness is already called out as a grouped P1 follow-up in:
|
||||
- `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
|
||||
- `docs/ui-ux-enterprise-audit/target-experience-briefs/provider-readiness.md`
|
||||
- The slice is small and repo-ready because the current repo already has the necessary underlying truth:
|
||||
- provider connection status fields
|
||||
- permission counts and capability groups
|
||||
- verification runs and proof links
|
||||
- dashboard operator guidance precedence
|
||||
|
||||
## Why Close Alternatives Were Deferred
|
||||
|
||||
- Governance Inbox follow-through is already farther along in the current spec sequence and is not the blocker named by the user for this prep.
|
||||
- Customer-facing localization is still valuable but does not close the provider-blocker destination gap opened by Spec 352.
|
||||
- Broader onboarding/provider redesign would be too large for the current narrow follow-up slice.
|
||||
|
||||
## Completed-Spec Guardrail Result
|
||||
|
||||
| Related spec | Current signal | Handling for Spec 353 |
|
||||
|---|---|---|
|
||||
| Spec 338 | checked implementation tasks and browser-smoke history | completed baseline; do not reopen scope contracts |
|
||||
| Spec 339 | checked implementation tasks over provider scope hardening | completed baseline; reuse scope rules only |
|
||||
| Spec 350 | shared guidance framework and contract artifacts already exist | context only; reuse, do not reopen |
|
||||
| Spec 351 | repo-real review-output action semantics with residual browser notes | reuse shared guidance lessons only; do not hide residual notes |
|
||||
| Spec 352 | `repo-truth-map.md` says `Status: implemented` | immediate dependency; follow dashboard target continuity only |
|
||||
|
||||
No `specs/353-*` package or `353-*` branch existed before this prep.
|
||||
|
||||
## Runtime Seam Inventory
|
||||
|
||||
| Surface / seam | Repo-real path(s) | Notes |
|
||||
|---|---|---|
|
||||
| Provider Connections list | `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `Pages/ListProviderConnections.php` | Table already shows consent, verification, provider capability, last check, and current environment filter behavior |
|
||||
| Provider Connections view | `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php` | Already exposes primary `Grant admin consent` CTA and grouped secondary actions |
|
||||
| Provider Connections edit | `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` | Already exposes `View last check run` plus existing provider operations |
|
||||
| Required Permissions page | `apps/platform/app/Filament/Pages/EnvironmentRequiredPermissions.php`, `apps/platform/resources/views/filament/pages/environment-required-permissions.blade.php` | Already has summary, guidance copy, issue cards, copy payloads, and technical-details disclosure |
|
||||
| Permission posture builder | `apps/platform/app/Services/Intune/ManagedEnvironmentRequiredPermissionsViewModelBuilder.php` | Already derives overall status, counts, capability groups, feature impacts, and freshness |
|
||||
| Provider readiness summary | `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php` | Already derives consent state, verification state, readiness summary, and primary provider capability |
|
||||
| Provider blocker translation | `apps/platform/app/Support/Providers/ProviderReasonTranslator.php`, `apps/platform/app/Support/Verification/VerificationLinkBehavior.php` | Already translates reason codes and classifies required-permissions / provider-connections paths as internal diagnostic targets |
|
||||
| Dashboard provider blocker | `apps/platform/app/Support/EnvironmentDashboard/EnvironmentDashboardSummaryBuilder.php` | Already promotes `required_permissions` / `delegated_permissions` into provider `operatorGuidance` |
|
||||
| Onboarding/provider state helper | `apps/platform/app/Filament/Resources/ManagedEnvironmentResource.php` | Already has `providerConnectionState()` and related provider-state presentation helpers |
|
||||
|
||||
## Draft-To-Repo Deviations That Must Stay Explicit
|
||||
|
||||
| User draft assumption | Repo truth | Spec 353 handling |
|
||||
|---|---|---|
|
||||
| `EnvironmentProviderHealth.php` exists | no such page class exists | do not invent it; use existing provider readiness helpers |
|
||||
| `ui-077-required-permissions.md` already exists | no file currently exists | create it during implementation instead of claiming an update |
|
||||
| Provider Connections still needs a primary CTA | view page already has `Grant admin consent` | guidance must coexist with the existing safe CTA hierarchy |
|
||||
| Required Permissions is mostly a raw list | page already has summary, issue cards, and technical details | productize current page instead of rebuilding it |
|
||||
| Dashboard still needs provider priority work | provider blockers already outrank review-output in Spec 352 | focus on destination continuity, not a new dashboard ranking spec |
|
||||
|
||||
## Likely Implementation Files
|
||||
|
||||
- `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`
|
||||
- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`
|
||||
- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`
|
||||
- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`
|
||||
- `apps/platform/app/Filament/Pages/EnvironmentRequiredPermissions.php`
|
||||
- `apps/platform/resources/views/filament/pages/environment-required-permissions.blade.php`
|
||||
- bounded provider-guidance support class under `apps/platform/app/Support/...` only if needed
|
||||
- `apps/platform/app/Support/EnvironmentDashboard/EnvironmentDashboardSummaryBuilder.php` only if continuity needs a narrow adjustment
|
||||
- `docs/ui-ux-enterprise-audit/page-reports/ui-009-provider-connections.md`
|
||||
- `docs/ui-ux-enterprise-audit/page-reports/ui-077-required-permissions.md`
|
||||
|
||||
## Files Explicitly Out Of Scope
|
||||
|
||||
- provider API adapters and Graph clients
|
||||
- onboarding workflow internals beyond existing outbound links
|
||||
- migrations, tables, enums, or new persisted readiness truth
|
||||
- customer portal, PDF/HTML renderer, PSA, billing, or AI follow-up files
|
||||
|
||||
## Prep Conclusion
|
||||
|
||||
Spec 353 is repo-safe as a new prep target:
|
||||
|
||||
- the selected candidate is not already prepared as `353-*`
|
||||
- the dependency chain is explicit
|
||||
- the needed runtime truth already exists
|
||||
- the remaining work is a bounded productization/guidance layer, not a provider architecture rewrite
|
||||
516
specs/353-provider-connections-resolution-guidance-v1/spec.md
Normal file
516
specs/353-provider-connections-resolution-guidance-v1/spec.md
Normal file
@ -0,0 +1,516 @@
|
||||
# Feature Specification: Spec 353 - Provider Connections Resolution Guidance v1
|
||||
|
||||
**Feature Branch**: `353-provider-connections-resolution-guidance-v1`
|
||||
**Created**: 2026-06-04
|
||||
**Status**: Implemented (close-out audit pending)
|
||||
**Type**: Platform productization / provider readiness guidance / operator workflow consolidation
|
||||
**Runtime posture**: Narrow provider-readiness guidance over existing Provider Connection, Required Permissions, verification, and dashboard truth. No provider architecture rewrite, no new permission model, no onboarding rewrite, and no new persistence.
|
||||
**Input**: User-provided Spec 353 draft + repo inspection of Provider Connections, Environment Required Permissions, Environment Dashboard provider blocker guidance, and provider verification/permission seams.
|
||||
|
||||
## Dependencies And Historical Context
|
||||
|
||||
This spec is a bounded follow-up over already repo-real scope, guidance, and provider foundations:
|
||||
|
||||
- Spec 338 - Workspace / Environment Resource Scope Contract
|
||||
- Spec 339 - Provider Connection Scope Hardening
|
||||
- Spec 350 - Operator Resolution Guidance Framework v1
|
||||
- Spec 351 - Review Output Resolve Actions v1
|
||||
- Spec 352 - Environment Dashboard Operator Guidance Consolidation
|
||||
- `docs/ui-ux-enterprise-audit/page-reports/ui-009-provider-connections.md`
|
||||
- `docs/ui-ux-enterprise-audit/target-experience-briefs/provider-readiness.md`
|
||||
- `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
|
||||
|
||||
Repo-truth adjustments against the user draft:
|
||||
|
||||
- There is no `apps/platform/app/Filament/Pages/EnvironmentProviderHealth.php` surface in the current repo. Provider health/readiness truth is currently spread across `ProviderConnectionSurfaceSummary`, `ProviderConnectionHealthCheckJob`, `ManagedEnvironmentResource::providerConnectionState()`, and `EnvironmentDashboardSummaryBuilder`.
|
||||
- `EnvironmentRequiredPermissions` already exists as a repo-real page with summary, capability groups, issues, copy flows, and technical-details disclosure. Spec 353 must productize this page further instead of rebuilding it.
|
||||
- `ProviderConnectionResource` already has list, view, and edit surfaces. The view page already exposes one primary `Grant admin consent` CTA and groups the rest under `More`.
|
||||
- `EnvironmentDashboardSummaryBuilder` already prioritizes provider blockers by elevating `required_permissions` / `delegated_permissions` recommended actions into `operatorGuidance`. Spec 353 must make the target surface equally decision-first.
|
||||
- `docs/ui-ux-enterprise-audit/page-reports/ui-077-required-permissions.md` did not exist during prep. Close-out must create that report and align the durable UI audit registry links/classification around UI-077.
|
||||
- `ProviderConnectionResource` is already not globally searchable. Spec 353 must preserve that state; it does not need a global-search change.
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Provider readiness blockers already stop evidence refresh, review output readiness, inventory work, and onboarding continuity, but the target surfaces still force operators to translate badges, counts, and verification states into the next safe action themselves.
|
||||
- **Today's failure**: The Environment Dashboard can now correctly say "provider readiness blocks evidence refresh", but the linked Provider Connections / Required Permissions surfaces still read as diagnostics-first. Operators can see consent, verification, and missing-permission facts without getting one clear blocker statement, one primary next action, and one safe proof path.
|
||||
- **User-visible improvement**: Provider Connections and Required Permissions become decision-first surfaces that answer "what is wrong, why it matters, and what to do next" in five seconds while keeping raw permission rows and provider diagnostics secondary.
|
||||
- **Smallest enterprise-capable version**: Reuse stored provider connection, permission, capability-group, verification, and last-operation truth to derive one provider guidance case with one primary action on the existing surfaces. Update dashboard-target continuity, add focused tests, and create/update the necessary UI audit artifacts.
|
||||
- **Explicit non-goals**: No provider execution rewrite, no new provider framework, no new onboarding wizard, no new permission persistence, no consent auto-remediation, no customer portal, no PDF/HTML renderer, and no new workflow engine.
|
||||
- **Permanent complexity imported**: One bounded provider-readiness guidance contract or presenter, focused unit/feature/browser tests, one repo-truth map, one signal inventory, and the required UI audit follow-through. No new table, model, enum family, or queue family is intended.
|
||||
- **Why now**: Spec 352 intentionally made provider blockers outrank review-output guidance on the Environment Dashboard. Without this follow-through, the new dashboard CTA lands on destination pages that still feel diagnostic instead of operational.
|
||||
- **Why not local**: Copy-only tweaks on one page would leave Provider Connections, Required Permissions, verification proof, and dashboard target continuity speaking different guidance dialects. A small shared derived guidance shape is the narrowest honest fix.
|
||||
- **Approval class**: Workflow Compression.
|
||||
- **Red flags triggered**: Strategic operator surfaces, shared interaction-family reuse, and provider-boundary vocabulary. Defense: the slice is explicitly derived-only, reuses repo-real signals, preserves existing capability/audit/OperationRun behavior, and forbids new provider architecture.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve.
|
||||
|
||||
## Candidate Source And Completed-Spec Guardrail
|
||||
|
||||
- **Candidate source**:
|
||||
- direct user-provided Spec 353 draft
|
||||
- grouped follow-up lane: `Provider onboarding/readiness UX cleanup`
|
||||
- dashboard follow-up explicitly called out by Spec 352
|
||||
- provider-readiness target brief and UI-009 page report
|
||||
- **Completed-spec guardrail result**:
|
||||
- no `specs/353-*` package existed before this prep
|
||||
- Specs 338, 339, 350, 351, and 352 already carry checked implementation/prep history and are treated as context only
|
||||
- no completed spec is being reopened, normalized, or converted back to preparation state
|
||||
- **Close alternatives deferred**:
|
||||
- further Governance Inbox closure after Spec 346
|
||||
- customer-facing localization follow-through
|
||||
- commercial/artifact-lifecycle productization
|
||||
- broader provider/onboarding redesign
|
||||
- **Smallest viable implementation slice**: existing Provider Connections list/view surfaces, existing edit-page action/proof continuity, the existing Environment Required Permissions page, and dashboard target continuity only: one dominant provider-readiness case, one primary action, secondary proof links, and technical details on demand.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace-owned provider hub plus environment-owned permissions follow-through
|
||||
- **Primary Routes**:
|
||||
- `/admin/provider-connections`
|
||||
- `/admin/provider-connections/{record}`
|
||||
- `/admin/provider-connections/{record}/edit`
|
||||
- `/admin/workspaces/{workspace}/environments/{environment}/required-permissions`
|
||||
- `/admin/workspaces/{workspace}/environments/{environment}`
|
||||
- **Data Ownership**:
|
||||
- `ProviderConnection` remains workspace-owned and linked to `ManagedEnvironment`
|
||||
- permission posture remains derived from existing stored `ManagedEnvironmentPermission` truth through `ManagedEnvironmentRequiredPermissionsViewModelBuilder`
|
||||
- verification proof remains `OperationRun` + verification-report truth
|
||||
- any new readiness case remains derived-only; no new persistence is introduced
|
||||
- **RBAC**:
|
||||
- existing workspace membership + environment entitlement remain authoritative
|
||||
- Provider Connection actions continue to use existing capabilities such as `PROVIDER_VIEW`, `PROVIDER_MANAGE`, and `PROVIDER_RUN`
|
||||
- Required Permissions page keeps deny-as-not-found workspace/environment semantics
|
||||
- no new authorization plane or hidden query authority is introduced
|
||||
|
||||
For canonical-view or mixed-scope follow-through:
|
||||
|
||||
- **Default filter behavior when environment-context is active**: Provider Connections continues to use explicit `environment_id` filtering only; Required Permissions keeps the route environment authoritative.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: provider records remain record-owned and workspace-scoped; required-permissions routes remain workspace+environment scoped; dashboard deep links remain current-scope only.
|
||||
|
||||
## UI Surface Impact *(mandatory - UI-COV-001)*
|
||||
|
||||
- [ ] No UI surface impact
|
||||
- [x] Existing page changed
|
||||
- [ ] New page/route added
|
||||
- [ ] Navigation changed
|
||||
- [ ] Filament panel/provider surface changed
|
||||
- [ ] New modal/drawer/wizard/action added
|
||||
- [x] New table/form/state added
|
||||
- [ ] Customer-facing surface changed
|
||||
- [ ] Dangerous action changed
|
||||
- [x] Status/evidence/review presentation changed
|
||||
- [x] Workspace/environment context presentation changed
|
||||
|
||||
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact")*
|
||||
|
||||
- **Route/page/surface**:
|
||||
- Provider Connections list/view
|
||||
- Provider Connections edit-page action/proof continuity without mandatory duplicate top guidance
|
||||
- Environment Required Permissions
|
||||
- Environment Dashboard provider-blocker target continuity
|
||||
- **Current or new page archetype**:
|
||||
- Provider Connections: existing strategic provider/integration surface
|
||||
- Required Permissions: existing diagnostic/list-plus-guidance surface
|
||||
- **Design depth**:
|
||||
- Provider Connections is already a Strategic Surface in the current audit registry
|
||||
- Required Permissions is currently a repo-verified domain surface in `route-inventory.md`; any promotion to Strategic Surface must be reflected in the registry during close-out
|
||||
- **Repo-truth level**: repo-verified runtime surfaces
|
||||
- **Existing pattern reused**:
|
||||
- Spec 350/352 resolution-guidance hierarchy
|
||||
- existing dashboard-to-detail deep-link patterns
|
||||
- existing provider verification / permission guidance links
|
||||
- **New pattern required**: one bounded provider-readiness guidance contract or presenter; no new generic cross-domain framework
|
||||
- **Screenshot required**: yes, under `specs/353-provider-connections-resolution-guidance-v1/artifacts/screenshots/`
|
||||
- **Page audit required**:
|
||||
- update `docs/ui-ux-enterprise-audit/page-reports/ui-009-provider-connections.md`
|
||||
- create or update `docs/ui-ux-enterprise-audit/page-reports/ui-077-required-permissions.md` because it is currently absent in repo truth
|
||||
- **Customer-safe review required**: no; operator/internal only
|
||||
- **Dangerous-action review required**: yes, but only to preserve existing confirmation/authorization/audit posture on provider actions while guidance becomes more prominent
|
||||
- **Coverage files that must be reviewed in close-out**:
|
||||
- `docs/ui-ux-enterprise-audit/page-reports/ui-009-provider-connections.md`
|
||||
- `docs/ui-ux-enterprise-audit/page-reports/ui-077-required-permissions.md`
|
||||
- `docs/ui-ux-enterprise-audit/route-inventory.md` to link UI-077 and refresh UI-072/UI-077 report or screenshot references
|
||||
- `docs/ui-ux-enterprise-audit/design-coverage-matrix.md` only if route-inventory counts or classification change
|
||||
- `docs/ui-ux-enterprise-audit/strategic-surfaces.md` only if UI-077 is intentionally promoted from its current registry classification
|
||||
- `docs/ui-ux-enterprise-audit/unresolved-pages.md` if browser or screenshot evidence cannot be durably stored under the Spec 353 artifact path
|
||||
- **No-impact rationale when applicable**: N/A
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: status messaging, next-action guidance, dashboard-to-detail continuity, verification proof links, diagnostic disclosure
|
||||
- **Systems touched**:
|
||||
- `App\Support\EnvironmentDashboard\EnvironmentDashboardSummaryBuilder`
|
||||
- `App\Filament\Resources\ProviderConnectionResource`
|
||||
- `App\Filament\Pages\EnvironmentRequiredPermissions`
|
||||
- `App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder`
|
||||
- `App\Services\Providers\ProviderConnectionResolver`
|
||||
- existing `RequiredPermissionsLinks`, `ManagedEnvironmentLinks`, and `OperationRunLinks`
|
||||
- **Existing pattern(s) to extend**:
|
||||
- current `operatorGuidance` on the Environment Dashboard
|
||||
- existing Spec 350/352 guidance hierarchy
|
||||
- existing verification start/proof link behavior
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: existing dashboard/operator-guidance structure, `ManagedEnvironmentRequiredPermissionsViewModelBuilder`, `ProviderConnectionResolver`, and current verification/result links before adding anything broader
|
||||
- **Why the existing shared path is sufficient or insufficient**: the repo already has the stored provider truth and one shared guidance family, but Provider Connections and Required Permissions did not yet consume that truth as one dominant provider-readiness case.
|
||||
- **Allowed deviation and why**: one small provider-specific derived adapter is allowed because reusing the review-output adapter directly would blur provider truth and output-readiness truth.
|
||||
- **Consistency impact**: blocker title, reason, impact, action hierarchy, and deep links must align between dashboard, provider connection, and required-permissions surfaces.
|
||||
- **Review focus**: block a second provider-readiness dialect, fake remediation buttons, or live provider calls during render.
|
||||
|
||||
## OperationRun UX Impact *(mandatory)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes, existing `provider.connection.check` start and proof-link behavior only
|
||||
- **Shared OperationRun UX contract/layer reused**:
|
||||
- existing `StartVerification`
|
||||
- existing `ProviderOperationStartResultPresenter`
|
||||
- existing `OperationRunLinks`
|
||||
- **Delegated start/completion UX behaviors**: re-run verification and open last check run must reuse current queued/block/deduped/result messaging rather than inventing new start UX
|
||||
- **Local surface-owned behavior that remains**: deciding when those existing actions are primary, secondary, or hidden based on the derived provider-readiness case
|
||||
- **Queued DB-notification policy**: unchanged
|
||||
- **Terminal notification path**: unchanged
|
||||
- **Exception required?**: none
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes
|
||||
- **Boundary classification**: mixed; provider-owned signals feed a platform-owned readiness guidance layer
|
||||
- **Seams affected**: consent status, verification status, provider capability groups, reason-code translation, operator vocabulary on shared surfaces
|
||||
- **Neutral platform terms preserved or introduced**: provider connection, required permissions, verification, readiness, evidence refresh, operation proof
|
||||
- **Provider-specific semantics retained and why**: provider-specific permission names and admin-consent destination remain available in details and action targets because the current provider-owned seams require them
|
||||
- **Why this does not deepen provider coupling accidentally**: the spec forbids new Microsoft-shaped core taxonomies, persistence, or route semantics; provider-specific wording stays bounded to existing provider-owned detail.
|
||||
- **Follow-up path**: deeper onboarding/provider health redesign stays a later follow-up candidate
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory)*
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Provider Connections list | yes | Native Filament resource | provider readiness, action hierarchy, dashboard continuity | table summary, derived state | no | Existing route only |
|
||||
| Provider Connections view | yes | Native Filament resource/detail | provider readiness, proof links, safe actions | infolist/header actions, derived state | no | Existing route only |
|
||||
| Provider Connections edit | yes | Native Filament resource/edit | action/proof continuity, grouped safe actions | header actions, existing form context | no | Existing route only; top guidance intentionally not duplicated |
|
||||
| Environment Required Permissions | yes | Native Filament page + custom Blade summary | permission blocker guidance, copy flows, verification links | page summary, disclosure hierarchy | no | Existing route only |
|
||||
| Dashboard target continuity | yes | Existing dashboard -> existing destinations | guidance contract reuse | link target continuity only | no | No new dashboard scope beyond continuity |
|
||||
|
||||
## Decision-First Surface Role *(mandatory)*
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Provider Connections view | Primary Decision Surface | Decide whether this connection is ready and what safe next action resolves the blocker | readiness state, blocker reason, impact, one primary action | capability detail, raw diagnostics, last run proof | Primary because it is the authority surface for connection-level readiness | follows provider admin workflow | removes translation from badges/columns/actions |
|
||||
| Provider Connections edit | Secondary Configuration Context | Adjust connection metadata or review existing action/proof context after the blocker is understood | existing record fields and safe action context | last run proof, grouped provider operations, diagnostics | Secondary because the first readiness decision is already handled on list/view | follows provider admin workflow | avoids duplicating another top-level guidance layer |
|
||||
| Environment Required Permissions | Primary Decision Surface | Decide whether missing permissions, stale evidence, or verification follow-up is the blocker | one blocker summary, one primary action, missing type split | raw permission matrix, copy payloads, technical details | Primary because dashboard often lands here first | follows permission-remediation workflow | removes need to infer next step from counts alone |
|
||||
| Provider Connections list | Secondary Context | Decide which connection needs inspection or whether the environment has any usable connection | top-level readiness summary and row signals | detail page, diagnostics, action group | Secondary because it routes to the real connection decision | supports workspace-wide scanning | keeps list scan-first |
|
||||
| Environment Dashboard target continuity | Secondary Context | Confirm the dashboard CTA landed on the same blocker | matching title/reason/action | deeper proof after landing | Secondary because the dashboard remains the first-start surface | preserves operator continuity | avoids target-page disorientation |
|
||||
|
||||
## Audience-Aware Disclosure *(mandatory)*
|
||||
|
||||
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Provider Connections | operator-MSP, workspace owner, support where authorized | readiness state, why blocked, impact, primary action | capability summary, consent/verification states, last error reason, last check | raw error message, detailed capability rows, operation proof | open required permissions, run verification, or open last check run depending on blocker | raw diagnostics remain secondary | guidance card states blocker once; lower sections add proof only |
|
||||
| Environment Required Permissions | operator-MSP, workspace owner, support where authorized | missing app/delegated or stale verification blocker, why it matters, primary action | capability groups, counts, freshness, filtered highlights | raw permission matrix, copy payloads, technical details | admin consent, provider connections, or re-run verification | technical details stay collapsed by default | overview replaces the current repeated issue interpretation burden |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory)*
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Provider Connections list | Utility / Workspace Decision | Strategic integration list | inspect the blocking connection or create one | row opens view page | allowed | grouped under existing page/header patterns | existing provider actions stay grouped | `/admin/provider-connections` | `/admin/provider-connections/{record}` | workspace context + optional `environment_id` | Provider connection | readiness blocker and scope summary | none |
|
||||
| Provider Connections view | Detail / Configuration Authority | Provider readiness detail | resolve the dominant blocker | existing detail page | N/A | secondary proof/actions in header group | existing destructive/high-impact actions stay confirmed and grouped | `/admin/provider-connections` | existing view route | workspace + record ownership | Provider connection | one derived readiness case | none |
|
||||
| Provider Connections edit | Configuration Surface | Provider connection maintenance | adjust or review connection configuration after readiness diagnosis | existing edit page | N/A | existing grouped proof/actions remain | existing destructive/high-impact actions stay confirmed and grouped | `/admin/provider-connections` | existing edit route | workspace + record ownership | Provider connection | existing action/proof context only; top guidance may be deferred | intentional non-duplication of first-screen guidance |
|
||||
| Environment Required Permissions | List / Guidance / Diagnostic | Read-first remediation page | grant consent, open provider connection, or re-run verification | same page | forbidden | secondary links in guidance/issues/details | none added | `/admin/workspaces/{workspace}/environments/{environment}/required-permissions` | same page | workspace + environment route | Required permissions | one blocker and one next action | inline workflow exemption remains valid |
|
||||
|
||||
## Summary
|
||||
|
||||
Spec 352 made provider blockers the dominant first-screen case on the Environment Dashboard. Spec 353 makes the destination surfaces trustworthy by turning existing Provider Connections and Required Permissions truth into one explicit provider-readiness guidance layer.
|
||||
|
||||
The feature remains narrow:
|
||||
|
||||
- no new provider architecture
|
||||
- no new permission model
|
||||
- no onboarding rewrite
|
||||
- no new mutation flow
|
||||
- no live provider calls during render
|
||||
|
||||
The product outcome is:
|
||||
|
||||
- one dominant provider-readiness case
|
||||
- one primary next action
|
||||
- secondary proof links only when repo-backed
|
||||
- technical detail on demand
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Provider readiness is an upstream blocker for evidence refresh, review-output readiness, inventory visibility, and onboarding continuity. The repo already exposes consent status, verification status, missing-permission counts, capability-group impacts, and last-operation proof, but the affected destination surfaces still require operators to interpret those signals for themselves.
|
||||
|
||||
After Spec 352, the Environment Dashboard can correctly prioritize provider blockers and send the operator to a target page. That target page must now preserve the same blocker story and present one safe next action immediately, otherwise the dashboard improvement terminates in a diagnostics-first dead end.
|
||||
|
||||
## Primary Users And Operators
|
||||
|
||||
- MSP operators managing multiple customer environments from the admin panel
|
||||
- workspace owners or admins responsible for provider consent and verification follow-through
|
||||
- internal support users with existing workspace/environment entitlement
|
||||
|
||||
This slice does not target customer-portal or public-facing audiences.
|
||||
|
||||
## Goals
|
||||
|
||||
### G1 - Add provider-readiness guidance on Provider Connections
|
||||
|
||||
Provider Connections list/detail must show a clear readiness case when the connection or its owning environment is blocked or needs follow-up. The edit surface may preserve action/proof continuity without duplicating first-screen guidance.
|
||||
|
||||
### G2 - Add provider-readiness guidance on Required Permissions
|
||||
|
||||
Required Permissions must explain which blocker matters, why it affects TenantPilot, and what to do next before the raw permission matrix.
|
||||
|
||||
### G3 - Reuse existing repo-backed signals
|
||||
|
||||
Use stored provider connection, missing-permission, capability-group, verification, and last-operation truth only.
|
||||
|
||||
### G4 - Reuse the current guidance family only where it fits
|
||||
|
||||
Provider readiness may reuse Spec 350-style `ResolutionCase` / `ResolutionAction` semantics or a bounded provider presenter, but it must not force a repo-wide provider framework.
|
||||
|
||||
### G5 - Keep provider actions safe
|
||||
|
||||
Only render repo-backed actions such as open required permissions, open provider connection, run verification, open last check run, or open onboarding/context destinations already supported by the repo.
|
||||
|
||||
### G6 - Preserve domain ownership
|
||||
|
||||
Do not rewrite onboarding ownership, permission calculation ownership, or provider execution ownership.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- rebuilding `ProviderConnectionResource`
|
||||
- rewriting `ManagedEnvironmentRequiredPermissionsViewModelBuilder`
|
||||
- changing provider APIs or Graph integration
|
||||
- adding a new provider or provider registry layer
|
||||
- adding a new OAuth or consent execution path
|
||||
- adding auto-fix, auto-consent, or autonomous remediation
|
||||
- adding a new dashboard framework
|
||||
- changing OperationRun semantics
|
||||
- adding new tables, enums, or persisted readiness states unless repo truth proves unavoidable
|
||||
- turning Provider Connections into a Microsoft admin mirror
|
||||
|
||||
## Current Repo Truth Summary
|
||||
|
||||
- Provider Connections already exposes consent, verification, provider capability, last check, and diagnostics on list and detail surfaces.
|
||||
- The view page already has one prominent `Grant admin consent` action; the edit page already has `View last check run` and existing provider operations.
|
||||
- Required Permissions already derives:
|
||||
- `overall`
|
||||
- `counts`
|
||||
- `feature_impacts`
|
||||
- `capability_groups`
|
||||
- `primary_capability_group`
|
||||
- `freshness`
|
||||
- Required Permissions already renders:
|
||||
- summary counts
|
||||
- guidance copy
|
||||
- issue cards
|
||||
- copy payload modals
|
||||
- technical-details disclosure
|
||||
- The Environment Dashboard already promotes `required_permissions` / `delegated_permissions` provider blockers into `operatorGuidance`.
|
||||
- `ProviderReasonTranslator` and `VerificationLinkBehavior` remain adjacent repo seams for provider explanation/link behavior, but they are not required to be the primary runtime path for Spec 353 if a narrower adapter over stored truth is sufficient.
|
||||
|
||||
## Provider Guidance Contract
|
||||
|
||||
The implementation may realize this as a Spec 350-style derived case or as a bounded provider presenter, but the product contract is:
|
||||
|
||||
```php
|
||||
[
|
||||
'key' => 'provider_readiness.required_permissions_missing',
|
||||
'title' => 'Provider readiness blocked',
|
||||
'status' => 'Blocked',
|
||||
'severity' => 'danger',
|
||||
'reason' => 'Required application permissions are missing.',
|
||||
'impact' => 'Evidence refresh and review output readiness may be incomplete.',
|
||||
'primary_action' => [
|
||||
'label' => 'Open required permissions',
|
||||
'url' => '...',
|
||||
'enabled' => true,
|
||||
],
|
||||
'secondary_actions' => [
|
||||
['label' => 'Run verification', 'url' => '...'],
|
||||
['label' => 'Open last check run', 'url' => '...'],
|
||||
],
|
||||
'details' => [
|
||||
'missing_application_permissions' => 15,
|
||||
'missing_delegated_permissions' => 0,
|
||||
'verification_status' => 'unknown',
|
||||
'last_error_reason_code' => 'provider_consent_missing',
|
||||
],
|
||||
]
|
||||
```
|
||||
|
||||
Priority order must stay narrow and repo-backed:
|
||||
|
||||
1. No provider connection
|
||||
2. Connection disabled or unusable
|
||||
3. Missing application permissions
|
||||
4. Missing delegated permissions
|
||||
5. Verification failed / blocked
|
||||
6. Verification not run / stale / unknown
|
||||
7. Provider ready
|
||||
|
||||
Safe action set for v1:
|
||||
|
||||
- Open required permissions
|
||||
- Open provider connection
|
||||
- Run verification
|
||||
- Open last check run
|
||||
- Open environment dashboard
|
||||
- Open existing onboarding destination only if current repo truth makes it the best supporting path
|
||||
|
||||
Unsupported for v1:
|
||||
|
||||
- auto-consent
|
||||
- auto-repair
|
||||
- "make provider ready" mutation buttons
|
||||
- live provider health polling during render
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
- **FR-353-001**: Provider Connections must expose one dominant provider-readiness case on list/detail when follow-up is required. The edit surface may preserve existing proof/action continuity without duplicating the first-screen guidance layer.
|
||||
- **FR-353-002**: Required Permissions must expose one dominant readiness case before the raw permission matrix.
|
||||
- **FR-353-003**: Guidance must reuse stored provider connection, permission, capability-group, freshness, verification, and last-run truth only.
|
||||
- **FR-353-004**: Guidance must distinguish at least these cases when repo-backed: no connection, disabled connection, missing application permissions, missing delegated permissions, verification blocked/failed, verification stale/unknown, ready.
|
||||
- **FR-353-005**: Each surface must keep one visually dominant primary action. Secondary links must not compete.
|
||||
- **FR-353-006**: Dashboard provider-blocker deep links must land on a target surface that shows the same blocker category clearly.
|
||||
- **FR-353-007**: Raw permission rows, copy payloads, and provider diagnostics must remain secondary or collapsed by default.
|
||||
- **FR-353-008**: Existing repo-backed actions such as `Grant admin consent`, `Run verification`, `View last check run`, and provider-connection navigation must be reused rather than replaced with unsupported buttons.
|
||||
- **FR-353-009**: Existing provider/onboarding/verification behavior must remain intact; only the guidance layer changes.
|
||||
- **FR-353-010**: Scope and authorization must remain workspace/environment safe with no cross-scope leakage.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- **NFR-353-001**: Calm enterprise UX. The default view must read as one blocker and one next action, not a wall of permission detail.
|
||||
- **NFR-353-002**: Provider-neutral core copy on shared surfaces. Provider-specific terms may appear in details only where current seams already require them.
|
||||
- **NFR-353-003**: Auditability and OperationRun reuse remain unchanged. Existing verification and provider operations keep current audit and run ownership.
|
||||
- **NFR-353-004**: Capability-first RBAC. Guidance may demote or disable actions, but server-side authorization remains authoritative.
|
||||
- **NFR-353-005**: Render path must stay DB-local. No live Graph/provider call is allowed during guidance rendering.
|
||||
- **NFR-353-006**: No new provider framework, no new status persistence, and no new global-search behavior.
|
||||
|
||||
## UX Requirements
|
||||
|
||||
- The first visible state on the affected surfaces must answer:
|
||||
- what is blocked
|
||||
- why it matters to TenantPilot
|
||||
- what the safest next action is
|
||||
- Exactly one action may be visually primary per rendered provider-readiness case.
|
||||
- Raw permission rows, copy payloads, diagnostic badges, and sanitized error details must remain secondary or collapsed by default.
|
||||
- Ready states must read as calm and non-alarming, with secondary proof and navigation still available when useful.
|
||||
- Dashboard-to-target continuity must preserve the blocker category so the operator does not feel they landed on an unrelated admin page.
|
||||
|
||||
## RBAC / Security Requirements
|
||||
|
||||
- Existing workspace membership and environment entitlement remain authoritative; deny-as-not-found behavior for out-of-scope users must not change.
|
||||
- Existing provider capabilities such as `PROVIDER_VIEW`, `PROVIDER_MANAGE`, and `PROVIDER_RUN` continue to govern action availability.
|
||||
- Guidance visibility must not widen authorization, route scope, or provider mutation reach.
|
||||
- Unsupported remediation must not be rendered as executable UI.
|
||||
|
||||
## Auditability / Observability Requirements
|
||||
|
||||
- Existing `OperationRun` ownership for provider verification and proof links remains authoritative.
|
||||
- Existing provider action logging and audit semantics must remain unchanged; Spec 353 only changes decision-first presentation.
|
||||
- Guidance rendering must not introduce live provider calls, background work, or untracked side effects in the request path.
|
||||
|
||||
## Data / Truth-Source Requirements
|
||||
|
||||
- Provider-readiness guidance remains derived-only and request-scoped.
|
||||
- Canonical inputs are existing stored truth from `ProviderConnection`, `ManagedEnvironmentRequiredPermissionsViewModelBuilder`, `ProviderConnectionResolver`, current link helpers, and existing `OperationRun` proof.
|
||||
- `ProviderConnectionSurfaceSummary`, `ProviderReasonTranslator`, and `VerificationLinkBehavior` remain adjacent repo seams that may assist explanation or link behavior, but they are not required to be the primary runtime inputs for Spec 353.
|
||||
- No new table, persisted snapshot, enum family, or provider taxonomy may be introduced unless later repo truth proves it unavoidable.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Livewire version contract**: unchanged; current repo truth stays on Livewire v4.x.
|
||||
- **Filament panel/provider registration**: unchanged; `apps/platform/bootstrap/providers.php` remains authoritative and is not part of this slice.
|
||||
- **Global search**: unchanged; `ProviderConnectionResource` stays not globally searchable and no new searchable surface is introduced.
|
||||
- **Test purpose / classification**:
|
||||
- Unit for deterministic provider-guidance selection
|
||||
- Feature/Livewire for Provider Connections and Required Permissions rendering/integration
|
||||
- Browser for strategic target continuity and first-screen hierarchy
|
||||
- **Validation lane(s)**: fast-feedback + confidence + browser
|
||||
- **Why this lane mix is the narrowest sufficient proof**: the risk is decision hierarchy, continuity, and safe action truth on existing strategic surfaces rather than schema or remote execution behavior.
|
||||
- **Planned proving commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Unit/ResolutionGuidance/Spec353ProviderReadinessResolutionAdapterTest.php --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/ProviderConnections/Spec353ProviderConnectionGuidanceTest.php --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament/Spec353RequiredPermissionsGuidanceTest.php --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec353ProviderReadinessGuidanceSmokeTest.php --compact`
|
||||
- **Planned regression filters**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=ProviderConnection --exclude-group=browser`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=RequiredPermissions`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec352 --exclude-group=browser`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=ResolutionGuidance --exclude-group=browser`
|
||||
- **Deployment/runtime impact expected**: none; no migrations, env vars, queues, scheduler entries, storage changes, or `filament:assets` registration are planned.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- **AC1**: Provider Connections list/detail shows one dominant provider-readiness case when action is required, while edit preserves existing action/proof continuity without widening scope or faking remediation.
|
||||
- **AC2**: Required Permissions is decision-first and explains the blocker before the raw permission matrix.
|
||||
- **AC3**: Exactly one provider action is visually primary on the affected surfaces.
|
||||
- **AC4**: Dashboard provider-blocker links land on a surface that shows a matching blocker clearly.
|
||||
- **AC5**: No unsupported auto-fix, auto-consent, or fake remediation buttons are rendered.
|
||||
- **AC6**: Guidance rendering does not call live provider APIs.
|
||||
- **AC7**: Workspace/environment/provider scope remains correct.
|
||||
- **AC8**: Existing provider actions, verification runs, required-permission calculation, and onboarding ownership are preserved.
|
||||
- **AC9**: Focused unit/feature/browser tests cover missing permissions, verification follow-up, ready state, and dashboard continuity.
|
||||
- **AC10**: UI audit artifacts and screenshots are updated under the Spec 353 package and the current UI audit registry.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- An operator can land from the Environment Dashboard onto a provider-readiness target and understand the blocker plus next safe action within one screen.
|
||||
- Provider Connections and Required Permissions no longer require the operator to translate badge/count truth into their own remediation decision.
|
||||
- No unsupported auto-fix or auto-consent action is introduced.
|
||||
- Focused validation covers blocker ranking, rendered hierarchy, and dashboard continuity without requiring broader provider rewrites.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Dashboard to provider target continuity (Priority: P1)
|
||||
|
||||
As an operator, when the dashboard says provider readiness blocks work, the linked page should show the same blocker and the same safe next action.
|
||||
|
||||
**Independent Test**: Seed a provider-blocked environment, open the dashboard, follow the primary provider CTA, and assert that the target surface shows a matching blocker category and one dominant action.
|
||||
|
||||
### User Story 2 - Understand connection-level readiness (Priority: P1)
|
||||
|
||||
As an operator on Provider Connections, I can tell whether a specific connection is blocked by missing consent, missing permissions, stale verification, or provider failure without reading raw diagnostics first.
|
||||
|
||||
**Independent Test**: Feature/browser coverage proves that Provider Connections surfaces show one derived readiness case, one primary action, and secondary proof links only when repo-backed.
|
||||
|
||||
### User Story 3 - Understand required-permissions impact (Priority: P1)
|
||||
|
||||
As an operator on Required Permissions, I can tell whether application permissions, delegated permissions, or stale verification are blocking TenantPilot and what to do next before inspecting the full matrix.
|
||||
|
||||
**Independent Test**: Feature/browser coverage proves that Required Permissions shows one blocker summary before the matrix and preserves raw permission rows as secondary detail.
|
||||
|
||||
### User Story 4 - Calm ready state (Priority: P2)
|
||||
|
||||
As an operator, when provider readiness is currently satisfied, the surfaces show a calm ready state and a non-alarming next step rather than another warning stack.
|
||||
|
||||
**Independent Test**: Unit + feature/browser coverage proves the ready case yields no urgent blocker and keeps technical details secondary.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Risk 1 - Duplicate onboarding ownership**: mitigate by keeping onboarding links secondary and not rewriting onboarding state or wizard logic.
|
||||
- **Risk 2 - Permission details overwhelm the page**: mitigate by deriving one blocker case and keeping matrix/diagnostics behind secondary disclosure.
|
||||
- **Risk 3 - Fake remediation**: mitigate by reusing only repo-backed actions and routing unsupported cases to navigation/proof instead of mutation.
|
||||
- **Risk 4 - Render-time provider calls**: mitigate by limiting guidance inputs to stored connection, permission, capability, freshness, and last-run truth.
|
||||
- **Risk 5 - Missing UI audit artifact for Required Permissions**: mitigate by explicitly creating `ui-077-required-permissions.md` during implementation instead of silently skipping audit coverage.
|
||||
|
||||
## Follow-Up Candidates
|
||||
|
||||
- accepted-risk / finding-exception resolution guidance follow-through
|
||||
- broader provider/onboarding productization after this readiness slice is stable
|
||||
- sellable smoke matrix after provider and governance operator flows are calmer
|
||||
- customer-facing localization and copy-polish follow-through
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The current stored provider, permission, and verification signals are sufficient to derive one dominant readiness case without new persistence.
|
||||
- Existing admin-consent, required-permissions, verification-run, and last-operation links remain the authoritative safe actions for this slice.
|
||||
- Creating `docs/ui-ux-enterprise-audit/page-reports/ui-077-required-permissions.md` during implementation is consistent with the repo's current UI audit structure.
|
||||
|
||||
## Open Questions
|
||||
|
||||
No blocking preparation questions remain.
|
||||
|
||||
Implementation should still confirm two narrow runtime choices during tests-first work:
|
||||
|
||||
- whether dashboard-target continuity can remain route-native without any new query-state hint
|
||||
- whether the current repo truth prefers surfacing stale verification ahead of delegated-permission follow-up when both are present on the same environment
|
||||
177
specs/353-provider-connections-resolution-guidance-v1/tasks.md
Normal file
177
specs/353-provider-connections-resolution-guidance-v1/tasks.md
Normal file
@ -0,0 +1,177 @@
|
||||
# Tasks: Spec 353 - Provider Connections Resolution Guidance v1
|
||||
|
||||
**Input**: `specs/353-provider-connections-resolution-guidance-v1/spec.md`, `plan.md`, `repo-truth-map.md`, `contracts/provider-readiness-signal-map.md`, and `checklists/requirements.md`
|
||||
|
||||
**Tests**: Required. This spec changes strategic operator-facing readiness guidance on existing Provider Connections and Required Permissions surfaces.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane assignment is explicit and narrow: Unit for guidance selection, Feature/Livewire for rendered integration, Browser for first-screen hierarchy and dashboard target continuity.
|
||||
- [x] New or changed tests stay in the smallest honest family, and the browser addition is explicit.
|
||||
- [x] Shared helpers, factories, seeds, and context defaults stay cheap by default.
|
||||
- [x] Planned validation commands cover the slice without pulling in unrelated lane cost.
|
||||
- [x] The changed surfaces are explicit strategic/detail surfaces, not a hidden infra-only refactor.
|
||||
- [x] No new persisted readiness truth, provider framework, or enum family is planned.
|
||||
|
||||
## Phase 1: Preparation And Repo Truth
|
||||
|
||||
**Purpose**: Keep the implementation bounded to the existing runtime seams and recorded draft-to-repo deviations.
|
||||
|
||||
- [x] T001 Re-read `spec.md`, `plan.md`, `tasks.md`, `repo-truth-map.md`, `contracts/provider-readiness-signal-map.md`, and `checklists/requirements.md`.
|
||||
- [x] T002 Re-verify the current runtime truth in:
|
||||
- `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`
|
||||
- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`
|
||||
- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`
|
||||
- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`
|
||||
- `apps/platform/app/Filament/Pages/EnvironmentRequiredPermissions.php`
|
||||
- `apps/platform/resources/views/filament/pages/environment-required-permissions.blade.php`
|
||||
- `apps/platform/app/Services/Intune/ManagedEnvironmentRequiredPermissionsViewModelBuilder.php`
|
||||
- `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`
|
||||
- `apps/platform/app/Support/EnvironmentDashboard/EnvironmentDashboardSummaryBuilder.php`
|
||||
- `apps/platform/app/Support/Providers/ProviderReasonTranslator.php`
|
||||
- `apps/platform/app/Support/Verification/VerificationLinkBehavior.php`
|
||||
- [x] T003 Re-confirm the draft deviations recorded in `repo-truth-map.md`:
|
||||
- no `EnvironmentProviderHealth.php`
|
||||
- no existing `ui-077-required-permissions.md`
|
||||
- Provider Connections already has primary/safe actions and detail truth
|
||||
- Required Permissions already has summary/issues/details scaffolding
|
||||
- [x] T004 Confirm no migration, package, env var, queue family, scheduler, storage, panel/provider, or global-search change is required; keep Livewire v4 and `apps/platform/bootstrap/providers.php` unchanged.
|
||||
- [x] T005 Keep `repo-truth-map.md` and `contracts/provider-readiness-signal-map.md` current if runtime inspection proves a narrower or broader safe slice.
|
||||
|
||||
## Phase 2: Tests First
|
||||
|
||||
**Purpose**: Lock decision hierarchy, scope, and no-fake-action behavior before runtime changes.
|
||||
|
||||
- [x] T006 Add `apps/platform/tests/Unit/ResolutionGuidance/Spec353ProviderReadinessResolutionAdapterTest.php`.
|
||||
- [x] T007 Add unit assertions for `no provider connection`.
|
||||
- [x] T008 Add unit assertions for `connection disabled or unusable`.
|
||||
- [x] T009 Add unit assertions for `missing application permissions`.
|
||||
- [x] T010 Add unit assertions for `missing delegated permissions`.
|
||||
- [x] T011 Add unit assertions for `verification blocked or failed`.
|
||||
- [x] T012 Add unit assertions for `verification stale / unknown / not yet trusted`.
|
||||
- [x] T013 Add unit assertions for `provider ready / no urgent provider action`.
|
||||
- [x] T014 Add a render-path guard assertion proving the derived guidance selection does not require live provider or Graph calls.
|
||||
- [x] T015 Add `apps/platform/tests/Feature/ProviderConnections/Spec353ProviderConnectionGuidanceTest.php`.
|
||||
- [x] T016 Add feature assertions that Provider Connections surfaces show one explicit provider-readiness case with one dominant primary action.
|
||||
- [x] T017 Add feature assertions that only repo-backed secondary actions are rendered and unsupported auto-fix buttons are absent.
|
||||
- [x] T018 Add feature assertions that workspace/environment/record scope remains correct for all rendered guidance links.
|
||||
- [x] T019 Add feature assertions that existing provider detail truth and action groups remain present as secondary context.
|
||||
- [x] T020 Add `apps/platform/tests/Feature/Filament/Spec353RequiredPermissionsGuidanceTest.php`.
|
||||
- [x] T021 Add feature assertions that Required Permissions renders the blocker guidance before the raw matrix.
|
||||
- [x] T022 Add feature assertions that missing application, missing delegated, and stale/unknown cases map to distinct operator-facing guidance.
|
||||
- [x] T023 Add feature assertions that copy payload flows and technical details remain secondary.
|
||||
- [x] T024 Add feature assertions that no live provider call is required to render Required Permissions guidance.
|
||||
- [x] T025 Add a dashboard target continuity assertion in the narrowest honest family (Feature if possible, Browser otherwise).
|
||||
- [x] T026 Add `apps/platform/tests/Browser/Spec353ProviderReadinessGuidanceSmokeTest.php`.
|
||||
- [x] T027 Browser Flow A: permissions-missing state on Required Permissions; assert one dominant blocker and one primary action.
|
||||
- [x] T028 Browser Flow B: provider connection verification-follow-up state; assert operation-proof or verification action continuity.
|
||||
- [x] T029 Browser Flow C: ready state; assert calm posture and secondary details only.
|
||||
- [x] T030 Browser Flow D: Environment Dashboard -> provider target continuity; assert matching blocker language and scope.
|
||||
- [x] T031 Browser Flow E: technical details remain collapsed by default and layout stays readable on a mobile-ish width if fixture support exists.
|
||||
|
||||
## Phase 3: Derived Guidance Contract
|
||||
|
||||
**Purpose**: Build the narrowest derived readiness payload over existing provider and permission truth.
|
||||
|
||||
- [x] T032 Choose the narrowest implementation shape:
|
||||
- prefer one bounded provider-readiness adapter/presenter
|
||||
- avoid broadening `ReviewPackOutputResolutionAdapter` into a provider meta-framework
|
||||
- [x] T033 Consume existing signals from `ProviderConnectionResolver`, `ManagedEnvironmentRequiredPermissionsViewModelBuilder`, and the last `provider.connection.check` run proof, using `ProviderReasonTranslator` or `VerificationLinkBehavior` only where directly helpful rather than as mandatory primary inputs.
|
||||
- [x] T034 Derive one provider guidance payload with:
|
||||
- `key`
|
||||
- `title`
|
||||
- `status`
|
||||
- `severity`
|
||||
- `reason`
|
||||
- `impact`
|
||||
- `primary_action`
|
||||
- `secondary_actions`
|
||||
- `technical_details`
|
||||
- [x] T035 Keep blocker priority explicit: no connection -> disabled/unusable -> missing application -> missing delegated -> verification failed/blocked -> stale/unknown -> ready.
|
||||
- [x] T036 Keep the derived guidance DB-local and request-scoped only; no new persistence.
|
||||
- [x] T037 Do not introduce a new provider enum/status family, provider registry, or generic workflow engine in this slice.
|
||||
|
||||
## Phase 4: Provider Connections Integration
|
||||
|
||||
**Purpose**: Make Provider Connections read as an operator guidance destination without removing current truth.
|
||||
|
||||
- [x] T038 Integrate the derived guidance into Provider Connections list in a way that keeps the table scan-first and the environment filter intact.
|
||||
- [x] T039 Integrate the derived guidance into Provider Connections view, and keep the edit surface aligned as action/proof continuity without forcing duplicate top guidance.
|
||||
- [x] T040 Reuse existing repo-backed primary/secondary targets where appropriate:
|
||||
- `Grant admin consent`
|
||||
- `Run verification`
|
||||
- `View last check run`
|
||||
- `Open required permissions`
|
||||
- `Open provider connection`
|
||||
- [x] T041 Preserve current destructive/high-impact provider actions exactly as confirmation-, authorization-, and audit-protected secondary actions.
|
||||
- [x] T042 Do not let guidance visibility widen action authorization or scope.
|
||||
|
||||
## Phase 5: Required Permissions Integration
|
||||
|
||||
**Purpose**: Make Required Permissions decision-first without losing the current diagnostic depth.
|
||||
|
||||
- [x] T043 Add a top guidance case near the summary that explains why the current blocker matters to TenantPilot.
|
||||
- [x] T044 Reuse the current capability-group and freshness truth before inventing any new provider state.
|
||||
- [x] T045 Distinguish application permission blockers, delegated permission blockers, and stale/unknown verification follow-up in the top guidance layer.
|
||||
- [x] T046 Keep copy payloads, feature-impact cards, and the native permission matrix as secondary detail.
|
||||
- [x] T047 Keep technical details collapsed by default and avoid duplicating the blocker message in lower sections.
|
||||
|
||||
## Phase 6: Dashboard Target Continuity
|
||||
|
||||
**Purpose**: Ensure the dashboard's provider CTA lands on a destination that says the same thing clearly.
|
||||
|
||||
- [x] T048 Reuse the existing dashboard provider blocker mapping from `EnvironmentDashboardSummaryBuilder` and adjust destination continuity only if required.
|
||||
- [x] T049 Avoid adding a fragile new query-string contract when existing route scope is sufficient.
|
||||
- [x] T050 Ensure the target page shows the same blocker category and a compatible next action as the dashboard guidance.
|
||||
|
||||
## Phase 7: Copy, Audit, And Artifacts
|
||||
|
||||
**Purpose**: Align user-facing wording and UI audit coverage with the new guidance hierarchy.
|
||||
|
||||
- [x] T051 Update only the required copy in `apps/platform/lang/en/localization.php`.
|
||||
- [x] T052 Update matching copy in `apps/platform/lang/de/localization.php`.
|
||||
- [x] T053 Update `docs/ui-ux-enterprise-audit/page-reports/ui-009-provider-connections.md`.
|
||||
- [x] T054 Create or update `docs/ui-ux-enterprise-audit/page-reports/ui-077-required-permissions.md` and align it with the final bounded route-inventory classification.
|
||||
- [x] T055 Save screenshots under `specs/353-provider-connections-resolution-guidance-v1/artifacts/screenshots/`, or record the container-local Sail/Pest artifact blocker explicitly if host-visible copies cannot be persisted.
|
||||
- [x] T056 Keep `repo-truth-map.md`, `contracts/provider-readiness-signal-map.md`, `docs/ui-ux-enterprise-audit/route-inventory.md`, and any conditional `design-coverage-matrix.md` / `strategic-surfaces.md` / `unresolved-pages.md` follow-through aligned with the final bounded implementation shape.
|
||||
|
||||
## Phase 8: Validation
|
||||
|
||||
**Purpose**: Prove the guidance remains bounded, scope-safe, and render-local.
|
||||
|
||||
- [x] T057 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Unit/ResolutionGuidance/Spec353ProviderReadinessResolutionAdapterTest.php --compact`.
|
||||
- [x] T058 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/ProviderConnections/Spec353ProviderConnectionGuidanceTest.php --compact`.
|
||||
- [x] T059 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament/Spec353RequiredPermissionsGuidanceTest.php --compact`.
|
||||
- [x] T060 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec353ProviderReadinessGuidanceSmokeTest.php --compact`.
|
||||
- [x] T061 Re-run `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=ProviderConnection --exclude-group=browser`, and document any separate browser-harness instability honestly.
|
||||
- [x] T062 Re-run `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=RequiredPermissions`.
|
||||
- [x] T063 Re-run `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec352 --exclude-group=browser`.
|
||||
- [x] T064 Re-run `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=ResolutionGuidance --exclude-group=browser`.
|
||||
- [x] T065 Confirm final render paths remain DB-local and do not call `GraphClientInterface` or provider HTTP during page render.
|
||||
- [x] T066 Run `cd apps/platform && ./vendor/bin/sail pint --dirty`.
|
||||
- [x] T067 Run `git diff --check`.
|
||||
- [x] T068 Report unrelated broader-suite or fixture issues honestly if they remain outside this slice.
|
||||
|
||||
## Non-Goals Checklist
|
||||
|
||||
- [x] NT001 Do not rewrite provider verification execution or provider-connection architecture.
|
||||
- [x] NT002 Do not add a new permission model, provider status persistence, or onboarding wizard flow.
|
||||
- [x] NT003 Do not add auto-consent, auto-repair, or fake "make provider ready" buttons.
|
||||
- [x] NT004 Do not widen Provider Connections global search, panel setup, or routing architecture.
|
||||
- [x] NT005 Do not introduce live provider calls during render.
|
||||
- [x] NT006 Do not add customer portal, PDF/HTML renderer, PSA, billing, or AI follow-up work.
|
||||
|
||||
## Required Final Report Content
|
||||
|
||||
When implementation later completes, report:
|
||||
|
||||
- changed provider-readiness behavior on Provider Connections and Required Permissions
|
||||
- dominant-case selection model
|
||||
- dashboard target continuity behavior
|
||||
- safe action set and any disabled/fallback cases
|
||||
- render-path result for no live provider calls
|
||||
- UI audit artifact updates and screenshot paths
|
||||
- files changed
|
||||
- tests run and results
|
||||
- explicit no migrations/packages/env/queues/scheduler/storage/panel/global-search change statement
|
||||
- known gaps or deferred findings
|
||||
Loading…
Reference in New Issue
Block a user