false, ]; } public Workspace $workspace; public ?Tenant $managedTenant = null; #[Locked] public ?int $managedTenantId = null; public ?TenantOnboardingSession $onboardingSession = null; #[Locked] public ?int $onboardingSessionId = null; public ?int $onboardingSessionVersion = null; public ?int $selectedProviderConnectionId = null; public bool $showDraftPicker = false; public bool $showStartState = false; /** * Filament schema state. * * @var array */ public array $data = []; /** * @var array */ public array $selectedBootstrapOperationTypes = []; /** * @return array */ protected function getHeaderActions(): array { $actions = []; $draft = $this->currentOnboardingSessionRecord(); $tenant = $this->currentManagedTenantRecord(); if (isset($this->workspace)) { $actions[] = Action::make('back_to_workspace') ->label('Back to workspace') ->color('gray') ->url(route('admin.home')); } if ($this->shouldShowDraftLandingAction()) { $actions[] = Action::make('back_to_onboarding_landing') ->label($this->onboardingDraftLandingActionLabel()) ->color('gray') ->url(route('admin.onboarding')); } if ($this->canViewLinkedTenant()) { $actions[] = Action::make('view_linked_tenant') ->label($this->linkedTenantActionLabel()) ->color('gray') ->url($tenant instanceof Tenant ? TenantResource::getUrl('view', ['record' => $tenant]) : null); } if ($this->canResumeDraft($draft)) { $actions[] = Action::make('cancel_onboarding_draft') ->label('Cancel draft') ->color('danger') ->requiresConfirmation() ->modalHeading('Cancel onboarding draft') ->modalDescription('This draft will become non-resumable. Confirm only if you intend to stop this onboarding flow.') ->visible(fn (): bool => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CANCEL)) ->action(fn () => $this->cancelOnboardingDraft()); } if ($this->canDeleteDraft($draft)) { $actions[] = Action::make('delete_onboarding_draft_header') ->label('Delete draft') ->color('danger') ->requiresConfirmation() ->modalHeading('Delete onboarding draft') ->modalDescription('This permanently deletes the onboarding draft record. The linked tenant record, if any, is not deleted.') ->modalSubmitActionLabel('Delete draft') ->visible(fn (): bool => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CANCEL)) ->action(fn () => $this->deleteOnboardingDraft()); } return $actions; } private function canViewLinkedTenant(): bool { $user = auth()->user(); $tenant = $this->currentManagedTenantRecord(); if (! $user instanceof User || ! $tenant instanceof Tenant) { return false; } if (! $user->canAccessTenant($tenant)) { return false; } return app(TenantOperabilityService::class)->outcomeFor( tenant: $tenant, question: TenantOperabilityQuestion::TenantBoundViewability, actor: $user, workspaceId: (int) $this->workspace->getKey(), lane: TenantInteractionLane::AdministrativeManagement, )->allowed; } private function linkedTenantActionLabel(): string { $tenant = $this->currentManagedTenantRecord(); if (! $tenant instanceof Tenant) { return 'View tenant'; } return sprintf( 'View tenant (%s)', TenantLifecyclePresentation::fromTenant($tenant)->label, ); } public function mount(TenantOnboardingSession|int|string|null $onboardingDraft = null): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); if ($workspaceId === null) { if ($onboardingDraft !== null) { abort(404); } $this->redirect('/admin/choose-workspace'); return; } $workspace = Workspace::query()->whereKey($workspaceId)->first(); if (! $workspace instanceof Workspace) { abort(404); } if (! app(WorkspaceContext::class)->isMember($user, $workspace)) { abort(404); } $this->workspace = $workspace; $this->authorize('viewAny', TenantOnboardingSession::class); if ($onboardingDraft !== null) { $this->loadOnboardingDraft($user, $onboardingDraft); $this->initializeWizardData(); return; } if ($this->resolveLandingState($user)) { return; } $this->showStartState = true; $this->initializeWizardData(); } public function content(Schema $schema): Schema { if ($this->showDraftPicker) { return $schema->schema($this->draftPickerSchema()); } if ($this->showsNonResumableSummary()) { return $schema->schema($this->nonResumableSummarySchema()); } return $schema ->statePath('data') ->schema([ SchemaView::make('filament.schemas.components.managed-tenant-onboarding-checkpoint-poll') ->visible(fn (): bool => $this->shouldPollCheckpointLifecycle()), ...$this->resumeContextSchema(), Wizard::make([ Step::make('Identify managed tenant') ->description('Create or resume a managed tenant in this workspace.') ->schema([ Section::make('Tenant') ->schema([ TextInput::make('entra_tenant_id') ->label('Entra Tenant ID (GUID)') ->required() ->placeholder('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx') ->rules(['uuid']) ->maxLength(255), Select::make('environment') ->label('Environment') ->required() ->options([ 'prod' => 'Production', 'staging' => 'Staging', 'dev' => 'Development', 'other' => 'Other', ]) ->default('other'), TextInput::make('name') ->label('Display name') ->required() ->maxLength(255), TextInput::make('primary_domain') ->label('Primary domain (optional)') ->maxLength(255), Textarea::make('notes') ->label('Notes (optional)') ->rows(3) ->maxLength(2000), ]), ]) ->afterValidation(function (): void { $entraTenantId = (string) ($this->data['entra_tenant_id'] ?? ''); $environment = (string) ($this->data['environment'] ?? 'other'); $tenantName = (string) ($this->data['name'] ?? ''); $primaryDomain = (string) ($this->data['primary_domain'] ?? ''); $notes = (string) ($this->data['notes'] ?? ''); try { $this->identifyManagedTenant([ 'entra_tenant_id' => $entraTenantId, 'environment' => $environment, 'name' => $tenantName, 'primary_domain' => $primaryDomain, 'notes' => $notes, ]); } catch (NotFoundHttpException) { Notification::make() ->title('Tenant not available') ->body('This tenant cannot be onboarded in this workspace.') ->danger() ->send(); throw new Halt; } $this->initializeWizardData(); }), Step::make('Provider connection') ->description('Select an existing connection or create a new one.') ->schema([ Section::make('Connection') ->schema([ Radio::make('connection_mode') ->label('Mode') ->options([ 'existing' => 'Use existing connection', 'new' => 'Create new connection', ]) ->required() ->default('existing') ->live(), Select::make('provider_connection_id') ->label('Provider connection') ->required(fn (Get $get): bool => $get('connection_mode') === 'existing') ->options(fn (): array => $this->providerConnectionOptions()) ->visible(fn (Get $get): bool => $get('connection_mode') === 'existing') ->hintActions([ Action::make('edit_selected_connection') ->label('Edit selected connection') ->icon('heroicon-m-pencil-square') ->color('gray') ->slideOver() ->modalHeading('Edit provider connection') ->modalDescription('Changes apply to this workspace connection.') ->modalSubmitActionLabel('Save changes') ->closeModalByClickingAway(false) ->visible(fn (Get $get): bool => $get('connection_mode') === 'existing' && is_numeric($get('provider_connection_id'))) ->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE)) ->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE) ? null : 'You don\'t have permission to edit connections.') ->fillForm(function (Get $get): array { $recordId = $get('provider_connection_id'); if (! is_numeric($recordId)) { return []; } return $this->inlineEditSelectedConnectionFill((int) $recordId); }) ->form([ TextInput::make('display_name') ->label('Connection name') ->required() ->maxLength(255), TextInput::make('entra_tenant_id') ->label('Directory (tenant) ID') ->disabled() ->dehydrated(false), Toggle::make('uses_dedicated_override') ->label('Dedicated override') ->helperText('Explicit exception path for a customer-managed app registration.') ->visible(fn (): bool => $this->canManageDedicatedOverride()) ->live(), TextInput::make('client_id') ->label('Dedicated app (client) ID') ->required(fn (Get $get): bool => (bool) $get('uses_dedicated_override')) ->maxLength(255) ->helperText('If you change the dedicated App (client) ID, enter the matching new client secret below.') ->visible(fn (Get $get): bool => (bool) $get('uses_dedicated_override')), TextInput::make('client_secret') ->label('New dedicated client secret') ->password() ->revealable(false) ->maxLength(255) ->helperText('Required when enabling dedicated mode or changing the dedicated App (client) ID. The existing secret is never shown.') ->visible(fn (Get $get): bool => (bool) $get('uses_dedicated_override')), ]) ->action(function (array $data, Get $get): void { $recordId = $get('provider_connection_id'); if (! is_numeric($recordId)) { abort(404); } $this->updateSelectedProviderConnectionInline((int) $recordId, $data); }), ]), TextInput::make('new_connection.display_name') ->label('Display name') ->required(fn (Get $get): bool => $get('connection_mode') === 'new') ->maxLength(255) ->visible(fn (Get $get): bool => $get('connection_mode') === 'new'), TextInput::make('new_connection.connection_type') ->label('Connection type') ->default('Platform connection') ->disabled() ->dehydrated(false) ->visible(fn (Get $get): bool => $get('connection_mode') === 'new') ->helperText('Managed centrally by platform. Grant admin consent after creating the connection.'), TextInput::make('new_connection.platform_app_id') ->label('Platform app ID') ->default(fn (): string => $this->platformAppClientId()) ->disabled() ->dehydrated(false) ->visible(fn (Get $get): bool => $get('connection_mode') === 'new'), Toggle::make('new_connection.uses_dedicated_override') ->label('Dedicated override') ->helperText('Explicit exception path for a customer-managed app registration.') ->visible(fn (Get $get): bool => $get('connection_mode') === 'new' && $this->canManageDedicatedOverride()) ->live(), TextInput::make('new_connection.client_id') ->label('Dedicated app (client) ID') ->required(fn (Get $get): bool => $get('connection_mode') === 'new' && (bool) $get('new_connection.uses_dedicated_override')) ->maxLength(255) ->visible(fn (Get $get): bool => $get('connection_mode') === 'new' && (bool) $get('new_connection.uses_dedicated_override')), TextInput::make('new_connection.client_secret') ->label('Dedicated client secret') ->password() ->revealable(false) ->required(fn (Get $get): bool => $get('connection_mode') === 'new' && (bool) $get('new_connection.uses_dedicated_override')) ->maxLength(255) ->helperText('Required only for the dedicated override path. The secret is never shown again after save.') ->visible(fn (Get $get): bool => $get('connection_mode') === 'new' && (bool) $get('new_connection.uses_dedicated_override')), Toggle::make('new_connection.is_default') ->label('Make default') ->default(true) ->visible(fn (Get $get): bool => $get('connection_mode') === 'new'), ]), ]) ->afterValidation(function (): void { if (! $this->managedTenant instanceof Tenant) { throw new Halt; } $mode = (string) ($this->data['connection_mode'] ?? 'existing'); if ($mode === 'new') { $new = is_array($this->data['new_connection'] ?? null) ? $this->data['new_connection'] : []; $this->createProviderConnection([ 'display_name' => (string) ($new['display_name'] ?? ''), 'connection_type' => (bool) ($new['uses_dedicated_override'] ?? false) ? ProviderConnectionType::Dedicated->value : ProviderConnectionType::Platform->value, 'client_id' => (string) ($new['client_id'] ?? ''), 'client_secret' => (string) ($new['client_secret'] ?? ''), 'is_default' => (bool) ($new['is_default'] ?? true), ]); } else { $providerConnectionId = (int) ($this->data['provider_connection_id'] ?? 0); if ($providerConnectionId <= 0) { throw new Halt; } $this->selectProviderConnection($providerConnectionId); } $this->touchOnboardingSessionStep('connection'); $this->initializeWizardData(); }), Step::make('Verify access') ->description('Run a queued verification check (Operation Run).') ->schema([ Section::make('Verification') ->schema([ Text::make(fn (): string => 'Status: '.$this->verificationStatusLabel()) ->badge() ->color(fn (): string => $this->verificationStatusColor()), Text::make('Connection updated — re-run verification to refresh results.') ->visible(fn (): bool => $this->connectionRecentlyUpdated()), Text::make('The selected provider connection has changed since this verification run. Start verification again to validate the current connection.') ->visible(fn (): bool => $this->verificationRunIsStaleForSelectedConnection()), Text::make('Verification is in progress. Status updates automatically about every 5 seconds. Use “Refresh” to re-check immediately.') ->visible(fn (): bool => $this->verificationStatus() === 'in_progress'), SchemaActions::make([ Action::make('wizardStartVerification') ->label('Start verification') ->visible(fn (): bool => $this->managedTenant instanceof Tenant && ! $this->verificationRunIsActive()) ->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)) ->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START) ? null : 'You do not have permission to start verification.') ->action(fn () => $this->startVerification()), Action::make('wizardRefreshVerification') ->label('Refresh') ->visible(fn (): bool => $this->verificationRunUrl() !== null && $this->verificationStatus() === 'in_progress') ->action(fn () => $this->refreshVerificationStatus()), ]), ViewField::make('verification_report') ->label('') ->default(null) ->view('filament.forms.components.managed-tenant-onboarding-verification-report') ->viewData(fn (): array => $this->verificationReportViewData()) ->visible(fn (): bool => $this->verificationRunUrl() !== null), ]), ]) ->beforeValidation(function (): void { if (! $this->verificationCanProceed()) { Notification::make() ->title('Verification required') ->body('Complete verification for the selected provider connection before continuing.') ->warning() ->send(); throw new Halt; } $this->touchOnboardingSessionStep('verify'); }), Step::make('Bootstrap (optional)') ->description('Optionally start inventory and compliance operations.') ->schema([ Section::make('Bootstrap') ->schema([ CheckboxList::make('bootstrap_operation_types') ->label('Bootstrap actions') ->options(fn (): array => $this->bootstrapOperationOptions()) ->columns(1), SchemaActions::make([ Action::make('wizardStartBootstrap') ->label('Start bootstrap') ->visible(fn (): bool => $this->managedTenant instanceof Tenant) ->disabled(fn (): bool => ! $this->canStartAnyBootstrap()) ->tooltip(fn (): ?string => $this->canStartAnyBootstrap() ? null : 'You do not have permission to start bootstrap actions.') ->action(fn () => $this->startBootstrap((array) ($this->data['bootstrap_operation_types'] ?? []))), ]), Text::make(fn (): string => $this->bootstrapRunsLabel()) ->hidden(fn (): bool => $this->bootstrapRunsLabel() === ''), ]), ]) ->afterValidation(function (): void { $types = $this->data['bootstrap_operation_types'] ?? []; $this->selectedBootstrapOperationTypes = is_array($types) ? array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== '')) : []; $this->touchOnboardingSessionStep('bootstrap'); }), Step::make('Complete') ->description('Review configuration and complete onboarding for this tenant.') ->schema([ Section::make('Review & Complete onboarding') ->description('Review the onboarding summary before completing onboarding for this tenant.') ->schema([ Section::make('Onboarding summary') ->compact() ->columns(2) ->schema([ Text::make('Tenant') ->color('gray'), Text::make(fn (): string => $this->completionSummaryTenantLine()) ->weight(FontWeight::SemiBold), Text::make('Provider connection') ->color('gray'), Text::make(fn (): string => $this->completionSummaryConnectionSummary()) ->weight(FontWeight::SemiBold), Text::make('Verification') ->color('gray'), Text::make(fn (): string => $this->verificationStatusLabel().' — '.$this->completionSummaryVerificationDetail()) ->badge() ->color(fn (): string => $this->verificationStatusColor()), Text::make('Bootstrap') ->color('gray'), Text::make(fn (): string => $this->completionSummaryBootstrapSummary()) ->badge() ->color(fn (): string => $this->completionSummaryBootstrapColor()), ]), Callout::make('After completion') ->description('This action is recorded in the audit log and cannot be undone from this wizard.') ->info() ->footer([ UnorderedList::make([ 'Tenant status will be set to Active.', 'Backup, inventory, and compliance operations become available.', 'The provider connection will be used for all Graph API calls.', ]), ]), Toggle::make('override_blocked') ->label('Override blocked verification') ->helperText('Owner-only. Requires a reason and will be recorded in the audit log.') ->visible(fn (): bool => $this->verificationStatus() === 'blocked' && $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)), Textarea::make('override_reason') ->label('Override reason') ->required(fn (Get $get): bool => (bool) $get('override_blocked')) ->visible(fn (Get $get): bool => (bool) $get('override_blocked')) ->rows(3) ->maxLength(500), SchemaActions::make([ Action::make('wizardCompleteOnboarding') ->label('Complete onboarding') ->color('success') ->requiresConfirmation() ->modalHeading('Complete onboarding') ->modalDescription(fn (): string => $this->managedTenant instanceof Tenant ? sprintf('Are you sure you want to complete onboarding for "%s"? This will make the tenant operational.', $this->managedTenant->name) : 'Are you sure you want to complete onboarding for this tenant?') ->modalSubmitActionLabel('Yes, complete onboarding') ->disabled(fn (): bool => ! $this->canCompleteOnboarding() || ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)) ->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE) ? null : 'Owner required to complete onboarding.') ->action(fn () => $this->completeOnboarding()), ]), ]), ]) ->beforeValidation(function (): void { if (! $this->canCompleteOnboarding()) { throw new Halt; } }), ]) ->startOnStep(fn (): int => $this->computeWizardStartStep()) ->skippable(false), ]); } private function resolveLandingState(User $user): bool { $drafts = $this->availableDraftsFor($user); if ($drafts->count() === 1) { $draft = $drafts->first(); if ($draft instanceof TenantOnboardingSession) { $this->redirect(route('admin.onboarding.draft', ['onboardingDraft' => $draft])); return true; } } if ($drafts->count() > 1) { $this->showDraftPicker = true; } return false; } private function loadOnboardingDraft(User $user, TenantOnboardingSession|int|string $onboardingDraft): void { $draft = app(OnboardingDraftResolver::class)->resolve($onboardingDraft, $user, $this->workspace); $this->showDraftPicker = false; $this->showStartState = false; $this->setOnboardingSession($draft); $tenant = $draft->tenant; if ($tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $this->workspace->getKey()) { $this->setManagedTenant($tenant); } $providerConnectionId = $draft->state['provider_connection_id'] ?? null; $this->selectedProviderConnectionId = $this->resolvePersistedProviderConnectionId($providerConnectionId); $bootstrapTypes = $draft->state['bootstrap_operation_types'] ?? []; $this->selectedBootstrapOperationTypes = is_array($bootstrapTypes) ? array_values(array_filter($bootstrapTypes, static fn ($v): bool => is_string($v) && $v !== '')) : []; } /** * @return Collection */ private function availableDraftsFor(User $user): Collection { return app(OnboardingDraftResolver::class)->resumableDraftsFor($user, $this->workspace); } /** * @return array */ private function draftPickerSchema(): array { $drafts = $this->currentUser() instanceof User ? $this->availableDraftsFor($this->currentUser()) : collect(); $components = [ Callout::make('Multiple onboarding drafts are available.') ->description('Select a draft to resume or start a new onboarding flow explicitly.') ->warning(), SchemaActions::make([ Action::make('start_new_onboarding_draft') ->label('Start new onboarding') ->color('primary') ->action(fn () => $this->startNewOnboardingDraft()), ])->key('draft_picker_start_actions'), ]; foreach ($drafts as $draft) { $components[] = Section::make($this->draftTitle($draft)) ->description($this->draftDescription($draft)) ->compact() ->columns(2) ->schema([ Text::make('Current stage') ->color('gray'), Text::make(fn (): string => $this->draftStageLabel($draft)) ->badge() ->color(fn (): string => $this->draftStageColor($draft)), Text::make('Status') ->color('gray'), Text::make(fn (): string => $draft->status()->label()) ->badge() ->color(fn (): string => $this->draftStatusColor($draft)), Text::make('Started by') ->color('gray'), Text::make(fn (): string => $draft->startedByUser?->name ?? 'Unknown'), Text::make('Last updated by') ->color('gray'), Text::make(fn (): string => $draft->updatedByUser?->name ?? 'Unknown'), Text::make('Last updated') ->color('gray'), Text::make(fn (): string => $draft->updated_at?->diffForHumans() ?? '—'), Text::make('Draft age') ->color('gray'), Text::make(fn (): string => $draft->created_at?->diffForHumans() ?? '—'), SchemaActions::make([ Action::make('resume_draft_'.$draft->getKey()) ->label($this->resumeOnboardingActionLabel()) ->action(fn () => $this->resumeOnboardingDraft((int) $draft->getKey(), true)), Action::make('view_draft_'.$draft->getKey()) ->label('View summary') ->color('gray') ->action(fn () => $this->resumeOnboardingDraft((int) $draft->getKey(), false)), ])->key('draft_picker_actions_'.$draft->getKey()), ]); } return $components; } /** * @return array */ private function resumeContextSchema(): array { $draft = $this->currentOnboardingSessionRecord(); if (! $draft instanceof TenantOnboardingSession) { return []; } return [ Section::make('Onboarding draft') ->compact() ->collapsible() ->collapsed() ->columns(2) ->schema([ Text::make('Tenant') ->color('gray'), Text::make(fn () => $this->draftTitle($this->currentOnboardingSessionRecord() ?? $draft)) ->weight(FontWeight::SemiBold), Text::make('Current stage') ->color('gray'), Text::make(fn () => $this->draftStageLabel($this->currentOnboardingSessionRecord() ?? $draft)) ->badge() ->color(fn () => $this->draftStageColor($this->currentOnboardingSessionRecord() ?? $draft)), Text::make('Started by') ->color('gray'), Text::make(fn () => ($this->currentOnboardingSessionRecord() ?? $draft)?->startedByUser?->name ?? 'Unknown'), Text::make('Last updated by') ->color('gray'), Text::make(fn () => ($this->currentOnboardingSessionRecord() ?? $draft)?->updatedByUser?->name ?? 'Unknown'), ]), ]; } /** * @return array */ private function nonResumableSummarySchema(): array { $draft = $this->currentOnboardingSessionRecord(); if (! $draft instanceof TenantOnboardingSession) { return []; } $statusLabel = $draft->status()->label(); return [ Callout::make("This onboarding draft is {$statusLabel}.") ->description('Completed, cancelled, and lifecycle-locked drafts remain viewable, but they cannot return to editable wizard mode.') ->warning(), ...$this->resumeContextSchema(), Section::make('Draft summary') ->compact() ->columns(2) ->schema([ Text::make('Status') ->color('gray'), Text::make(fn (): string => $statusLabel) ->badge() ->color(fn () => $this->draftStatusColor($this->currentOnboardingSessionRecord() ?? $draft)), Text::make('Primary domain') ->color('gray'), Text::make(fn () => (string) ((($this->currentOnboardingSessionRecord() ?? $draft)?->state['primary_domain'] ?? null) ?: '—')), Text::make('Environment') ->color('gray'), Text::make(fn () => (string) ((($this->currentOnboardingSessionRecord() ?? $draft)?->state['environment'] ?? null) ?: '—')), Text::make('Notes') ->color('gray'), Text::make(fn () => (string) ((($this->currentOnboardingSessionRecord() ?? $draft)?->state['notes'] ?? null) ?: '—')), ]), SchemaActions::make([ Action::make('back_to_workspace_summary') ->label('Back to workspace') ->color('gray') ->url(route('admin.home')), Action::make('return_to_onboarding_landing') ->label('Return to onboarding') ->color('gray') ->url(route('admin.onboarding')), Action::make('delete_onboarding_draft') ->label('Delete draft') ->color('danger') ->requiresConfirmation() ->modalHeading('Delete onboarding draft') ->modalDescription('This permanently deletes the onboarding draft record. The linked tenant record, if any, is not deleted.') ->modalSubmitActionLabel('Delete draft') ->visible(fn (): bool => $this->canDeleteDraft($this->currentOnboardingSessionRecord() ?? $draft)) ->action(fn () => $this->deleteOnboardingDraft()), ]), ]; } private function startNewOnboardingDraft(): void { $this->showDraftPicker = false; $this->showStartState = true; $this->setManagedTenant(null); $this->setOnboardingSession(null); $this->selectedProviderConnectionId = null; $this->selectedBootstrapOperationTypes = []; $this->data = []; $this->initializeWizardData(); } private function resumeOnboardingDraft(int $draftId, bool $logSelection): void { $user = $this->currentUser(); if (! $user instanceof User) { abort(403); } $draft = app(OnboardingDraftResolver::class)->resolve($draftId, $user, $this->workspace); if ($logSelection) { app(WorkspaceAuditLogger::class)->log( workspace: $this->workspace, action: AuditActionId::ManagedTenantOnboardingDraftSelected->value, context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'onboarding_session_id' => (int) $draft->getKey(), 'tenant_db_id' => $draft->tenant_id !== null ? (int) $draft->tenant_id : null, ], ], actor: $user, status: 'success', resourceType: 'managed_tenant_onboarding_session', resourceId: (string) $draft->getKey(), ); } $this->redirect(route('admin.onboarding.draft', ['onboardingDraft' => $draft])); } private function cancelOnboardingDraft(): void { $user = $this->currentUser(); if (! $user instanceof User) { abort(403); } if (! $this->onboardingSession instanceof TenantOnboardingSession) { abort(404); } $this->authorizeWorkspaceMember($user); $draft = app(TrustedStateResolver::class)->resolveOnboardingDraft( $this->onboardingSessionId ?? $this->onboardingSession, $user, $this->workspace, app(OnboardingDraftResolver::class), ); $this->setOnboardingSession($draft); $this->authorize('cancel', $draft); if (! $this->canResumeDraft($draft)) { Notification::make() ->title('Draft is not resumable') ->warning() ->send(); return; } try { $this->setOnboardingSession($this->mutationService()->mutate( draft: $this->onboardingSession, actor: $user, expectedVersion: $this->expectedDraftVersion(), mutator: function (TenantOnboardingSession $draft): void { $draft->current_step = 'cancelled'; $draft->completed_at = null; $draft->cancelled_at = now(); }, )); } catch (OnboardingDraftConflictException) { $this->handleDraftConflict(); return; } catch (OnboardingDraftImmutableException) { $this->handleImmutableDraft(); return; } app(WorkspaceAuditLogger::class)->log( workspace: $this->workspace, action: AuditActionId::ManagedTenantOnboardingCancelled->value, context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'tenant_db_id' => $this->onboardingSession->tenant_id !== null ? (int) $this->onboardingSession->tenant_id : null, 'onboarding_session_id' => (int) $this->onboardingSession->getKey(), ], ], actor: $user, status: 'success', resourceType: 'managed_tenant_onboarding_session', resourceId: (string) $this->onboardingSession->getKey(), ); $normalizedTenant = $this->lifecycleService()->syncLinkedTenantAfterCancellation($this->onboardingSession); if ($normalizedTenant instanceof Tenant) { app(WorkspaceAuditLogger::class)->logTenantLifecycleAction( tenant: $normalizedTenant, action: AuditActionId::TenantReturnedToDraft, actor: $user, context: [ 'metadata' => [ 'source' => 'onboarding_cancel', 'onboarding_session_id' => (int) $this->onboardingSession->getKey(), ], ], ); $this->setManagedTenant($normalizedTenant); } Notification::make() ->title('Onboarding draft cancelled') ->success() ->send(); $this->redirect(route('admin.onboarding.draft', ['onboardingDraft' => $this->onboardingSession])); } private function deleteOnboardingDraft(): void { $user = $this->currentUser(); if (! $user instanceof User) { abort(403); } if (! $this->onboardingSession instanceof TenantOnboardingSession) { abort(404); } $this->authorizeWorkspaceMember($user); $draft = app(TrustedStateResolver::class)->resolveOnboardingDraft( $this->onboardingSessionId ?? $this->onboardingSession, $user, $this->workspace, app(OnboardingDraftResolver::class), ); $this->setOnboardingSession($draft); $this->authorize('cancel', $draft); if (! $this->canDeleteDraft($draft)) { Notification::make() ->title('Draft cannot be deleted') ->warning() ->send(); return; } $draftId = (int) $draft->getKey(); $draftTitle = $this->draftTitle($draft); $draftStatus = $draft->status()->value; $draftLifecycle = $draft->lifecycleState()->value; $tenantId = $draft->tenant_id !== null ? (int) $draft->tenant_id : null; $draft->delete(); app(WorkspaceAuditLogger::class)->log( workspace: $this->workspace, action: AuditActionId::ManagedTenantOnboardingDeleted->value, context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'onboarding_session_id' => $draftId, 'tenant_db_id' => $tenantId, 'status' => $draftStatus, 'lifecycle_state' => $draftLifecycle, ], ], actor: $user, status: 'success', resourceType: 'managed_tenant_onboarding_session', resourceId: (string) $draftId, targetLabel: $draftTitle, ); $this->setManagedTenant(null); $this->setOnboardingSession(null); Notification::make() ->title('Onboarding draft deleted') ->success() ->send(); $this->redirect(route('admin.onboarding')); } private function showsNonResumableSummary(): bool { $draft = $this->currentOnboardingSessionRecord(); return $draft instanceof TenantOnboardingSession && ! $this->canResumeDraft($draft); } private function canDeleteDraft(?TenantOnboardingSession $draft): bool { return $draft instanceof TenantOnboardingSession && ! $this->canResumeDraft($draft) && $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CANCEL); } private function onboardingDraftLandingActionLabel(): string { $user = $this->currentUser(); if (! $user instanceof User) { return 'Choose onboarding draft'; } return $this->onboardingEntryActionDescriptor($this->availableDraftsFor($user)->count())->label; } private function resumeOnboardingActionLabel(): string { return $this->onboardingEntryActionDescriptor(1)->label; } private function onboardingEntryActionDescriptor(int $resumableDraftCount): \App\Support\Tenants\TenantActionDescriptor { return TenantResource::tenantActionPolicy()->onboardingEntryDescriptor($resumableDraftCount); } private function shouldShowDraftLandingAction(): bool { $draft = $this->currentOnboardingSessionRecord(); if (! $draft instanceof TenantOnboardingSession) { return false; } if (! $this->canResumeDraft($draft)) { return false; } if (! isset($this->workspace)) { return false; } $user = $this->currentUser(); if (! $user instanceof User) { return false; } return $this->availableDraftsFor($user)->count() > 1; } private function draftTitle(TenantOnboardingSession $draft): string { $state = is_array($draft->state) ? $draft->state : []; $tenantName = $state['tenant_name'] ?? $draft->tenant?->name ?? null; $tenantName = is_string($tenantName) && trim($tenantName) !== '' ? trim($tenantName) : 'Unidentified tenant'; $entraTenantId = $state['entra_tenant_id'] ?? $draft->entra_tenant_id; if (is_string($entraTenantId) && trim($entraTenantId) !== '') { return sprintf('%s (%s)', $tenantName, trim($entraTenantId)); } return $tenantName; } private function draftDescription(TenantOnboardingSession $draft): string { $state = is_array($draft->state) ? $draft->state : []; $environment = $state['environment'] ?? null; $primaryDomain = $state['primary_domain'] ?? null; return collect([ is_string($environment) && $environment !== '' ? ucfirst($environment) : null, is_string($primaryDomain) && $primaryDomain !== '' ? $primaryDomain : null, ])->filter()->implode(' · '); } private function draftStageLabel(TenantOnboardingSession $draft): string { return app(OnboardingDraftStageResolver::class)->resolve($draft)->label(); } private function draftStageColor(TenantOnboardingSession $draft): string { return match (app(OnboardingDraftStageResolver::class)->resolve($draft)) { OnboardingDraftStage::Identify => 'gray', OnboardingDraftStage::ConnectProvider => 'info', OnboardingDraftStage::VerifyAccess => 'warning', OnboardingDraftStage::Bootstrap => 'info', OnboardingDraftStage::Review => 'success', OnboardingDraftStage::Completed => 'success', OnboardingDraftStage::Cancelled => 'danger', }; } private function draftStatusColor(TenantOnboardingSession $draft): string { return match ($draft->status()->value) { 'draft' => 'info', 'completed' => 'success', 'cancelled' => 'danger', default => 'gray', }; } private function currentUser(): ?User { $user = auth()->user(); return $user instanceof User ? $user : null; } private function lifecycleService(): OnboardingLifecycleService { return app(OnboardingLifecycleService::class); } private function mutationService(): OnboardingDraftMutationService { return app(OnboardingDraftMutationService::class); } private function expectedDraftVersion(): ?int { return is_int($this->onboardingSessionVersion) && $this->onboardingSessionVersion > 0 ? $this->onboardingSessionVersion : null; } private function setOnboardingSession(?TenantOnboardingSession $draft): void { $this->onboardingSession = $draft; $this->onboardingSessionId = $draft instanceof TenantOnboardingSession ? (int) $draft->getKey() : null; $this->onboardingSessionVersion = $draft instanceof TenantOnboardingSession ? $draft->expectedVersion() : null; if ($draft instanceof TenantOnboardingSession && $draft->tenant instanceof Tenant) { $this->setManagedTenant($draft->tenant); return; } if ($draft instanceof TenantOnboardingSession && $draft->tenant_id !== null) { $this->managedTenantId = (int) $draft->tenant_id; return; } $this->setManagedTenant(null); } private function setManagedTenant(?Tenant $tenant): void { $this->managedTenant = $tenant; $this->managedTenantId = $tenant instanceof Tenant ? (int) $tenant->getKey() : null; if ($this->onboardingSession instanceof TenantOnboardingSession && $tenant instanceof Tenant) { $this->onboardingSession->setRelation('tenant', $tenant); } } private function currentOnboardingSessionRecord(): ?TenantOnboardingSession { if ($this->onboardingSession instanceof TenantOnboardingSession && $this->onboardingSessionId !== null && (int) $this->onboardingSession->getKey() === $this->onboardingSessionId) { return $this->onboardingSession; } if ($this->onboardingSessionId === null) { return $this->onboardingSession; } $query = TenantOnboardingSession::query() ->with(['tenant', 'startedByUser', 'updatedByUser']) ->whereKey($this->onboardingSessionId); if (isset($this->workspace)) { $query->where('workspace_id', (int) $this->workspace->getKey()); } return $query->first(); } private function currentManagedTenantRecord(): ?Tenant { $draft = $this->currentOnboardingSessionRecord(); if ($draft instanceof TenantOnboardingSession && $draft->tenant instanceof Tenant) { return $draft->tenant; } if ($this->managedTenant instanceof Tenant && $this->managedTenantId !== null && (int) $this->managedTenant->getKey() === $this->managedTenantId) { return $this->managedTenant; } if ($this->managedTenantId === null) { return $this->managedTenant; } $query = Tenant::query()->withTrashed()->whereKey($this->managedTenantId); if (isset($this->workspace)) { $query->where('workspace_id', (int) $this->workspace->getKey()); } return $query->first(); } private function refreshOnboardingDraftFromBackend(): void { $draft = $this->currentOnboardingSessionRecord(); if (! $draft instanceof TenantOnboardingSession) { return; } $user = $this->currentUser(); if (! $user instanceof User) { return; } $this->setOnboardingSession(app(OnboardingDraftResolver::class)->resolve( $draft, $user, $this->workspace, )); $providerConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null; $this->selectedProviderConnectionId = $this->resolvePersistedProviderConnectionId($providerConnectionId); $this->initializeWizardData(); } private function handleDraftConflict(string $title = 'This onboarding draft changed in another tab or session.'): void { $this->refreshOnboardingDraftFromBackend(); Notification::make() ->title($title) ->body('TenantAtlas refreshed the latest saved state. Review it and try again.') ->warning() ->send(); } private function handleImmutableDraft(string $title = 'This onboarding draft is no longer editable.'): void { $this->refreshOnboardingDraftFromBackend(); Notification::make() ->title($title) ->body('The latest draft state is now shown below.') ->warning() ->send(); } private function lifecycleState(): OnboardingLifecycleState { $draft = $this->currentOnboardingSessionRecord(); if (! $draft instanceof TenantOnboardingSession) { return OnboardingLifecycleState::Draft; } return $this->lifecycleService()->snapshot($draft)['lifecycle_state']; } private function lifecycleStateLabel(): string { return $this->lifecycleState()->label(); } private function lifecycleStateColor(): string { return match ($this->lifecycleState()) { OnboardingLifecycleState::Draft => 'gray', OnboardingLifecycleState::Verifying => 'info', OnboardingLifecycleState::ActionRequired => 'warning', OnboardingLifecycleState::Bootstrapping => 'info', OnboardingLifecycleState::ReadyForActivation => 'success', OnboardingLifecycleState::Completed => 'success', OnboardingLifecycleState::Cancelled => 'danger', }; } private function currentCheckpointLabel(): string { $draft = $this->currentOnboardingSessionRecord(); if (! $draft instanceof TenantOnboardingSession) { return OnboardingCheckpoint::Identify->label(); } return ($this->lifecycleService()->snapshot($draft)['current_checkpoint'] ?? OnboardingCheckpoint::Identify)?->label() ?? OnboardingCheckpoint::Identify->label(); } public function shouldPollCheckpointLifecycle(): bool { $draft = $this->currentOnboardingSessionRecord(); return $draft instanceof TenantOnboardingSession && $this->lifecycleService()->hasActiveCheckpoint($draft); } public function refreshCheckpointLifecycle(): void { $draft = $this->currentOnboardingSessionRecord(); if (! $draft instanceof TenantOnboardingSession) { return; } $this->setOnboardingSession($this->lifecycleService()->syncPersistedLifecycle($draft)); $tenant = $this->currentManagedTenantRecord(); if ($tenant instanceof Tenant) { $this->setManagedTenant($tenant->fresh()); } $this->initializeWizardData(); } private function initializeWizardData(): void { // Ensure all entangled schema state paths exist at render time. // Livewire v4 can throw when entangling to missing nested array keys. $this->data['notes'] ??= ''; $this->data['override_blocked'] ??= false; $this->data['override_reason'] ??= ''; $this->data['new_connection'] ??= []; if (! array_key_exists('connection_mode', $this->data)) { $this->data['connection_mode'] = 'existing'; } if (is_array($this->data['new_connection'])) { $this->data['new_connection']['connection_type'] ??= 'Platform connection'; $this->data['new_connection']['platform_app_id'] ??= $this->platformAppClientId(); $this->data['new_connection']['uses_dedicated_override'] ??= false; $this->data['new_connection']['is_default'] ??= true; } $tenant = $this->currentManagedTenantRecord(); if ($tenant instanceof Tenant) { $this->data['entra_tenant_id'] ??= (string) $tenant->tenant_id; $this->data['environment'] ??= (string) ($tenant->environment ?? 'other'); $this->data['name'] ??= (string) $tenant->name; $this->data['primary_domain'] ??= (string) ($tenant->domain ?? ''); $notes = is_array($tenant->metadata) ? ($tenant->metadata['notes'] ?? null) : null; if (is_string($notes) && trim($notes) !== '') { $this->data['notes'] ??= trim($notes); } } $draft = $this->currentOnboardingSessionRecord(); if ($draft instanceof TenantOnboardingSession) { $state = is_array($draft->state) ? $draft->state : []; if (isset($state['entra_tenant_id']) && is_string($state['entra_tenant_id']) && trim($state['entra_tenant_id']) !== '') { $this->data['entra_tenant_id'] ??= trim($state['entra_tenant_id']); } if (isset($state['environment']) && is_string($state['environment']) && trim($state['environment']) !== '') { $this->data['environment'] ??= trim($state['environment']); } if (isset($state['tenant_name']) && is_string($state['tenant_name']) && trim($state['tenant_name']) !== '') { $this->data['name'] ??= trim($state['tenant_name']); } if (array_key_exists('primary_domain', $state)) { $domain = $state['primary_domain']; if (is_string($domain)) { $this->data['primary_domain'] ??= $domain; } } if (array_key_exists('notes', $state)) { $notes = $state['notes']; if (is_string($notes)) { $this->data['notes'] ??= $notes; } } $providerConnectionId = $this->resolvePersistedProviderConnectionId($draft->state['provider_connection_id'] ?? null); if ($providerConnectionId !== null) { $this->data['provider_connection_id'] = $providerConnectionId; $this->selectedProviderConnectionId = $providerConnectionId; } $types = $draft->state['bootstrap_operation_types'] ?? null; if (is_array($types)) { $this->data['bootstrap_operation_types'] = array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== '')); } } $this->selectedProviderConnectionId = $this->resolvePersistedProviderConnectionId($this->selectedProviderConnectionId); if ($this->selectedProviderConnectionId !== null) { $this->data['provider_connection_id'] = $this->selectedProviderConnectionId; } else { $this->data['provider_connection_id'] = null; } } private function computeWizardStartStep(): int { return app(OnboardingDraftStageResolver::class) ->resolve($this->currentOnboardingSessionRecord()) ->wizardStep(); } /** * @return array */ private function providerConnectionOptions(): array { $tenant = $this->currentManagedTenantRecord(); if (! $tenant instanceof Tenant) { return []; } return ProviderConnection::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->where('tenant_id', $tenant->getKey()) ->orderByDesc('is_default') ->orderBy('display_name') ->pluck('display_name', 'id') ->all(); } private function verificationStatusLabel(): string { return BadgeCatalog::spec( BadgeDomain::ManagedTenantOnboardingVerificationStatus, $this->verificationStatus(), )->label; } private function verificationStatus(): string { $draft = $this->currentOnboardingSessionRecord(); if (! $draft instanceof TenantOnboardingSession) { return 'not_started'; } return $this->lifecycleService()->verificationStatus($draft, $this->selectedProviderConnectionId); } private function verificationStatusFromRunOutcome(OperationRun $run): string { if ($run->outcome === OperationRunOutcome::Blocked->value) { return 'blocked'; } if ($run->outcome === OperationRunOutcome::Succeeded->value) { return 'ready'; } if ($run->outcome === OperationRunOutcome::PartiallySucceeded->value) { return 'needs_attention'; } if ($run->outcome !== OperationRunOutcome::Failed->value) { return 'needs_attention'; } $failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : []; if ($failures === []) { return 'blocked'; } foreach ($failures as $failure) { if (! is_array($failure)) { continue; } $reasonCode = $failure['reason_code'] ?? null; if (! is_string($reasonCode) || $reasonCode === '') { continue; } if (in_array($reasonCode, ['provider_auth_failed', 'provider_permission_denied', 'permission_denied', 'provider_consent_missing'], true)) { return 'blocked'; } } return 'needs_attention'; } private function verificationReportOverall(): ?string { $run = $this->verificationRun(); if (! $run instanceof OperationRun) { return null; } $report = VerificationReportViewer::report($run); $summary = is_array($report['summary'] ?? null) ? $report['summary'] : null; $overall = $summary['overall'] ?? null; if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) { return null; } return $overall; } /** * @return array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}|null */ private function verificationReportCounts(): ?array { $run = $this->verificationRun(); if (! $run instanceof OperationRun) { return null; } $report = VerificationReportViewer::report($run); $summary = is_array($report['summary'] ?? null) ? $report['summary'] : null; $counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : null; if (! is_array($counts)) { return null; } foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) { if (! is_int($counts[$key] ?? null) || $counts[$key] < 0) { return null; } } return [ 'total' => $counts['total'], 'pass' => $counts['pass'], 'fail' => $counts['fail'], 'warn' => $counts['warn'], 'skip' => $counts['skip'], 'running' => $counts['running'], ]; } private function verificationRunIsActive(): bool { $run = $this->verificationRun(); return $run instanceof OperationRun && $run->status !== OperationRunStatus::Completed->value; } private function verificationStatusColor(): string { return BadgeCatalog::spec( BadgeDomain::ManagedTenantOnboardingVerificationStatus, $this->verificationStatus(), )->color; } private function verificationRunUrl(): ?string { if (! $this->managedTenant instanceof Tenant) { return null; } if (! $this->onboardingSession instanceof TenantOnboardingSession) { return null; } $runId = $this->onboardingSession->state['verification_operation_run_id'] ?? null; if (! is_int($runId)) { return null; } return $this->tenantlessOperationRunUrl($runId); } /** * @return array{ * run: array|null, * runUrl: string|null, * report: array|null, * fingerprint: string|null, * changeIndicator: array{state: 'no_changes'|'changed', previous_report_id: int}|null, * previousRunUrl: string|null, * canAcknowledge: bool, * acknowledgements: array, * assistVisibility: array{is_visible: bool, reason: 'permission_blocked'|'permission_attention'|'hidden_ready'|'hidden_irrelevant'}, * assistActionName: string * } */ private function verificationReportViewData(): array { $run = $this->verificationRun(); $runUrl = $this->verificationRunUrl(); $assistVisibility = $this->verificationAssistVisibility(); if (! $run instanceof OperationRun) { return [ 'run' => null, 'runUrl' => $runUrl, 'report' => null, 'fingerprint' => null, 'changeIndicator' => null, 'previousRunUrl' => null, 'canAcknowledge' => false, 'acknowledgements' => [], 'assistVisibility' => $assistVisibility, 'assistActionName' => 'wizardVerificationRequiredPermissionsAssist', ]; } $report = VerificationReportViewer::report($run); $fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null; $changeIndicator = VerificationReportChangeIndicator::forRun($run); $previousRunUrl = $changeIndicator === null ? null : $this->tenantlessOperationRunUrl((int) $changeIndicator['previous_report_id']); $user = auth()->user(); $canAcknowledge = $user instanceof User && $this->managedTenant instanceof Tenant ? $user->can(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $this->managedTenant) : false; $acknowledgements = VerificationCheckAcknowledgement::query() ->where('tenant_id', (int) $run->tenant_id) ->where('workspace_id', (int) $run->workspace_id) ->where('operation_run_id', (int) $run->getKey()) ->with('acknowledgedByUser') ->get() ->mapWithKeys(static function (VerificationCheckAcknowledgement $ack): array { $user = $ack->acknowledgedByUser; return [ (string) $ack->check_key => [ 'check_key' => (string) $ack->check_key, 'ack_reason' => (string) $ack->ack_reason, 'acknowledged_at' => $ack->acknowledged_at?->toJSON(), 'expires_at' => $ack->expires_at?->toJSON(), 'acknowledged_by' => $user instanceof User ? [ 'id' => (int) $user->getKey(), 'name' => (string) $user->name, ] : null, ], ]; }) ->all(); $context = is_array($run->context ?? null) ? $run->context : []; $targetScope = $context['target_scope'] ?? []; $targetScope = is_array($targetScope) ? $targetScope : []; $failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : []; $verificationReport = VerificationReportViewer::report($run); return [ 'run' => [ 'id' => (int) $run->getKey(), 'type' => (string) $run->type, 'status' => (string) $run->status, 'outcome' => (string) $run->outcome, 'initiator_name' => (string) $run->initiator_name, 'started_at' => $run->started_at?->toJSON(), 'completed_at' => $run->completed_at?->toJSON(), 'target_scope' => $targetScope, 'failures' => $failures, ], 'runUrl' => $runUrl, 'report' => $report, 'fingerprint' => $fingerprint, 'changeIndicator' => $changeIndicator, 'previousRunUrl' => $previousRunUrl, 'canAcknowledge' => $canAcknowledge, 'acknowledgements' => $acknowledgements, 'assistVisibility' => $assistVisibility, 'assistActionName' => 'wizardVerificationRequiredPermissionsAssist', ]; } public function wizardVerificationRequiredPermissionsAssistAction(): Action { return Action::make('wizardVerificationRequiredPermissionsAssist') ->label('View required permissions') ->icon('heroicon-m-key') ->color('warning') ->modal() ->slideOver() ->stickyModalHeader() ->modalHeading('Required permissions assist') ->modalDescription('Review stored permission diagnostics without leaving the onboarding wizard.') ->modalSubmitAction(false) ->modalCancelAction(fn (Action $action): Action => $action->label('Close')) ->modalContent(fn (): View => view('filament.actions.verification-required-permissions-assist', [ 'assist' => $this->verificationAssistViewModel(), ])) ->visible(fn (): bool => $this->verificationAssistVisibility()['is_visible']); } public function acknowledgeVerificationCheckAction(): Action { return Action::make('acknowledgeVerificationCheck') ->label('Acknowledge') ->color('gray') ->requiresConfirmation() ->modalHeading('Acknowledge issue') ->modalDescription('This records an acknowledgement for governance and audit. It does not change the verification outcome.') ->form([ Textarea::make('ack_reason') ->label('Reason') ->required() ->maxLength(160) ->rows(3), TextInput::make('expires_at') ->label('Expiry (optional)') ->helperText('Optional timestamp (informational only).') ->maxLength(64), ]) ->action(function (array $data, array $arguments): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } if (! $this->managedTenant instanceof Tenant) { abort(404); } $tenant = $this->managedTenant->fresh(); if (! $tenant instanceof Tenant) { abort(404); } $run = $this->verificationRun(); if (! $run instanceof OperationRun) { throw new NotFoundHttpException; } $checkKey = (string) ($arguments['check_key'] ?? ''); $ackReason = (string) ($data['ack_reason'] ?? ''); $expiresAt = $data['expires_at'] ?? null; $expiresAt = is_string($expiresAt) ? $expiresAt : null; try { app(VerificationCheckAcknowledgementService::class)->acknowledge( tenant: $tenant, run: $run, checkKey: $checkKey, ackReason: $ackReason, expiresAt: $expiresAt, actor: $user, ); } catch (InvalidArgumentException $e) { Notification::make() ->title('Unable to acknowledge') ->body($e->getMessage()) ->danger() ->send(); return; } Notification::make() ->title('Issue acknowledged') ->success() ->send(); }) ->visible(function (array $arguments): bool { $user = auth()->user(); if (! $user instanceof User) { return false; } if (! $this->managedTenant instanceof Tenant) { return false; } if (! $user->can(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $this->managedTenant)) { return false; } $run = $this->verificationRun(); if (! $run instanceof OperationRun) { return false; } $checkKey = trim((string) ($arguments['check_key'] ?? '')); if ($checkKey === '') { return false; } $ackExists = VerificationCheckAcknowledgement::query() ->where('operation_run_id', (int) $run->getKey()) ->where('check_key', $checkKey) ->exists(); if ($ackExists) { return false; } $report = VerificationReportViewer::report($run); if (! is_array($report)) { return false; } $checks = $report['checks'] ?? null; $checks = is_array($checks) ? $checks : []; foreach ($checks as $check) { if (! is_array($check)) { continue; } if (($check['key'] ?? null) !== $checkKey) { continue; } $status = $check['status'] ?? null; return is_string($status) && in_array($status, [ VerificationCheckStatus::Fail->value, VerificationCheckStatus::Warn->value, ], true); } return false; }); } private function bootstrapRunsLabel(): string { if (! $this->onboardingSession instanceof TenantOnboardingSession) { return ''; } $runs = $this->lifecycleService()->bootstrapRunSummaries($this->onboardingSession, $this->selectedProviderConnectionId); if ($runs === []) { return ''; } $activeRuns = array_filter($runs, static fn (array $run): bool => (bool) $run['is_active']); $failedRuns = array_filter($runs, static fn (array $run): bool => (bool) $run['is_failure'] || (bool) $run['is_partial_failure']); if ($activeRuns !== []) { return sprintf('Bootstrap is running across %d operation run(s).', count($activeRuns)); } if ($failedRuns !== []) { return sprintf('Bootstrap needs attention for %d operation run(s).', count($failedRuns)); } return sprintf('Bootstrap completed across %d operation run(s).', count($runs)); } private function touchOnboardingSessionStep(string $step): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } if (! $this->onboardingSession instanceof TenantOnboardingSession) { return; } if ($this->onboardingSession->current_step === $step) { return; } try { $this->setOnboardingSession($this->mutationService()->mutate( draft: $this->onboardingSession, actor: $user, expectedVersion: $this->expectedDraftVersion(), incrementVersion: false, mutator: function (TenantOnboardingSession $draft) use ($step): void { $draft->current_step = $step; }, )); } catch (OnboardingDraftConflictException) { $this->handleDraftConflict(); return; } catch (OnboardingDraftImmutableException) { $this->handleImmutableDraft(); return; } app(WorkspaceAuditLogger::class)->log( workspace: $this->workspace, action: AuditActionId::ManagedTenantOnboardingDraftUpdated->value, context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'tenant_db_id' => $this->onboardingSession->tenant_id !== null ? (int) $this->onboardingSession->tenant_id : null, 'onboarding_session_id' => (int) $this->onboardingSession->getKey(), 'current_step' => $step, ], ], actor: $user, status: 'success', resourceType: 'managed_tenant_onboarding_session', resourceId: (string) $this->onboardingSession->getKey(), ); } private function authorizeWorkspaceMutation(User $user, string $capability): void { $this->authorizeWorkspaceMember($user); if (! $user->can($capability, $this->workspace)) { abort(403); } } private function authorizeEditableDraft(User $user): void { if (! $this->onboardingSession instanceof TenantOnboardingSession) { return; } $expectedVersion = $this->expectedDraftVersion(); $this->setOnboardingSession(app(TrustedStateResolver::class)->resolveOnboardingDraft( $this->onboardingSessionId ?? $this->onboardingSession, $user, $this->workspace, app(OnboardingDraftResolver::class), )); if ($expectedVersion !== null) { $this->onboardingSessionVersion = $expectedVersion; } $this->authorize('update', $this->onboardingSession); if (! $this->canResumeDraft($this->onboardingSession)) { abort(404); } } private function trustedManagedTenantForUser(User $user): Tenant { $tenant = $this->currentManagedTenantRecord(); if (! $tenant instanceof Tenant) { abort(404); } $tenant = $tenant->fresh(); if (! $tenant instanceof Tenant) { abort(404); } $tenant = app(WorkspaceContext::class)->ensureTenantAccessibleInCurrentWorkspace($tenant, $user, request()); $this->setManagedTenant($tenant); return $tenant; } private function canResumeDraft(?TenantOnboardingSession $draft): bool { if (! $draft instanceof TenantOnboardingSession) { return false; } if (! $draft->tenant instanceof Tenant) { return $this->lifecycleService()->canResumeDraft($draft); } $user = $this->currentUser(); return app(TenantOperabilityService::class)->outcomeFor( tenant: $draft->tenant, question: TenantOperabilityQuestion::ResumeOnboardingEligibility, actor: $user instanceof User ? $user : null, workspaceId: isset($this->workspace) ? (int) $this->workspace->getKey() : null, lane: TenantInteractionLane::OnboardingWorkflow, onboardingDraft: $draft, )->allowed; } private function authorizeWorkspaceMember(User $user): void { $this->workspace = app(TrustedStateResolver::class)->currentWorkspaceForMember( $user, app(WorkspaceContext::class), request(), ); } private function resolveWorkspaceIdForUnboundTenant(Tenant $tenant): ?int { $workspaceId = DB::table('tenant_memberships') ->join('workspace_memberships', 'workspace_memberships.user_id', '=', 'tenant_memberships.user_id') ->where('tenant_memberships.tenant_id', (int) $tenant->getKey()) ->orderByRaw("CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END") ->value('workspace_memberships.workspace_id'); return $workspaceId === null ? null : (int) $workspaceId; } /** * @param array{entra_tenant_id: string, environment: string, name: string, primary_domain?: string, notes?: string} $data */ public function identifyManagedTenant(array $data): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY); $entraTenantId = (string) ($data['entra_tenant_id'] ?? ''); $tenantName = (string) ($data['name'] ?? ''); $environment = (string) ($data['environment'] ?? 'other'); $primaryDomain = trim((string) ($data['primary_domain'] ?? '')); $notes = trim((string) ($data['notes'] ?? '')); if ($entraTenantId === '' || $tenantName === '') { abort(422); } if (! in_array($environment, ['prod', 'staging', 'dev', 'other'], true)) { abort(422); } $primaryDomain = $primaryDomain !== '' ? $primaryDomain : null; $notes = $notes !== '' ? $notes : null; $notificationTitle = 'Onboarding draft ready'; $notificationBody = null; try { DB::transaction(function () use ($user, $entraTenantId, $tenantName, $environment, $primaryDomain, $notes, &$notificationTitle, &$notificationBody): void { $auditLogger = app(WorkspaceAuditLogger::class); $membershipManager = app(TenantMembershipManager::class); $currentDraftId = $this->onboardingSession?->getKey(); $sessionWasCreated = false; $existingTenant = Tenant::query() ->withTrashed() ->where('tenant_id', $entraTenantId) ->first(); if ($existingTenant instanceof Tenant) { if ($existingTenant->trashed() || $existingTenant->status === Tenant::STATUS_ARCHIVED) { abort(404); } if ($existingTenant->workspace_id === null) { $resolvedWorkspaceId = $this->resolveWorkspaceIdForUnboundTenant($existingTenant); if ($resolvedWorkspaceId === (int) $this->workspace->getKey()) { $existingTenant->forceFill(['workspace_id' => $resolvedWorkspaceId])->save(); } } if ((int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) { abort(404); } $existingTenant->forceFill([ 'name' => $tenantName, 'environment' => $environment, 'domain' => $primaryDomain, 'status' => $existingTenant->status === Tenant::STATUS_DRAFT ? Tenant::STATUS_ONBOARDING : $existingTenant->status, 'metadata' => array_merge(is_array($existingTenant->metadata) ? $existingTenant->metadata : [], array_filter([ 'notes' => $notes, ], static fn ($value): bool => $value !== null)), ])->save(); $tenant = $existingTenant; } else { try { $tenant = Tenant::query()->create([ 'workspace_id' => (int) $this->workspace->getKey(), 'name' => $tenantName, 'tenant_id' => $entraTenantId, 'domain' => $primaryDomain, 'environment' => $environment, 'status' => Tenant::STATUS_ONBOARDING, 'metadata' => array_filter([ 'notes' => $notes, ], static fn ($value): bool => $value !== null), ]); } catch (QueryException $exception) { // Race-safe global uniqueness: if another workspace created the tenant_id first, // treat it as deny-as-not-found. $existingTenant = Tenant::query() ->withTrashed() ->where('tenant_id', $entraTenantId) ->first(); if ($existingTenant instanceof Tenant && (int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) { abort(404); } if ($existingTenant instanceof Tenant && (int) $existingTenant->workspace_id === (int) $this->workspace->getKey()) { $tenant = $existingTenant; } else { throw $exception; } } } $membershipManager->addMember( tenant: $tenant, actor: $user, member: $user, role: 'owner', source: 'manual', ); $ownerCount = TenantMembership::query() ->where('tenant_id', $tenant->getKey()) ->where('role', 'owner') ->count(); if ($ownerCount === 0) { throw new RuntimeException('Tenant must have at least one owner.'); } $this->selectedProviderConnectionId ??= $this->resolveDefaultProviderConnectionId($tenant); $session = $this->mutationService()->createOrResume( workspace: $this->workspace, actor: $user, entraTenantId: $entraTenantId, preferredDraft: $this->onboardingSession, expectedVersion: $this->expectedDraftVersion(), mutator: function (TenantOnboardingSession $draft) use ($tenant, $entraTenantId, $tenantName, $environment, $primaryDomain, $notes): void { $draft->tenant_id = (int) $tenant->getKey(); $draft->current_step = 'identify'; $draft->state = array_merge($draft->state ?? [], [ 'entra_tenant_id' => $entraTenantId, 'tenant_name' => $tenantName, 'environment' => $environment, 'primary_domain' => $primaryDomain, 'notes' => $notes, ]); if ($this->selectedProviderConnectionId !== null) { $draft->state = array_merge($draft->state ?? [], [ 'provider_connection_id' => (int) $this->selectedProviderConnectionId, ]); } }, wasCreated: $sessionWasCreated, ); $didResumeExistingDraft = ! $sessionWasCreated && ($currentDraftId === null || (int) $currentDraftId !== (int) $session->getKey()); $notificationTitle = $didResumeExistingDraft ? 'Existing onboarding draft resumed' : 'Onboarding draft ready'; $notificationBody = $didResumeExistingDraft ? 'A resumable draft already exists for this tenant. TenantAtlas reopened it instead of creating a duplicate.' : null; $auditLogger->log( workspace: $this->workspace, action: ($sessionWasCreated ? AuditActionId::ManagedTenantOnboardingStart : AuditActionId::ManagedTenantOnboardingResume )->value, context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'tenant_db_id' => (int) $tenant->getKey(), 'entra_tenant_id' => $entraTenantId, 'tenant_name' => $tenantName, 'onboarding_session_id' => (int) $session->getKey(), 'current_step' => (string) $session->current_step, ], ], actor: $user, status: 'success', resourceType: 'tenant', resourceId: (string) $tenant->getKey(), ); $this->setManagedTenant($tenant); $this->setOnboardingSession($session); }); } catch (OnboardingDraftConflictException) { $this->handleDraftConflict('The onboarding draft changed before the tenant details were saved.'); return; } catch (OnboardingDraftImmutableException) { $this->handleImmutableDraft('The onboarding draft is no longer editable.'); return; } $notification = Notification::make() ->title($notificationTitle) ->success(); if (is_string($notificationBody) && $notificationBody !== '') { $notification->body($notificationBody); } $notification->send(); if ($this->onboardingSession instanceof TenantOnboardingSession) { $this->redirect(route('admin.onboarding.draft', ['onboardingDraft' => $this->onboardingSession])); } } public function selectProviderConnection(int $providerConnectionId): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW); $this->authorizeEditableDraft($user); $tenant = $this->trustedManagedTenantForUser($user); $connection = ProviderConnection::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->where('tenant_id', (int) $tenant->getKey()) ->whereKey($providerConnectionId) ->first(); if (! $connection instanceof ProviderConnection) { abort(404); } $previousProviderConnectionId = $this->selectedProviderConnectionId; $this->selectedProviderConnectionId = (int) $connection->getKey(); if ($this->onboardingSession instanceof TenantOnboardingSession) { try { $this->setOnboardingSession($this->mutationService()->mutate( draft: $this->onboardingSession, actor: $user, expectedVersion: $this->expectedDraftVersion(), mutator: function (TenantOnboardingSession $draft) use ($connection, $previousProviderConnectionId): void { $draft->state = $this->resetDependentOnboardingStateOnConnectionChange( state: array_merge($draft->state ?? [], [ 'provider_connection_id' => (int) $connection->getKey(), ]), previousProviderConnectionId: $previousProviderConnectionId, newProviderConnectionId: (int) $connection->getKey(), ); $draft->current_step = 'connection'; }, )); } catch (OnboardingDraftConflictException) { $this->handleDraftConflict(); return; } catch (OnboardingDraftImmutableException) { $this->handleImmutableDraft(); return; } } app(WorkspaceAuditLogger::class)->log( workspace: $this->workspace, action: AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value, context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'tenant_db_id' => (int) $tenant->getKey(), 'provider_connection_id' => (int) $connection->getKey(), 'onboarding_session_id' => $this->onboardingSession?->getKey(), ], ], actor: $user, status: 'success', resourceType: 'provider_connection', resourceId: (string) $connection->getKey(), ); Notification::make() ->title('Provider connection selected') ->success() ->send(); $this->initializeWizardData(); } /** * @param array{display_name: string, connection_type?: string, client_id?: string, client_secret?: string, is_default?: bool} $data */ public function createProviderConnection(array $data): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE); $this->authorizeEditableDraft($user); $tenant = $this->trustedManagedTenantForUser($user)->fresh(); if (! $tenant instanceof Tenant) { abort(404); } if ((int) $tenant->workspace_id !== (int) $this->workspace->getKey()) { abort(404); } $displayName = trim((string) ($data['display_name'] ?? '')); $requestedConnectionType = ProviderConnectionType::tryFrom(trim((string) ($data['connection_type'] ?? ''))); $clientId = trim((string) ($data['client_id'] ?? '')); $clientSecret = trim((string) ($data['client_secret'] ?? '')); $makeDefault = (bool) ($data['is_default'] ?? false); $usesDedicatedCredential = $requestedConnectionType === ProviderConnectionType::Dedicated || $clientId !== '' || $clientSecret !== ''; if ($displayName === '') { abort(422); } if ($usesDedicatedCredential) { $this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED); } if ($usesDedicatedCredential && ($clientId === '' || $clientSecret === '')) { abort(422); } $wasExistingConnection = false; $previousConnectionType = null; /** @var ProviderConnection $connection */ $connection = DB::transaction(function () use ($tenant, $displayName, $clientId, $clientSecret, $makeDefault, $usesDedicatedCredential, &$wasExistingConnection, &$previousConnectionType): ProviderConnection { $projectedState = app(ProviderConnectionStateProjector::class)->project( connectionType: ProviderConnectionType::Platform, consentStatus: ProviderConsentStatus::Required, verificationStatus: ProviderVerificationStatus::Unknown, ); $connection = ProviderConnection::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('provider', 'microsoft') ->where('entra_tenant_id', (string) $tenant->tenant_id) ->first(); if (! $connection instanceof ProviderConnection) { $connection = ProviderConnection::query()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, 'display_name' => $displayName, 'connection_type' => ProviderConnectionType::Platform->value, 'status' => $projectedState['status'], 'consent_status' => ProviderConsentStatus::Required->value, 'consent_granted_at' => null, 'consent_last_checked_at' => null, 'consent_error_code' => null, 'consent_error_message' => null, 'verification_status' => ProviderVerificationStatus::Unknown->value, 'health_status' => $projectedState['health_status'], 'migration_review_required' => false, 'migration_reviewed_at' => null, 'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing, 'last_error_message' => null, ]); } else { $wasExistingConnection = true; $previousConnectionType = $connection->connection_type instanceof ProviderConnectionType ? $connection->connection_type : ProviderConnectionType::Platform; $connection->forceFill([ 'workspace_id' => (int) $tenant->workspace_id, 'display_name' => $displayName, ])->save(); } if ($usesDedicatedCredential) { $connection = app(ProviderConnectionMutationService::class)->enableDedicatedOverride( connection: $connection, clientId: $clientId, clientSecret: $clientSecret, ); } if ($makeDefault) { $connection->makeDefault(); } return $connection; }); $auditLogger = app(AuditLogger::class); $actorId = (int) $user->getKey(); $actorEmail = (string) $user->email; $actorName = (string) $user->name; if (! $wasExistingConnection) { $auditLogger->log( tenant: $tenant, action: 'provider_connection.created', context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'provider_connection_id' => (int) $connection->getKey(), 'provider' => (string) $connection->provider, 'entra_tenant_id' => (string) $connection->entra_tenant_id, 'connection_type' => $connection->connection_type->value, 'source' => 'managed_tenant_onboarding_wizard.create', ], ], actorId: $actorId, actorEmail: $actorEmail, actorName: $actorName, resourceType: 'provider_connection', resourceId: (string) $connection->getKey(), status: 'success', ); } if ($previousConnectionType instanceof ProviderConnectionType && $previousConnectionType !== $connection->connection_type) { $auditLogger->log( tenant: $tenant, action: 'provider_connection.connection_type_changed', context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'provider_connection_id' => (int) $connection->getKey(), 'provider' => (string) $connection->provider, 'entra_tenant_id' => (string) $connection->entra_tenant_id, 'from_connection_type' => $previousConnectionType->value, 'to_connection_type' => $connection->connection_type->value, 'source' => 'managed_tenant_onboarding_wizard.create', ], ], actorId: $actorId, actorEmail: $actorEmail, actorName: $actorName, resourceType: 'provider_connection', resourceId: (string) $connection->getKey(), status: 'success', ); } $this->selectedProviderConnectionId = (int) $connection->getKey(); if ($this->onboardingSession instanceof TenantOnboardingSession) { $previousProviderConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null; $previousProviderConnectionId = is_int($previousProviderConnectionId) ? $previousProviderConnectionId : (is_numeric($previousProviderConnectionId) ? (int) $previousProviderConnectionId : null); try { $this->setOnboardingSession($this->mutationService()->mutate( draft: $this->onboardingSession, actor: $user, expectedVersion: $this->expectedDraftVersion(), mutator: function (TenantOnboardingSession $draft) use ($connection, $previousProviderConnectionId): void { $draft->state = $this->resetDependentOnboardingStateOnConnectionChange( state: array_merge($draft->state ?? [], [ 'provider_connection_id' => (int) $connection->getKey(), ]), previousProviderConnectionId: $previousProviderConnectionId, newProviderConnectionId: (int) $connection->getKey(), ); $draft->current_step = 'connection'; }, )); } catch (OnboardingDraftConflictException) { $this->handleDraftConflict(); return; } catch (OnboardingDraftImmutableException) { $this->handleImmutableDraft(); return; } } app(WorkspaceAuditLogger::class)->log( workspace: $this->workspace, action: AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value, context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'tenant_db_id' => (int) $tenant->getKey(), 'provider_connection_id' => (int) $connection->getKey(), 'onboarding_session_id' => $this->onboardingSession?->getKey(), ], ], actor: $user, status: 'success', resourceType: 'provider_connection', resourceId: (string) $connection->getKey(), ); Notification::make() ->title('Provider connection created') ->success() ->send(); $this->initializeWizardData(); } private function platformAppClientId(): string { $clientId = trim((string) config('graph.client_id')); return $clientId !== '' ? $clientId : 'Platform app not configured'; } private function canManageDedicatedOverride(): bool { return $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED); } public function startVerification(): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START); $this->authorizeEditableDraft($user); try { $tenant = $this->trustedManagedTenantForUser($user)->fresh(); } catch (\Symfony\Component\HttpKernel\Exception\NotFoundHttpException) { Notification::make() ->title('Identify a managed tenant first') ->warning() ->send(); return; } if (! $tenant instanceof Tenant) { abort(404); } $connection = $this->resolveSelectedProviderConnection($tenant); if (! $connection instanceof ProviderConnection) { Notification::make() ->title('No provider connection selected') ->body('Create or select a provider connection first.') ->warning() ->send(); return; } $result = null; try { $this->setOnboardingSession($this->mutationService()->mutate( draft: $this->onboardingSession, actor: $user, expectedVersion: $this->expectedDraftVersion(), mutator: function (TenantOnboardingSession $draft) use ($tenant, $user, $connection, &$result): void { $result = app(ProviderOperationStartGate::class)->start( tenant: $tenant, connection: $connection, operationType: 'provider.connection.check', dispatcher: function (OperationRun $run) use ($tenant, $user, $connection): void { ProviderConnectionHealthCheckJob::dispatch( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), providerConnectionId: (int) $connection->getKey(), operationRun: $run, ); }, initiator: $user, extraContext: [ 'wizard' => [ 'flow' => 'managed_tenant_onboarding', 'step' => 'verification', ], ], ); if (! $result instanceof \App\Services\Providers\ProviderOperationStartResult) { throw new RuntimeException('Verification start did not return a run result.'); } $draft->state = array_merge($draft->state ?? [], [ 'provider_connection_id' => (int) $connection->getKey(), 'verification_operation_run_id' => (int) $result->run->getKey(), 'connection_recently_updated' => false, ]); $draft->current_step = 'verify'; }, )); } catch (OnboardingDraftConflictException) { $this->handleDraftConflict(); return; } catch (OnboardingDraftImmutableException) { $this->handleImmutableDraft(); return; } if (! $result instanceof \App\Services\Providers\ProviderOperationStartResult) { throw new RuntimeException('Verification start did not return a run result.'); } $auditStatus = match ($result->status) { 'started' => 'success', 'deduped' => 'deduped', 'scope_busy' => 'blocked', default => 'success', }; app(WorkspaceAuditLogger::class)->log( workspace: $this->workspace, action: AuditActionId::ManagedTenantOnboardingVerificationStart->value, context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'tenant_db_id' => (int) $tenant->getKey(), 'provider_connection_id' => (int) $connection->getKey(), 'operation_run_id' => (int) $result->run->getKey(), 'result' => (string) $result->status, ], ], actor: $user, status: $auditStatus, resourceType: 'operation_run', resourceId: (string) $result->run->getKey(), ); if ($this->onboardingSession instanceof TenantOnboardingSession) { app(WorkspaceAuditLogger::class)->log( workspace: $this->workspace, action: AuditActionId::ManagedTenantOnboardingVerificationPersisted->value, context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'tenant_db_id' => (int) $tenant->getKey(), 'onboarding_session_id' => (int) $this->onboardingSession->getKey(), 'operation_run_id' => (int) $result->run->getKey(), ], ], actor: $user, status: 'success', resourceType: 'managed_tenant_onboarding_session', resourceId: (string) $this->onboardingSession->getKey(), ); } if ($result->status === 'scope_busy') { OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make() ->title('Another operation is already running') ->body('Please wait for the active run to finish.') ->warning() ->actions([ Action::make('view_run') ->label('View run') ->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())), ]) ->send(); return; } if ($result->status === 'blocked') { $reasonCode = is_string($result->run->context['reason_code'] ?? null) ? (string) $result->run->context['reason_code'] : 'unknown_error'; $actions = [ Action::make('view_run') ->label('View run') ->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())), ]; $nextSteps = $result->run->context['next_steps'] ?? []; $nextSteps = is_array($nextSteps) ? $nextSteps : []; foreach ($nextSteps as $index => $step) { if (! is_array($step)) { continue; } $label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : ''; $url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : ''; if ($label === '' || $url === '') { continue; } $actions[] = Action::make('next_step_'.$index) ->label($label) ->url($url); break; } Notification::make() ->title('Verification blocked') ->body("Blocked by provider configuration ({$reasonCode}).") ->warning() ->actions($actions) ->send(); return; } OpsUxBrowserEvents::dispatchRunEnqueued($this); if ($result->status === 'deduped') { OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) ->actions([ Action::make('view_run') ->label('View run') ->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())), ]) ->send(); return; } OperationUxPresenter::queuedToast((string) $result->run->type) ->actions([ Action::make('view_run') ->label('View run') ->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())), ]) ->send(); } public function refreshVerificationStatus(): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMember($user); $this->authorizeEditableDraft($user); $this->refreshCheckpointLifecycle(); Notification::make() ->title('Verification refreshed') ->success() ->send(); } /** * @param array $operationTypes */ public function startBootstrap(array $operationTypes): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMember($user); $this->authorizeEditableDraft($user); $tenant = $this->trustedManagedTenantForUser($user)->fresh(); if (! $tenant instanceof Tenant) { abort(404); } if (! $this->verificationCanProceed()) { Notification::make() ->title('Verification required') ->body('Complete verification for the selected provider connection before starting bootstrap actions.') ->warning() ->send(); return; } $connection = $this->resolveSelectedProviderConnection($tenant); if (! $connection instanceof ProviderConnection) { Notification::make() ->title('No provider connection selected') ->warning() ->send(); return; } $registry = app(ProviderOperationRegistry::class); $types = array_values(array_unique(array_filter($operationTypes, static fn ($v): bool => is_string($v) && trim($v) !== ''))); $types = array_values(array_filter( $types, static fn (string $type): bool => $type !== 'provider.connection.check' && $registry->isAllowed($type), )); foreach ($types as $operationType) { $capability = $this->resolveBootstrapCapability($operationType); if ($capability === null) { abort(422); } if (! $user->can($capability, $this->workspace)) { abort(403); } } if (empty($types)) { Notification::make() ->title('No bootstrap actions selected') ->warning() ->send(); return; } $result = null; try { $this->setOnboardingSession($this->mutationService()->mutate( draft: $this->onboardingSession, actor: $user, expectedVersion: $this->expectedDraftVersion(), mutator: function (TenantOnboardingSession $draft) use ($tenant, $connection, $types, $registry, $user, &$result): void { $lockedConnection = ProviderConnection::query() ->whereKey($connection->getKey()) ->lockForUpdate() ->firstOrFail(); $activeRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) ->active() ->where('context->provider_connection_id', (int) $lockedConnection->getKey()) ->orderByDesc('id') ->first(); if ($activeRun instanceof OperationRun) { $result = [ 'status' => 'scope_busy', 'run' => $activeRun, ]; return; } $runsService = app(OperationRunService::class); $bootstrapRuns = []; $bootstrapCreated = []; foreach ($types as $operationType) { $definition = $registry->get($operationType); $context = [ 'wizard' => [ 'flow' => 'managed_tenant_onboarding', 'step' => 'bootstrap', ], 'provider' => $lockedConnection->provider, 'module' => $definition['module'], 'provider_connection_id' => (int) $lockedConnection->getKey(), 'target_scope' => [ 'entra_tenant_id' => $lockedConnection->entra_tenant_id, ], ]; $run = $runsService->ensureRunWithIdentity( tenant: $tenant, type: $operationType, identityInputs: [ 'provider_connection_id' => (int) $lockedConnection->getKey(), ], context: $context, initiator: $user, ); if ($run->wasRecentlyCreated) { $this->dispatchBootstrapJob( operationType: $operationType, tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), providerConnectionId: (int) $lockedConnection->getKey(), run: $run, ); } $bootstrapRuns[$operationType] = (int) $run->getKey(); $bootstrapCreated[$operationType] = (bool) $run->wasRecentlyCreated; } $state = $draft->state ?? []; $existing = $state['bootstrap_operation_runs'] ?? []; $existing = is_array($existing) ? $existing : []; $state['bootstrap_operation_runs'] = array_merge($existing, $bootstrapRuns); $state['bootstrap_operation_types'] = $types; $draft->state = $state; $draft->current_step = 'bootstrap'; $result = [ 'status' => 'started', 'runs' => $bootstrapRuns, 'created' => $bootstrapCreated, ]; }, )); } catch (OnboardingDraftConflictException) { $this->handleDraftConflict(); return; } catch (OnboardingDraftImmutableException) { $this->handleImmutableDraft(); return; } if (! is_array($result)) { throw new RuntimeException('Bootstrap start did not return a run result.'); } if ($result['status'] === 'scope_busy') { OpsUxBrowserEvents::dispatchRunEnqueued($this); Notification::make() ->title('Another operation is already running') ->body('Please wait for the active run to finish.') ->warning() ->actions([ Action::make('view_run') ->label('View run') ->url($this->tenantlessOperationRunUrl((int) $result['run']->getKey())), ]) ->send(); return; } $bootstrapRuns = $result['runs']; if ($this->onboardingSession instanceof TenantOnboardingSession) { app(WorkspaceAuditLogger::class)->log( workspace: $this->workspace, action: AuditActionId::ManagedTenantOnboardingBootstrapStarted->value, context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'tenant_db_id' => (int) $tenant->getKey(), 'onboarding_session_id' => (int) $this->onboardingSession->getKey(), 'operation_types' => $types, 'operation_run_ids' => $bootstrapRuns, ], ], actor: $user, status: 'success', resourceType: 'managed_tenant_onboarding_session', resourceId: (string) $this->onboardingSession->getKey(), ); } OpsUxBrowserEvents::dispatchRunEnqueued($this); foreach ($types as $operationType) { $runId = (int) ($bootstrapRuns[$operationType] ?? 0); $runUrl = $runId > 0 ? $this->tenantlessOperationRunUrl($runId) : null; $wasCreated = (bool) ($result['created'][$operationType] ?? false); $toast = $wasCreated ? OperationUxPresenter::queuedToast($operationType) : OperationUxPresenter::alreadyQueuedToast($operationType); if ($runUrl !== null) { $toast->actions([ Action::make('view_run') ->label('View run') ->url($runUrl), ]); } $toast->send(); } } private function dispatchBootstrapJob( string $operationType, int $tenantId, int $userId, int $providerConnectionId, OperationRun $run, ): void { match ($operationType) { 'inventory_sync' => ProviderInventorySyncJob::dispatch( tenantId: $tenantId, userId: $userId, providerConnectionId: $providerConnectionId, operationRun: $run, ), 'compliance.snapshot' => ProviderComplianceSnapshotJob::dispatch( tenantId: $tenantId, userId: $userId, providerConnectionId: $providerConnectionId, operationRun: $run, ), default => throw new RuntimeException("Unsupported bootstrap operation type: {$operationType}"), }; } private function resolveBootstrapCapability(string $operationType): ?string { return match ($operationType) { 'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC, 'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC, default => null, }; } private function canStartAnyBootstrap(): bool { return $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC) || $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC) || $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP); } private function currentUserCan(string $capability): bool { $user = auth()->user(); if (! $user instanceof User) { return false; } return $user->can($capability, $this->workspace); } private function tenantlessOperationRunUrl(int $runId): string { return OperationRunLinks::tenantlessView($runId); } public function verificationSucceeded(): bool { return $this->verificationHasSucceeded(); } private function verificationCanProceed(): bool { return $this->onboardingSession instanceof TenantOnboardingSession && $this->lifecycleService()->verificationCanProceed($this->onboardingSession, $this->selectedProviderConnectionId); } private function verificationIsBlocked(): bool { return $this->onboardingSession instanceof TenantOnboardingSession && $this->lifecycleService()->verificationIsBlocked($this->onboardingSession, $this->selectedProviderConnectionId); } private function canCompleteOnboarding(): bool { if (! $this->managedTenant instanceof Tenant) { return false; } $user = $this->currentUser(); if (! app(TenantOperabilityService::class)->outcomeFor( tenant: $this->managedTenant, question: TenantOperabilityQuestion::OnboardingCompletionEligibility, actor: $user instanceof User ? $user : null, workspaceId: isset($this->workspace) ? (int) $this->workspace->getKey() : null, lane: TenantInteractionLane::OnboardingWorkflow, onboardingDraft: $this->onboardingSession, )->allowed) { return false; } if (! $this->resolveSelectedProviderConnection($this->managedTenant)) { return false; } if ($this->lifecycleState() === OnboardingLifecycleState::ReadyForActivation) { return true; } if (! (bool) ($this->data['override_blocked'] ?? false)) { return false; } if (! $this->verificationIsBlocked()) { return false; } return trim((string) ($this->data['override_reason'] ?? '')) !== ''; } private function completionSummaryTenantLine(): string { $tenant = $this->currentManagedTenantRecord(); if (! $tenant instanceof Tenant) { return '—'; } $name = $tenant->name ?? '—'; $tenantId = $tenant->graphTenantId(); return $tenantId !== null ? "{$name} ({$tenantId})" : $name; } private function completionSummaryConnectionLabel(): string { $tenant = $this->currentManagedTenantRecord(); if (! $tenant instanceof Tenant) { return '—'; } $connection = $this->resolveSelectedProviderConnection($tenant); if (! $connection instanceof ProviderConnection) { return 'Not configured'; } $type = $connection->connection_type instanceof ProviderConnectionType ? $connection->connection_type : ProviderConnectionType::tryFrom((string) $connection->connection_type); return match ($type) { ProviderConnectionType::Platform => 'Platform', ProviderConnectionType::Dedicated => 'Dedicated', default => (string) ($connection->display_name ?? 'Unknown'), }; } private function completionSummaryConnectionDetail(): string { if (! $this->managedTenant instanceof Tenant) { return ''; } $connection = $this->resolveSelectedProviderConnection($this->managedTenant); if (! $connection instanceof ProviderConnection) { return ''; } $consentStatus = $connection->consent_status instanceof ProviderConsentStatus ? $connection->consent_status : ProviderConsentStatus::tryFrom((string) $connection->consent_status); $consentLabel = match ($consentStatus) { ProviderConsentStatus::Granted => 'Consent granted', ProviderConsentStatus::Failed => 'Consent failed', ProviderConsentStatus::Required => 'Consent required', ProviderConsentStatus::Revoked => 'Consent revoked', default => 'Consent unknown', }; $parts = [$connection->display_name ?? '', $consentLabel]; return implode(' · ', array_filter($parts, static fn (string $v): bool => $v !== '')); } private function completionSummaryConnectionSummary(): string { $label = $this->completionSummaryConnectionLabel(); $detail = $this->completionSummaryConnectionDetail(); if ($detail === '') { return $label; } return sprintf('%s - %s', $label, $detail); } private function completionSummaryVerificationDetail(): string { $counts = $this->verificationReportCounts(); if ($counts === null) { return 'Not started'; } return sprintf('%d/%d checks passed', $counts['pass'], $counts['total']); } private function completionSummaryBootstrapLabel(): string { if (! $this->onboardingSession instanceof TenantOnboardingSession) { return 'Skipped'; } $runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null; $runs = is_array($runs) ? $runs : []; if ($runs === []) { return 'Skipped'; } return 'Started'; } private function completionSummaryBootstrapDetail(): string { if (! $this->onboardingSession instanceof TenantOnboardingSession) { return 'No bootstrap actions selected'; } $runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null; $runs = is_array($runs) ? $runs : []; if ($runs === []) { return 'No bootstrap actions selected'; } return sprintf('%d operation run(s) started', count($runs)); } private function completionSummaryBootstrapSummary(): string { return sprintf( '%s - %s', $this->completionSummaryBootstrapLabel(), $this->completionSummaryBootstrapDetail(), ); } private function completionSummaryBootstrapColor(): string { return $this->completionSummaryBootstrapLabel() === 'Started' ? 'info' : 'gray'; } public function completeOnboarding(): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE); $this->authorizeEditableDraft($user); if (! $this->onboardingSession instanceof TenantOnboardingSession) { abort(404); } $tenant = $this->trustedManagedTenantForUser($user); $completionOutcome = app(TenantOperabilityService::class)->outcomeFor( tenant: $tenant, question: TenantOperabilityQuestion::OnboardingCompletionEligibility, actor: $user, workspaceId: (int) $this->workspace->getKey(), lane: TenantInteractionLane::OnboardingWorkflow, onboardingDraft: $this->onboardingSession, ); if (! $completionOutcome->allowed) { Notification::make() ->title('Onboarding unavailable') ->body('This tenant can no longer be completed from the current onboarding workflow state.') ->warning() ->send(); return; } $run = $this->verificationRun(); $verificationSucceeded = $this->verificationHasSucceeded(); $verificationCanProceed = $this->verificationCanProceed(); $verificationBlocked = $this->verificationIsBlocked(); if (! $verificationCanProceed) { $overrideBlocked = (bool) ($this->data['override_blocked'] ?? false); if (! $overrideBlocked) { Notification::make() ->title('Verification required') ->body('Complete verification for the selected provider connection before completing onboarding.') ->warning() ->send(); return; } if (! $verificationBlocked) { throw ValidationException::withMessages([ 'data.override_blocked' => 'Verification override is only allowed when verification is blocked.', ]); } $overrideReason = trim((string) ($this->data['override_reason'] ?? '')); if ($overrideReason === '') { throw ValidationException::withMessages([ 'data.override_reason' => 'Override reason is required.', ]); } } $tenant = $tenant->fresh(); if (! $tenant instanceof Tenant) { abort(404); } $connection = $this->resolveSelectedProviderConnection($tenant); if (! $connection instanceof ProviderConnection) { Notification::make() ->title('Provider connection required') ->body('Create or select a provider connection before completing onboarding.') ->warning() ->send(); return; } $overrideBlocked = (bool) ($this->data['override_blocked'] ?? false); $overrideReason = trim((string) ($this->data['override_reason'] ?? '')); try { DB::transaction(function () use ($tenant, $user): void { $tenant->update(['status' => Tenant::STATUS_ACTIVE]); $this->setOnboardingSession($this->mutationService()->mutate( draft: $this->onboardingSession, actor: $user, expectedVersion: $this->expectedDraftVersion(), mutator: function (TenantOnboardingSession $draft): void { $draft->completed_at = now(); $draft->cancelled_at = null; $draft->current_step = 'complete'; }, )); }); } catch (OnboardingDraftConflictException) { $tenant->refresh(); if ($tenant->status === Tenant::STATUS_ACTIVE) { $tenant->update(['status' => Tenant::STATUS_ONBOARDING]); } $this->handleDraftConflict('Completing onboarding was blocked because the onboarding draft changed.'); return; } catch (OnboardingDraftImmutableException) { $tenant->refresh(); if ($tenant->status === Tenant::STATUS_ACTIVE) { $tenant->update(['status' => Tenant::STATUS_ONBOARDING]); } $this->handleImmutableDraft('Completing onboarding was blocked because the onboarding draft is no longer editable.'); return; } if ($overrideBlocked) { app(WorkspaceAuditLogger::class)->log( workspace: $this->workspace, action: AuditActionId::ManagedTenantOnboardingActivationOverrideUsed->value, context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'tenant_db_id' => (int) $tenant->getKey(), 'onboarding_session_id' => (int) $this->onboardingSession->getKey(), 'verification_operation_run_id' => $run instanceof OperationRun ? (int) $run->getKey() : null, 'override_reason' => $overrideReason, ], ], actor: $user, status: 'override', resourceType: 'managed_tenant_onboarding_session', resourceId: (string) $this->onboardingSession->getKey(), ); } app(WorkspaceAuditLogger::class)->log( workspace: $this->workspace, action: AuditActionId::ManagedTenantOnboardingActivation->value, context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'tenant_db_id' => (int) $tenant->getKey(), 'provider_connection_id' => (int) $connection->getKey(), 'verification_operation_run_id' => $run instanceof OperationRun ? (int) $run->getKey() : null, 'verification_succeeded' => $verificationSucceeded, 'override_blocked' => $overrideBlocked, 'override_reason' => $overrideBlocked ? $overrideReason : null, ], ], actor: $user, status: $overrideBlocked ? 'override' : 'success', resourceType: 'tenant', resourceId: (string) $tenant->getKey(), ); $this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)); } private function verificationRun(): ?OperationRun { if (! $this->onboardingSession instanceof TenantOnboardingSession) { return null; } return $this->lifecycleService()->verificationRun($this->onboardingSession); } private function verificationHasSucceeded(): bool { return $this->verificationCanProceed() && $this->verificationStatus() === 'ready'; } private function verificationRunIsStaleForSelectedConnection(): bool { $run = $this->verificationRun(); if (! $run instanceof OperationRun) { return false; } return ! $this->verificationRunMatchesSelectedConnection($run); } private function verificationRunMatchesSelectedConnection(OperationRun $run): bool { $selectedProviderConnectionId = $this->selectedProviderConnectionId; if ($selectedProviderConnectionId === null && $this->onboardingSession instanceof TenantOnboardingSession) { $candidate = $this->onboardingSession->state['provider_connection_id'] ?? null; $selectedProviderConnectionId = $this->resolvePersistedProviderConnectionId($candidate); } if ($selectedProviderConnectionId === null) { return false; } $context = is_array($run->context ?? null) ? $run->context : []; $runProviderConnectionId = $context['provider_connection_id'] ?? null; $runProviderConnectionId = is_int($runProviderConnectionId) ? $runProviderConnectionId : (is_numeric($runProviderConnectionId) ? (int) $runProviderConnectionId : null); if ($runProviderConnectionId === null) { return false; } return $runProviderConnectionId === $selectedProviderConnectionId; } /** * @return array{is_visible: bool, reason: 'permission_blocked'|'permission_attention'|'hidden_ready'|'hidden_irrelevant'} */ private function verificationAssistVisibility(): array { $tenant = $this->managedTenant; $user = $this->currentUser(); $run = $this->verificationRun(); if (! $tenant instanceof Tenant || ! $user instanceof User || ! $user->canAccessTenant($tenant)) { return $this->hiddenVerificationAssistVisibility(); } if (! $run instanceof OperationRun || $run->status !== OperationRunStatus::Completed->value) { return $this->hiddenVerificationAssistVisibility(); } $report = VerificationReportViewer::report($run); if (! is_array($report)) { return $this->hiddenVerificationAssistVisibility(); } return app(VerificationAssistViewModelBuilder::class)->visibility($tenant, $report); } /** * @return array{ * tenant: array{id:int,external_id:string,name:string}, * verification: array{overall:?string,status:?string,is_stale:bool,stale_reason:?string}, * overview: array{ * overall:string, * counts: array{missing_application:int,missing_delegated:int,present:int,error:int}, * freshness: array{last_refreshed_at:?string,is_stale:bool} * }, * missing_permissions: array{ * application: array,status:'granted'|'missing'|'error',details:array|null}>, * delegated: array,status:'granted'|'missing'|'error',details:array|null}> * }, * copy: array{application:string,delegated:string}, * actions: array, * fallback: array{has_incomplete_detail:bool,message:?string} * } */ private function verificationAssistViewModel(): array { $tenant = $this->managedTenant; $user = $this->currentUser(); $run = $this->verificationRun(); if (! $tenant instanceof Tenant || ! $user instanceof User || ! $user->canAccessTenant($tenant)) { abort(404); } if (! $run instanceof OperationRun || $run->status !== OperationRunStatus::Completed->value) { abort(404); } $report = VerificationReportViewer::report($run); if (! is_array($report)) { abort(404); } return app(VerificationAssistViewModelBuilder::class)->build( tenant: $tenant, verificationReport: $report, providerConnection: $this->resolveSelectedProviderConnection($tenant), verificationStatus: $this->verificationStatus(), isVerificationStale: $this->verificationRunIsStaleForSelectedConnection(), staleReason: $this->verificationAssistStaleReason(), canAccessProviderConnectionDiagnostics: $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW), ); } private function verificationAssistStaleReason(): ?string { if (! $this->verificationRunIsStaleForSelectedConnection()) { return null; } return 'The selected provider connection has changed since this verification run. Start verification again to validate the current connection.'; } /** * @return array{is_visible: bool, reason: 'hidden_irrelevant'} */ private function hiddenVerificationAssistVisibility(): array { return [ 'is_visible' => false, 'reason' => 'hidden_irrelevant', ]; } /** * @param array $state * @return array */ private function resetDependentOnboardingStateOnConnectionChange(array $state, ?int $previousProviderConnectionId, int $newProviderConnectionId): array { if ($previousProviderConnectionId === null) { return $state; } if ($previousProviderConnectionId === $newProviderConnectionId) { return $state; } unset( $state['verification_operation_run_id'], $state['bootstrap_operation_runs'], $state['bootstrap_operation_types'], ); $state['connection_recently_updated'] = true; return $state; } private function connectionRecentlyUpdated(): bool { if (! $this->onboardingSession instanceof TenantOnboardingSession) { return false; } return (bool) ($this->onboardingSession->state['connection_recently_updated'] ?? false); } /** * @return array{display_name: string, entra_tenant_id: string, uses_dedicated_override: bool, client_id: string} */ private function inlineEditSelectedConnectionFill(int $providerConnectionId): array { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE); if (! $this->managedTenant instanceof Tenant) { abort(404); } $connection = ProviderConnection::query() ->with('credential') ->where('workspace_id', (int) $this->workspace->getKey()) ->where('tenant_id', (int) $this->managedTenant->getKey()) ->whereKey($providerConnectionId) ->first(); if (! $connection instanceof ProviderConnection) { abort(404); } $payload = $connection->credential?->payload; $payload = is_array($payload) ? $payload : []; return [ 'display_name' => (string) $connection->display_name, 'entra_tenant_id' => (string) $connection->entra_tenant_id, 'uses_dedicated_override' => $connection->connection_type === ProviderConnectionType::Dedicated, 'client_id' => (string) ($payload['client_id'] ?? ''), ]; } /** * @param array{display_name?: mixed, connection_type?: mixed, uses_dedicated_override?: mixed, client_id?: mixed, client_secret?: mixed} $data */ public function updateSelectedProviderConnectionInline(int $providerConnectionId, array $data): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE); if (! $this->managedTenant instanceof Tenant) { abort(404); } $connection = ProviderConnection::query() ->with('credential') ->where('workspace_id', (int) $this->workspace->getKey()) ->where('tenant_id', (int) $this->managedTenant->getKey()) ->whereKey($providerConnectionId) ->first(); if (! $connection instanceof ProviderConnection) { abort(404); } $displayName = trim((string) ($data['display_name'] ?? '')); $explicitConnectionType = ProviderConnectionType::tryFrom(trim((string) ($data['connection_type'] ?? ''))); $requestedDedicatedOverride = (bool) ($data['uses_dedicated_override'] ?? false); $clientId = trim((string) ($data['client_id'] ?? '')); $clientSecret = trim((string) ($data['client_secret'] ?? '')); $existingType = $connection->connection_type instanceof ProviderConnectionType ? $connection->connection_type : ProviderConnectionType::Platform; if ($displayName === '') { throw ValidationException::withMessages([ 'display_name' => 'Connection name is required.', ]); } $existingPayload = $connection->credential?->payload; $existingPayload = is_array($existingPayload) ? $existingPayload : []; $existingClientId = trim((string) ($existingPayload['client_id'] ?? '')); $targetType = $explicitConnectionType ?? (($requestedDedicatedOverride || $clientId !== '' || $clientSecret !== '' || $existingType === ProviderConnectionType::Dedicated) ? ProviderConnectionType::Dedicated : ProviderConnectionType::Platform); $changedFields = []; if ($displayName !== (string) $connection->display_name) { $changedFields[] = 'display_name'; } if ($targetType !== $existingType) { $changedFields[] = 'connection_type'; } if ($targetType === ProviderConnectionType::Dedicated) { $this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED); if ($clientId === '') { throw ValidationException::withMessages([ 'client_id' => 'Dedicated App (client) ID is required.', ]); } if ($clientId !== $existingClientId) { $changedFields[] = 'client_id'; } if (($existingType !== ProviderConnectionType::Dedicated || $clientId !== $existingClientId) && $clientSecret === '') { throw ValidationException::withMessages([ 'client_secret' => 'Enter a dedicated client secret when enabling dedicated mode or changing the App (client) ID.', ]); } if ($clientSecret !== '') { $changedFields[] = 'client_secret'; } } if ($targetType === ProviderConnectionType::Platform && $existingType === ProviderConnectionType::Dedicated) { $this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED); } DB::transaction(function () use ($connection, $displayName, $clientId, $clientSecret, $targetType, $existingType, $existingClientId): void { $connection->forceFill([ 'display_name' => $displayName, ])->save(); if ($targetType === ProviderConnectionType::Dedicated && $clientSecret !== '') { app(ProviderConnectionMutationService::class)->enableDedicatedOverride( connection: $connection->fresh(), clientId: $clientId, clientSecret: $clientSecret, ); } if ($targetType === ProviderConnectionType::Dedicated && $existingType === ProviderConnectionType::Dedicated && $clientSecret === '' && $clientId !== '' && $clientId === $existingClientId) { return; } if ($targetType === ProviderConnectionType::Platform && $existingType === ProviderConnectionType::Dedicated) { app(ProviderConnectionMutationService::class)->revertToPlatform($connection->fresh()); } }); if (in_array('connection_type', $changedFields, true)) { app(AuditLogger::class)->log( tenant: $this->managedTenant, action: 'provider_connection.connection_type_changed', context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'provider_connection_id' => (int) $connection->getKey(), 'provider' => (string) $connection->provider, 'entra_tenant_id' => (string) $connection->entra_tenant_id, 'from_connection_type' => $existingType->value, 'to_connection_type' => $targetType->value, 'source' => 'managed_tenant_onboarding_wizard.inline_edit', ], ], actorId: (int) $user->getKey(), actorEmail: (string) $user->email, actorName: (string) $user->name, resourceType: 'provider_connection', resourceId: (string) $connection->getKey(), status: 'success', ); } if ($changedFields !== []) { app(AuditLogger::class)->log( tenant: $this->managedTenant, action: 'provider_connection.updated', context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'provider_connection_id' => (int) $connection->getKey(), 'provider' => (string) $connection->provider, 'entra_tenant_id' => (string) $connection->entra_tenant_id, 'fields' => $changedFields, 'connection_type' => $targetType->value, 'source' => 'managed_tenant_onboarding_wizard.inline_edit', ], ], actorId: (int) $user->getKey(), actorEmail: (string) $user->email, actorName: (string) $user->name, resourceType: 'provider_connection', resourceId: (string) $connection->getKey(), status: 'success', ); } if ($this->onboardingSession instanceof TenantOnboardingSession) { try { $this->setOnboardingSession($this->mutationService()->mutate( draft: $this->onboardingSession, actor: $user, expectedVersion: $this->expectedDraftVersion(), mutator: function (TenantOnboardingSession $draft) use ($connection): void { $state = is_array($draft->state) ? $draft->state : []; unset( $state['verification_operation_run_id'], $state['bootstrap_operation_runs'], $state['bootstrap_operation_types'], ); $state['connection_recently_updated'] = true; $draft->state = array_merge($state, [ 'provider_connection_id' => (int) $connection->getKey(), ]); }, )); } catch (OnboardingDraftConflictException) { $this->handleDraftConflict(); return; } catch (OnboardingDraftImmutableException) { $this->handleImmutableDraft(); return; } } Notification::make() ->title('Connection updated') ->success() ->send(); $this->initializeWizardData(); } /** * @return array */ private function bootstrapOperationOptions(): array { $registry = app(ProviderOperationRegistry::class); return collect($registry->all()) ->reject(fn (array $definition, string $type): bool => $type === 'provider.connection.check') ->mapWithKeys(fn (array $definition, string $type): array => [$type => (string) ($definition['label'] ?? $type)]) ->all(); } private function resolveDefaultProviderConnectionId(Tenant $tenant): ?int { $id = ProviderConnection::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->where('tenant_id', (int) $tenant->getKey()) ->where('is_default', true) ->orderByDesc('id') ->value('id'); if (is_int($id)) { return $id; } $fallback = ProviderConnection::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->where('tenant_id', (int) $tenant->getKey()) ->orderByDesc('id') ->value('id'); return is_int($fallback) ? $fallback : null; } private function resolveSelectedProviderConnection(Tenant $tenant): ?ProviderConnection { $providerConnectionId = $this->selectedProviderConnectionId; if (! is_int($providerConnectionId) && $this->onboardingSession instanceof TenantOnboardingSession) { $candidate = $this->onboardingSession->state['provider_connection_id'] ?? null; $providerConnectionId = is_int($candidate) ? $candidate : null; } if (! is_int($providerConnectionId)) { $providerConnectionId = $this->resolveDefaultProviderConnectionId($tenant); } if (! is_int($providerConnectionId)) { return null; } return ProviderConnection::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->where('tenant_id', (int) $tenant->getKey()) ->whereKey($providerConnectionId) ->first(); } private function resolvePersistedProviderConnectionId(mixed $providerConnectionId): ?int { $providerConnectionId = is_int($providerConnectionId) ? $providerConnectionId : (is_numeric($providerConnectionId) ? (int) $providerConnectionId : null); if ($providerConnectionId === null) { return null; } $tenantId = $this->managedTenant?->getKey(); if (! is_int($tenantId) && $this->onboardingSession instanceof TenantOnboardingSession) { $tenantId = is_numeric($this->onboardingSession->tenant_id) ? (int) $this->onboardingSession->tenant_id : null; } if (! is_int($tenantId)) { return null; } $exists = ProviderConnection::query() ->whereKey($providerConnectionId) ->where('workspace_id', (int) $this->workspace->getKey()) ->where('tenant_id', $tenantId) ->exists(); return $exists ? $providerConnectionId : null; } }