*/ public array $selectedPolicyTypes = []; /** * @var array|null */ public ?array $navigationContextPayload = null; /** * @var array|null */ public ?array $preview = null; /** * @var array|null */ public ?array $preflight = null; public ?string $selectionMessage = null; public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions preserve return navigation, tenant drill-downs, and one dominant promotion-preflight action.') ->exempt(ActionSurfaceSlot::InspectAffordance, 'The compare page uses explicit selection controls instead of row-click inspection.') ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Cross-tenant compare renders focused subject summaries instead of row-level overflow actions.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The compare page has no bulk actions.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The compare page explains when a selection is incomplete or invalid before any preview exists.') ->exempt(ActionSurfaceSlot::DetailHeader, 'Cross-tenant compare is a workspace decision page, not a record detail header.'); } public function mount(): void { $this->authorizePageAccess(); $this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null; $this->hydrateSelectionFromRequest(); $this->refreshPreview(); $this->form->fill($this->formState()); } public function form(Schema $schema): Schema { return $schema ->schema([ Grid::make([ 'default' => 1, 'xl' => 3, ]) ->schema([ Select::make('sourceTenantId') ->label('Source tenant') ->options(fn (): array => $this->tenantOptions()) ->searchable() ->preload() ->native(false) ->placeholder('Select a source tenant') ->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-source']) ->extraInputAttributes(['data-testid' => 'cross-tenant-source']), Select::make('targetTenantId') ->label('Target tenant') ->options(fn (): array => $this->tenantOptions()) ->searchable() ->preload() ->native(false) ->placeholder('Select a target tenant') ->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-target']) ->extraInputAttributes(['data-testid' => 'cross-tenant-target']), Select::make('selectedPolicyTypes') ->label('Governed subjects') ->options(fn (): array => $this->policyTypeOptions()) ->multiple() ->searchable() ->preload() ->native(false) ->placeholder('All governed subjects') ->helperText(fn (): ?string => $this->policyTypeOptions() === [] ? 'Governed subject filters appear after authorized tenant inventory exists in the active workspace.' : null) ->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-policy-types']) ->extraInputAttributes(['data-testid' => 'cross-tenant-policy-types']), ]), ]); } /** * @return array */ protected function getHeaderActions(): array { $actions = []; $navigationContext = $this->navigationContext(); if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) { $actions[] = Action::make('return_to_origin') ->label($navigationContext->backLinkLabel) ->icon('heroicon-o-arrow-left') ->color('gray') ->url($navigationContext->backLinkUrl); } $sourceTenant = $this->selectedSourceTenant(); if ($sourceTenant instanceof Tenant) { $actions[] = Action::make('open_source_tenant') ->label('Open source tenant') ->icon('heroicon-o-arrow-top-right-on-square') ->color('gray') ->url(TenantResource::getUrl('view', ['record' => $sourceTenant], panel: 'admin')); } $targetTenant = $this->selectedTargetTenant(); if ($targetTenant instanceof Tenant) { $actions[] = Action::make('open_target_tenant') ->label('Open target tenant') ->icon('heroicon-o-arrow-top-right-on-square') ->color('gray') ->url(TenantResource::getUrl('view', ['record' => $targetTenant], panel: 'admin')); } $preflightAction = Action::make('generatePromotionPreflight') ->label('Generate promotion preflight') ->icon('heroicon-o-sparkles') ->color('primary') ->disabled(fn (): bool => $this->preflightDisabledReason() !== null) ->tooltip(fn (): ?string => $this->preflightDisabledReason()) ->action(fn (): mixed => $this->generatePromotionPreflight()); $preflightAction = WorkspaceUiEnforcement::forAction( $preflightAction, fn (): ?Workspace => $this->workspace(), ) ->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE) ->preserveDisabled() ->tooltip('You need workspace baseline manage access to generate a promotion preflight.') ->apply() ->tooltip(function (): ?string { $user = auth()->user(); $workspace = $this->workspace(); if ($user instanceof User && $workspace instanceof Workspace) { /** @var WorkspaceCapabilityResolver $resolver */ $resolver = app(WorkspaceCapabilityResolver::class); if ($resolver->isMember($user, $workspace) && ! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) { return 'You need workspace baseline manage access to generate a promotion preflight.'; } } return $this->preflightDisabledReason(); }); $actions[] = $preflightAction; return $actions; } public function applySelection(): void { $this->selectionMessage = null; $this->preflight = null; $this->sourceTenantId = $this->normalizeTenantIdentifier($this->sourceTenantId); $this->targetTenantId = $this->normalizeTenantIdentifier($this->targetTenantId); $this->selectedPolicyTypes = $this->normalizePolicyTypes($this->selectedPolicyTypes); if ($this->sourceTenantId !== null && $this->targetTenantId !== null && $this->sourceTenantId === $this->targetTenantId) { $this->selectionMessage = 'Choose two different tenants.'; $this->addError('targetTenantId', $this->selectionMessage); return; } $this->redirect($this->selectionUrl(), navigate: true); } public function generatePromotionPreflight(): void { $this->authorizePageAccess(); $this->authorizePreflightExecution(); if ($this->preview === null) { $this->refreshPreview(); } if ($this->preview === null) { return; } $selection = $this->compareSelection(); if (! $selection instanceof CrossTenantCompareSelection) { return; } $this->preflight = app(CrossTenantPromotionPreflight::class)->build($this->preview); $workspace = $this->workspace(); $user = auth()->user(); if ($workspace instanceof Workspace && $user instanceof User) { app(WorkspaceAuditLogger::class)->logCrossTenantPromotionPreflightGenerated( workspace: $workspace, sourceTenant: $selection->sourceTenant, targetTenant: $selection->targetTenant, preflight: $this->preflight, actor: $user, ); } } public function clearSelectionUrl(): string { return static::getUrl($this->routeParameters([ self::SOURCE_TENANT_QUERY_KEY => null, self::TARGET_TENANT_QUERY_KEY => null, self::POLICY_TYPE_QUERY_KEY => null, ]), panel: 'admin'); } public function selectionUrl(): string { return static::getUrl($this->routeParameters(), panel: 'admin'); } public static function launchUrl( ?Tenant $sourceTenant = null, ?Tenant $targetTenant = null, ?CanonicalNavigationContext $navigationContext = null, ): string { $parameters = []; if ($sourceTenant instanceof Tenant) { $parameters[self::SOURCE_TENANT_QUERY_KEY] = (int) $sourceTenant->getKey(); } if ($targetTenant instanceof Tenant) { $parameters[self::TARGET_TENANT_QUERY_KEY] = (int) $targetTenant->getKey(); } if ($navigationContext instanceof CanonicalNavigationContext) { $parameters = array_replace($parameters, $navigationContext->toQuery()); } return static::getUrl($parameters, panel: 'admin'); } public function hasActiveSelection(): bool { return $this->sourceTenantId !== null || $this->targetTenantId !== null || $this->selectedPolicyTypes !== []; } public function stateColor(string $state): string { return match ($state) { 'match', 'ready' => 'success', 'different', 'manual_mapping_required' => 'warning', 'missing' => 'info', 'ambiguous' => 'gray', 'blocked' => 'danger', default => 'gray', }; } public function stateLabel(string $value): string { return Str::headline(str_replace('_', ' ', $value)); } public function reasonLabel(string $reasonCode): string { return Str::headline(str_replace('_', ' ', $reasonCode)); } public function sourceTenantUrl(): ?string { $tenant = $this->selectedSourceTenant(); if (! $tenant instanceof Tenant) { return null; } return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'); } public function targetTenantUrl(): ?string { $tenant = $this->selectedTargetTenant(); if (! $tenant instanceof Tenant) { return null; } return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'); } /** * @return array */ private function formState(): array { return [ 'sourceTenantId' => $this->sourceTenantId, 'targetTenantId' => $this->targetTenantId, 'selectedPolicyTypes' => $this->selectedPolicyTypes, ]; } private function hydrateSelectionFromRequest(): void { $this->sourceTenantId = $this->normalizeTenantIdentifier(request()->query(self::SOURCE_TENANT_QUERY_KEY)); $this->targetTenantId = $this->normalizeTenantIdentifier(request()->query(self::TARGET_TENANT_QUERY_KEY)); $this->selectedPolicyTypes = $this->normalizePolicyTypes(request()->query(self::POLICY_TYPE_QUERY_KEY, [])); } private function refreshPreview(): void { $this->selectionMessage = null; $this->preview = null; $this->preflight = null; $selection = $this->compareSelection(); if (! $selection instanceof CrossTenantCompareSelection) { return; } $this->preview = app(CrossTenantComparePreviewBuilder::class)->build($selection); } private function authorizePageAccess(): void { $user = auth()->user(); $workspace = $this->workspace(); if (! $user instanceof User) { abort(403); } if (! $workspace instanceof Workspace) { abort(404); } /** @var WorkspaceCapabilityResolver $resolver */ $resolver = app(WorkspaceCapabilityResolver::class); if (! $resolver->isMember($user, $workspace)) { abort(404); } if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) { abort(403); } } private function authorizePreflightExecution(): void { $user = auth()->user(); $workspace = $this->workspace(); if (! $user instanceof User) { abort(403); } if (! $workspace instanceof Workspace) { abort(404); } /** @var WorkspaceCapabilityResolver $resolver */ $resolver = app(WorkspaceCapabilityResolver::class); if (! $resolver->isMember($user, $workspace)) { abort(404); } if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) { abort(403); } } private function compareSelection(): ?CrossTenantCompareSelection { $sourceTenant = $this->selectedSourceTenant(); $targetTenant = $this->selectedTargetTenant(); if (! $sourceTenant instanceof Tenant || ! $targetTenant instanceof Tenant) { return null; } if ((int) $sourceTenant->getKey() === (int) $targetTenant->getKey()) { $this->selectionMessage = 'Choose two different tenants.'; return null; } return new CrossTenantCompareSelection( sourceTenant: $sourceTenant, targetTenant: $targetTenant, policyTypes: $this->selectedPolicyTypes, ); } private function selectedSourceTenant(): ?Tenant { if ($this->sourceTenantId === null) { return null; } return $this->resolveAuthorizedTenant($this->sourceTenantId); } private function selectedTargetTenant(): ?Tenant { if ($this->targetTenantId === null) { return null; } return $this->resolveAuthorizedTenant($this->targetTenantId); } private function resolveAuthorizedTenant(string $tenantId): Tenant { $workspace = $this->workspace(); $user = auth()->user(); if (! $workspace instanceof Workspace || ! $user instanceof User) { abort(404); } $tenant = Tenant::query() ->where('workspace_id', (int) $workspace->getKey()) ->whereKey((int) $tenantId) ->first(); if (! $tenant instanceof Tenant) { abort(404); } /** @var CapabilityResolver $resolver */ $resolver = app(CapabilityResolver::class); if (! $user->canAccessTenant($tenant) || ! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) { abort(404); } return $tenant; } /** * @return array */ private function tenantOptions(): array { $workspace = $this->workspace(); $user = auth()->user(); if (! $workspace instanceof Workspace || ! $user instanceof User) { return []; } /** @var CapabilityResolver $resolver */ $resolver = app(CapabilityResolver::class); $tenants = $user->tenants() ->where('tenants.workspace_id', (int) $workspace->getKey()) ->select('tenants.*') ->orderBy('tenants.name') ->get(); $resolver->primeMemberships($user, $tenants->modelKeys()); return $tenants ->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) ->mapWithKeys(fn (Tenant $tenant): array => [ (string) $tenant->getKey() => (string) $tenant->name, ]) ->all(); } /** * @return array */ private function policyTypeOptions(): array { $tenantIds = array_map(static fn (string $tenantId): int => (int) $tenantId, array_keys($this->tenantOptions())); if ($tenantIds === []) { return []; } return InventoryItem::query() ->whereIn('tenant_id', $tenantIds) ->whereNotNull('policy_type') ->where('policy_type', '!=', '') ->distinct() ->orderBy('policy_type') ->pluck('policy_type') ->mapWithKeys(fn (string $policyType): array => [ $policyType => Str::headline($policyType), ]) ->all(); } private function preflightDisabledReason(): ?string { if ($this->selectionMessage !== null) { return $this->selectionMessage; } if (! is_array($this->preview)) { return 'Select an authorized source and target tenant to generate a promotion preflight.'; } if ((int) data_get($this->preview, 'summary.total', 0) === 0) { return 'No governed subjects are available for this compare selection yet.'; } return null; } /** * @param mixed $value */ private function normalizeTenantIdentifier(mixed $value): ?string { if (! is_string($value) && ! is_int($value)) { return null; } $normalized = trim((string) $value); return is_numeric($normalized) && (int) $normalized > 0 ? (string) (int) $normalized : null; } /** * @param mixed $value * @return list */ private function normalizePolicyTypes(mixed $value): array { $allowed = array_fill_keys(array_keys($this->policyTypeOptions()), true); $values = match (true) { is_string($value) && $value !== '' => [$value], is_array($value) => $value, default => [], }; return array_values(array_filter(array_unique(array_map( static fn (mixed $item): string => is_string($item) ? trim($item) : '', $values, )), static fn (string $item): bool => $item !== '' && isset($allowed[$item]))); } /** * @param array $overrides * @return array */ private function routeParameters(array $overrides = []): array { $parameters = [ self::SOURCE_TENANT_QUERY_KEY => $this->sourceTenantId, self::TARGET_TENANT_QUERY_KEY => $this->targetTenantId, self::POLICY_TYPE_QUERY_KEY => $this->selectedPolicyTypes, ]; if (is_array($this->navigationContextPayload)) { $parameters['nav'] = $this->navigationContextPayload; } foreach ($overrides as $key => $value) { $parameters[$key] = $value; } return array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []); } private function navigationContext(): ?CanonicalNavigationContext { if (! is_array($this->navigationContextPayload)) { return CanonicalNavigationContext::fromRequest(request()); } return CanonicalNavigationContext::fromPayload($this->navigationContextPayload); } private function workspace(): ?Workspace { $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); if (! is_int($workspaceId)) { return null; } $workspace = Workspace::query()->whereKey($workspaceId)->first(); return $workspace instanceof Workspace ? $workspace : null; } }