## Summary - introduce a shared reason-translation contract with envelopes, presenter helpers, fallback handling, and provider translation support - adopt translated operator-facing reason presentation across operation runs, notifications, provider guidance, tenant operability, and RBAC-related surfaces - add Spec 157 design artifacts and targeted regression coverage for translation quality, diagnostics retention, and authorization-safe guidance ## Validation - `vendor/bin/sail bin pint --dirty --format agent` - `vendor/bin/sail artisan test --compact tests/Architecture/ReasonTranslationPrimarySurfaceGuardTest.php tests/Unit/Support/ReasonTranslation/ReasonResolutionEnvelopeTest.php tests/Unit/Support/ReasonTranslation/ExecutionDenialReasonTranslationTest.php tests/Unit/Support/ReasonTranslation/TenantOperabilityReasonTranslationTest.php tests/Unit/Support/ReasonTranslation/RbacReasonTranslationTest.php tests/Unit/Support/ReasonTranslation/ProviderReasonTranslationTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/ReasonTranslation/GovernanceReasonPresentationTest.php tests/Feature/Authorization/ReasonTranslationScopeSafetyTest.php tests/Feature/Monitoring/OperationRunBlockedSpec081Test.php tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php tests/Feature/ProviderConnections/ProviderGatewayRuntimeSmokeSpec081Test.php` ## Notes - Livewire v4.0+ compliance remains unchanged within the existing Filament v5 stack. - No new panel was added; provider registration remains in `bootstrap/providers.php`. - No new globally searchable resource was introduced. - No new destructive action family was introduced. - No new assets were added; the existing `filament:assets` deployment behavior remains unchanged. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #187
365 lines
18 KiB
PHP
365 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Providers;
|
|
|
|
use App\Filament\Resources\ProviderConnectionResource;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\Tenant;
|
|
use App\Support\Links\RequiredPermissionsLinks;
|
|
use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode;
|
|
use App\Support\ReasonTranslation\NextStepOption;
|
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
|
|
final class ProviderReasonTranslator implements TranslatesReasonCode
|
|
{
|
|
public const string ARTIFACT_KEY = 'provider_reason_codes';
|
|
|
|
public function artifactKey(): string
|
|
{
|
|
return self::ARTIFACT_KEY;
|
|
}
|
|
|
|
public function canTranslate(string $reasonCode): bool
|
|
{
|
|
return ProviderReasonCodes::isKnown(trim($reasonCode));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
public function translate(string $reasonCode, string $surface = 'detail', array $context = []): ?ReasonResolutionEnvelope
|
|
{
|
|
$reasonCode = trim($reasonCode);
|
|
|
|
if ($reasonCode === '') {
|
|
return null;
|
|
}
|
|
|
|
$normalizedCode = ProviderReasonCodes::isKnown($reasonCode)
|
|
? $reasonCode
|
|
: ProviderReasonCodes::UnknownError;
|
|
$tenant = $context['tenant'] ?? null;
|
|
$connection = $context['connection'] ?? null;
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
$nextSteps = $this->fallbackNextSteps($normalizedCode);
|
|
} else {
|
|
$nextSteps = $this->nextStepsFor($tenant, $normalizedCode, $connection instanceof ProviderConnection ? $connection : null);
|
|
}
|
|
|
|
return match ($normalizedCode) {
|
|
ProviderReasonCodes::ProviderConnectionMissing => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Provider connection required',
|
|
shortExplanation: 'This tenant does not have a usable provider connection for Microsoft operations.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::ProviderConnectionInvalid => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Provider connection needs review',
|
|
shortExplanation: 'The selected provider connection is incomplete or no longer valid for this workflow.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::ProviderCredentialMissing => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Credentials missing',
|
|
shortExplanation: 'The provider connection is missing the credentials required to authenticate.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::ProviderCredentialInvalid => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Credentials need review',
|
|
shortExplanation: 'Stored provider credentials are no longer valid for the selected provider connection.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::ProviderConnectionTypeInvalid => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Connection type unsupported',
|
|
shortExplanation: 'The selected provider connection type cannot be used for this workflow.',
|
|
actionability: 'permanent_configuration',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::PlatformIdentityMissing => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Platform identity missing',
|
|
shortExplanation: 'The platform provider connection is missing the app identity details required to continue.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::PlatformIdentityIncomplete => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Platform identity incomplete',
|
|
shortExplanation: 'The platform provider connection needs more app identity details before it can continue.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::DedicatedCredentialMissing => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Dedicated credentials required',
|
|
shortExplanation: 'This dedicated provider connection cannot continue until dedicated credentials are configured.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::DedicatedCredentialInvalid => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Dedicated credentials need review',
|
|
shortExplanation: 'The dedicated credentials are no longer valid for this provider connection.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::ProviderConsentMissing => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Admin consent required',
|
|
shortExplanation: 'The provider connection cannot continue until admin consent is granted.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::ProviderConsentFailed => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Admin consent check failed',
|
|
shortExplanation: 'TenantPilot could not confirm admin consent for this provider connection.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::ProviderConsentRevoked => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Admin consent revoked',
|
|
shortExplanation: 'Previously granted admin consent is no longer valid for this provider connection.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::ProviderConnectionReviewRequired => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Connection classification needs review',
|
|
shortExplanation: 'TenantPilot needs you to confirm how this provider connection should be used.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::ProviderAuthFailed => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Provider authentication failed',
|
|
shortExplanation: 'The provider connection could not authenticate with the stored credentials.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::ProviderPermissionMissing => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Permissions missing',
|
|
shortExplanation: 'The provider app is missing required Microsoft Graph permissions.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::ProviderPermissionDenied => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Permission denied',
|
|
shortExplanation: 'Microsoft Graph denied the requested permission for this provider connection.',
|
|
actionability: 'permanent_configuration',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::ProviderPermissionRefreshFailed => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Permission refresh failed',
|
|
shortExplanation: 'TenantPilot could not refresh the provider permission snapshot.',
|
|
actionability: 'retryable_transient',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::IntuneRbacPermissionMissing => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Intune RBAC permission missing',
|
|
shortExplanation: 'The provider app lacks the Intune RBAC permission needed for this workflow.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::TenantTargetMismatch => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Connection targets a different tenant',
|
|
shortExplanation: 'The selected provider connection points to a different Microsoft tenant than the current scope.',
|
|
actionability: 'permanent_configuration',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::NetworkUnreachable => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Microsoft Graph temporarily unreachable',
|
|
shortExplanation: 'TenantPilot could not reach Microsoft Graph or the provider dependency.',
|
|
actionability: 'retryable_transient',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::RateLimited => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Request rate limited',
|
|
shortExplanation: 'Microsoft Graph asked TenantPilot to slow down before retrying.',
|
|
actionability: 'retryable_transient',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::IntuneRbacNotConfigured => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Intune RBAC not configured',
|
|
shortExplanation: 'Intune RBAC has not been configured for this tenant yet.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::IntuneRbacUnhealthy => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Intune RBAC health degraded',
|
|
shortExplanation: 'The latest Intune RBAC health check found a blocking issue.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
ProviderReasonCodes::IntuneRbacStale => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: 'Intune RBAC check is stale',
|
|
shortExplanation: 'The latest Intune RBAC health check is too old to trust for write operations.',
|
|
actionability: 'prerequisite_missing',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
default => $this->envelope(
|
|
reasonCode: $normalizedCode,
|
|
operatorLabel: str_starts_with($normalizedCode, 'ext.')
|
|
? 'Provider configuration needs review'
|
|
: 'Provider check needs review',
|
|
shortExplanation: 'TenantPilot recorded a provider error that does not yet have a domain-specific translation.',
|
|
actionability: 'permanent_configuration',
|
|
nextSteps: $nextSteps,
|
|
),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<int, NextStepOption> $nextSteps
|
|
*/
|
|
private function envelope(
|
|
string $reasonCode,
|
|
string $operatorLabel,
|
|
string $shortExplanation,
|
|
string $actionability,
|
|
array $nextSteps,
|
|
): ReasonResolutionEnvelope {
|
|
return new ReasonResolutionEnvelope(
|
|
internalCode: $reasonCode,
|
|
operatorLabel: $operatorLabel,
|
|
shortExplanation: $shortExplanation,
|
|
actionability: $actionability,
|
|
nextSteps: $nextSteps,
|
|
showNoActionNeeded: false,
|
|
diagnosticCodeLabel: $reasonCode,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, NextStepOption>
|
|
*/
|
|
private function fallbackNextSteps(string $reasonCode): array
|
|
{
|
|
return match ($reasonCode) {
|
|
ProviderReasonCodes::NetworkUnreachable, ProviderReasonCodes::RateLimited => [
|
|
NextStepOption::instruction('Retry after the provider dependency recovers.'),
|
|
],
|
|
ProviderReasonCodes::UnknownError => [
|
|
NextStepOption::instruction('Review the provider connection and retry once the cause is understood.'),
|
|
],
|
|
default => [
|
|
NextStepOption::instruction('Review the provider connection before retrying.'),
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<int, NextStepOption>
|
|
*/
|
|
private function nextStepsFor(
|
|
Tenant $tenant,
|
|
string $reasonCode,
|
|
?ProviderConnection $connection = null,
|
|
): array {
|
|
return match ($reasonCode) {
|
|
ProviderReasonCodes::ProviderConnectionMissing,
|
|
ProviderReasonCodes::ProviderConnectionInvalid,
|
|
ProviderReasonCodes::TenantTargetMismatch,
|
|
ProviderReasonCodes::PlatformIdentityMissing,
|
|
ProviderReasonCodes::PlatformIdentityIncomplete,
|
|
ProviderReasonCodes::ProviderConnectionReviewRequired => [
|
|
NextStepOption::link(
|
|
label: $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections',
|
|
destination: $connection instanceof ProviderConnection
|
|
? ProviderConnectionResource::getUrl('view', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
|
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
),
|
|
NextStepOption::link(
|
|
label: 'Review effective app details',
|
|
destination: $connection instanceof ProviderConnection
|
|
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
|
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
),
|
|
],
|
|
ProviderReasonCodes::DedicatedCredentialMissing,
|
|
ProviderReasonCodes::DedicatedCredentialInvalid => [
|
|
NextStepOption::link(
|
|
label: $connection instanceof ProviderConnection ? 'Manage dedicated connection' : 'Manage Provider Connections',
|
|
destination: $connection instanceof ProviderConnection
|
|
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
|
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
),
|
|
],
|
|
ProviderReasonCodes::ProviderCredentialMissing,
|
|
ProviderReasonCodes::ProviderCredentialInvalid,
|
|
ProviderReasonCodes::ProviderConsentFailed,
|
|
ProviderReasonCodes::ProviderConsentRevoked,
|
|
ProviderReasonCodes::ProviderAuthFailed,
|
|
ProviderReasonCodes::ProviderConsentMissing => [
|
|
NextStepOption::link(
|
|
label: 'Grant admin consent',
|
|
destination: RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
|
|
),
|
|
NextStepOption::link(
|
|
label: $connection instanceof ProviderConnection
|
|
? ($connection->connection_type === ProviderConnectionType::Dedicated ? 'Manage dedicated connection' : 'Review platform connection')
|
|
: 'Manage Provider Connections',
|
|
destination: $connection instanceof ProviderConnection
|
|
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
|
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
),
|
|
],
|
|
ProviderReasonCodes::ProviderPermissionMissing,
|
|
ProviderReasonCodes::ProviderPermissionDenied,
|
|
ProviderReasonCodes::ProviderPermissionRefreshFailed,
|
|
ProviderReasonCodes::IntuneRbacPermissionMissing => [
|
|
NextStepOption::link(
|
|
label: 'Open Required Permissions',
|
|
destination: RequiredPermissionsLinks::requiredPermissions($tenant),
|
|
),
|
|
],
|
|
ProviderReasonCodes::IntuneRbacNotConfigured,
|
|
ProviderReasonCodes::IntuneRbacUnhealthy,
|
|
ProviderReasonCodes::IntuneRbacStale => [
|
|
NextStepOption::link(
|
|
label: 'Review provider connections',
|
|
destination: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
),
|
|
NextStepOption::instruction('Refresh the tenant RBAC health check before retrying.', scope: 'tenant'),
|
|
],
|
|
ProviderReasonCodes::NetworkUnreachable,
|
|
ProviderReasonCodes::RateLimited => [
|
|
NextStepOption::instruction('Retry after the provider dependency recovers.', scope: 'tenant'),
|
|
NextStepOption::link(
|
|
label: 'Review provider connection',
|
|
destination: $connection instanceof ProviderConnection
|
|
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
|
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
),
|
|
],
|
|
default => [
|
|
NextStepOption::link(
|
|
label: 'Manage Provider Connections',
|
|
destination: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
),
|
|
],
|
|
};
|
|
}
|
|
}
|