*/ public array $data = []; /** * @var array */ public array $selectedBootstrapOperationTypes = []; /** * @return array */ protected function getHeaderActions(): array { return []; } public function mount(Workspace $workspace): void { $this->workspace = $workspace; $user = auth()->user(); if (! $user instanceof User) { abort(403); } if (! app(WorkspaceContext::class)->isMember($user, $workspace)) { abort(404); } if (! $user->can(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, $workspace)) { abort(403); } $this->resumeLatestOnboardingSessionIfUnambiguous(); $this->initializeWizardData(); } public function content(Schema $schema): Schema { return $schema ->statePath('data') ->schema([ Wizard::make([ Step::make('Identify managed tenant') ->description('Create or resume a pending managed tenant in this workspace.') ->schema([ Section::make('Tenant') ->schema([ TextInput::make('tenant_id') ->label('Tenant ID (GUID)') ->required() ->rules(['uuid']) ->maxLength(255), TextInput::make('name') ->label('Display name') ->required() ->maxLength(255), ]), ]) ->afterValidation(function (): void { $tenantGuid = (string) ($this->data['tenant_id'] ?? ''); $tenantName = (string) ($this->data['name'] ?? ''); try { $this->identifyManagedTenant([ 'tenant_id' => $tenantGuid, 'name' => $tenantName, ]); } 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'), 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.client_id') ->label('Client ID') ->required(fn (Get $get): bool => $get('connection_mode') === 'new') ->maxLength(255) ->visible(fn (Get $get): bool => $get('connection_mode') === 'new'), TextInput::make('new_connection.client_secret') ->label('Client secret') ->password() ->required(fn (Get $get): bool => $get('connection_mode') === 'new') ->maxLength(255) ->visible(fn (Get $get): bool => $get('connection_mode') === 'new') ->helperText('Stored encrypted and never shown again.'), 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'] ?? ''), 'client_id' => (string) ($new['client_id'] ?? ''), 'client_secret' => (string) ($new['client_secret'] ?? ''), 'is_default' => (bool) ($new['is_default'] ?? true), ]); if (is_array($this->data['new_connection'] ?? null)) { $this->data['new_connection']['client_secret'] = null; } } 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->verificationHasSucceeded() ? 'success' : 'warning'), SchemaActions::make([ Action::make('wizardStartVerification') ->label('Start verification') ->visible(fn (): bool => $this->managedTenant instanceof Tenant) ->action(fn () => $this->startVerification()), Action::make('wizardViewVerificationRun') ->label('View run') ->url(fn (): ?string => $this->verificationRunUrl()) ->visible(fn (): bool => $this->verificationRunUrl() !== null), ]), ]), ]) ->beforeValidation(function (): void { if (! $this->verificationHasSucceeded()) { Notification::make() ->title('Verification required') ->body('Run verification successfully 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) ->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('Activate the tenant and finish onboarding.') ->schema([ Section::make('Finish') ->schema([ Text::make(fn (): string => $this->managedTenant instanceof Tenant ? 'Tenant: '.$this->managedTenant->name : 'Tenant: not selected') ->badge() ->color('gray'), Text::make(fn (): string => 'Verification: '.($this->verificationHasSucceeded() ? 'succeeded' : 'not yet succeeded')) ->badge() ->color(fn (): string => $this->verificationHasSucceeded() ? 'success' : 'warning'), SchemaActions::make([ Action::make('wizardCompleteOnboarding') ->label('Complete onboarding') ->color('success') ->disabled(fn (): bool => ! $this->verificationHasSucceeded()) ->action(fn () => $this->completeOnboarding()), ]), ]), ]) ->beforeValidation(function (): void { if (! $this->verificationHasSucceeded()) { throw new Halt; } }), ]) ->startOnStep(fn (): int => $this->computeWizardStartStep()) ->skippable(false), ]); } private function resumeLatestOnboardingSessionIfUnambiguous(): void { $sessionCount = TenantOnboardingSession::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->whereNull('completed_at') ->count(); if ($sessionCount !== 1) { return; } $session = TenantOnboardingSession::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->whereNull('completed_at') ->orderByDesc('updated_at') ->first(); if (! $session instanceof TenantOnboardingSession) { return; } $tenant = Tenant::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->whereKey((int) $session->tenant_id) ->first(); if (! $tenant instanceof Tenant) { return; } $this->managedTenant = $tenant; $this->onboardingSession = $session; $providerConnectionId = $session->state['provider_connection_id'] ?? null; $this->selectedProviderConnectionId = is_int($providerConnectionId) ? $providerConnectionId : $this->resolveDefaultProviderConnectionId($tenant); $bootstrapTypes = $session->state['bootstrap_operation_types'] ?? []; $this->selectedBootstrapOperationTypes = is_array($bootstrapTypes) ? array_values(array_filter($bootstrapTypes, static fn ($v): bool => is_string($v) && $v !== '')) : []; } private function initializeWizardData(): void { if (! array_key_exists('connection_mode', $this->data)) { $this->data['connection_mode'] = 'existing'; } if ($this->managedTenant instanceof Tenant) { $this->data['tenant_id'] ??= (string) $this->managedTenant->tenant_id; $this->data['name'] ??= (string) $this->managedTenant->name; } if ($this->onboardingSession instanceof TenantOnboardingSession) { $providerConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null; if (is_int($providerConnectionId)) { $this->data['provider_connection_id'] = $providerConnectionId; $this->selectedProviderConnectionId = $providerConnectionId; } $types = $this->onboardingSession->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 !== '')); } } if (($this->data['provider_connection_id'] ?? null) === null && $this->selectedProviderConnectionId !== null) { $this->data['provider_connection_id'] = $this->selectedProviderConnectionId; } } private function computeWizardStartStep(): int { if (! $this->managedTenant instanceof Tenant) { return 1; } if (! $this->resolveSelectedProviderConnection($this->managedTenant)) { return 2; } if (! $this->verificationHasSucceeded()) { return 3; } return 4; } /** * @return array */ private function providerConnectionOptions(): array { if (! $this->managedTenant instanceof Tenant) { return []; } return ProviderConnection::query() ->where('tenant_id', $this->managedTenant->getKey()) ->orderByDesc('is_default') ->orderBy('display_name') ->pluck('display_name', 'id') ->all(); } private function verificationStatusLabel(): string { if (! $this->managedTenant instanceof Tenant) { return 'not started'; } if ($this->verificationHasSucceeded()) { return 'succeeded'; } $runId = $this->onboardingSession?->state['verification_operation_run_id'] ?? null; return is_int($runId) ? 'running or failed' : 'not started'; } 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 OperationRunLinks::view($runId, $this->managedTenant); } private function bootstrapRunsLabel(): string { if (! $this->onboardingSession instanceof TenantOnboardingSession) { return ''; } $runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null; $runs = is_array($runs) ? $runs : []; if ($runs === []) { return ''; } return sprintf('Started %d bootstrap 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; } $this->onboardingSession->forceFill([ 'current_step' => $step, 'updated_by_user_id' => (int) $user->getKey(), ])->save(); } private function authorizeWorkspaceMutation(User $user): void { if (! app(WorkspaceContext::class)->isMember($user, $this->workspace)) { abort(404); } if (! $user->can(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, $this->workspace)) { abort(403); } } 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{tenant_id: string, name: string} $data */ public function identifyManagedTenant(array $data): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMutation($user); $tenantGuid = $data['tenant_id']; $tenantName = $data['name']; DB::transaction(function () use ($user, $tenantGuid, $tenantName): void { $auditLogger = app(WorkspaceAuditLogger::class); $membershipManager = app(TenantMembershipManager::class); $existingTenant = Tenant::query() ->withTrashed() ->where('tenant_id', $tenantGuid) ->first(); if ($existingTenant instanceof Tenant) { if ($existingTenant->trashed() || $existingTenant->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); } if ($existingTenant->name !== $tenantName) { $existingTenant->forceFill(['name' => $tenantName])->save(); } $tenant = $existingTenant; } else { try { $tenant = Tenant::query()->create([ 'workspace_id' => (int) $this->workspace->getKey(), 'name' => $tenantName, 'tenant_id' => $tenantGuid, 'environment' => 'other', 'status' => 'pending', ]); } 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', $tenantGuid) ->first(); if ($existingTenant instanceof Tenant && (int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) { abort(404); } 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.'); } $session = TenantOnboardingSession::query() ->where('workspace_id', $this->workspace->getKey()) ->where('tenant_id', $tenant->getKey()) ->first(); $sessionWasCreated = false; if (! $session instanceof TenantOnboardingSession) { $session = new TenantOnboardingSession; $session->workspace_id = (int) $this->workspace->getKey(); $session->tenant_id = (int) $tenant->getKey(); $session->started_by_user_id = (int) $user->getKey(); $sessionWasCreated = true; } $session->current_step = 'identify'; $session->state = array_merge($session->state ?? [], [ 'tenant_id' => $tenantGuid, ]); $session->updated_by_user_id = (int) $user->getKey(); $session->save(); $this->selectedProviderConnectionId ??= $this->resolveDefaultProviderConnectionId($tenant); if ($this->selectedProviderConnectionId !== null) { $session->state = array_merge($session->state ?? [], [ 'provider_connection_id' => (int) $this->selectedProviderConnectionId, ]); $session->save(); } $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(), 'tenant_guid' => $tenantGuid, '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->managedTenant = $tenant; $this->onboardingSession = $session; }); Notification::make() ->title('Managed tenant identified') ->success() ->send(); } public function selectProviderConnection(int $providerConnectionId): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMutation($user); if (! $this->managedTenant instanceof Tenant) { abort(404); } $connection = ProviderConnection::query() ->where('tenant_id', $this->managedTenant->getKey()) ->whereKey($providerConnectionId) ->first(); if (! $connection instanceof ProviderConnection) { abort(404); } $this->selectedProviderConnectionId = (int) $connection->getKey(); if ($this->onboardingSession instanceof TenantOnboardingSession) { $this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [ 'provider_connection_id' => (int) $connection->getKey(), ]); $this->onboardingSession->current_step = 'connection'; $this->onboardingSession->updated_by_user_id = (int) $user->getKey(); $this->onboardingSession->save(); } Notification::make() ->title('Provider connection selected') ->success() ->send(); } /** * @param array{display_name: 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); if (! $this->managedTenant instanceof Tenant) { abort(404); } $tenant = $this->managedTenant->fresh(); if (! $tenant instanceof Tenant) { abort(404); } if ((int) $tenant->workspace_id !== (int) $this->workspace->getKey()) { abort(404); } $displayName = trim((string) ($data['display_name'] ?? '')); $clientId = (string) ($data['client_id'] ?? ''); $clientSecret = (string) ($data['client_secret'] ?? ''); $makeDefault = (bool) ($data['is_default'] ?? false); if ($displayName === '') { abort(422); } /** @var ProviderConnection $connection */ $connection = DB::transaction(function () use ($tenant, $displayName, $clientId, $clientSecret, $makeDefault): ProviderConnection { $connection = ProviderConnection::query()->updateOrCreate( [ 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, ], [ 'display_name' => $displayName, ], ); app(CredentialManager::class)->upsertClientSecretCredential( connection: $connection, clientId: $clientId, clientSecret: $clientSecret, ); if ($makeDefault) { $connection->makeDefault(); } return $connection; }); $this->selectedProviderConnectionId = (int) $connection->getKey(); if ($this->onboardingSession instanceof TenantOnboardingSession) { $this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [ 'provider_connection_id' => (int) $connection->getKey(), ]); $this->onboardingSession->current_step = 'connection'; $this->onboardingSession->updated_by_user_id = (int) $user->getKey(); $this->onboardingSession->save(); } Notification::make() ->title('Provider connection created') ->success() ->send(); } public function startVerification(): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMutation($user); if (! $this->managedTenant instanceof Tenant) { Notification::make() ->title('Identify a managed tenant first') ->warning() ->send(); return; } $tenant = $this->managedTenant->fresh(); 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 = 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 ($this->onboardingSession instanceof TenantOnboardingSession) { $this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [ 'provider_connection_id' => (int) $connection->getKey(), 'verification_operation_run_id' => (int) $result->run->getKey(), ]); $this->onboardingSession->current_step = 'verify'; $this->onboardingSession->updated_by_user_id = (int) $user->getKey(); $this->onboardingSession->save(); } $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 ($result->status === 'scope_busy') { 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(OperationRunLinks::view($result->run, $tenant)), ]) ->send(); return; } Notification::make() ->title($result->status === 'deduped' ? 'Verification already running' : 'Verification started') ->success() ->actions([ Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($result->run, $tenant)), ]) ->send(); } /** * @param array $operationTypes */ public function startBootstrap(array $operationTypes): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMutation($user); if (! $this->managedTenant instanceof Tenant) { abort(404); } $tenant = $this->managedTenant->fresh(); if (! $tenant instanceof Tenant) { abort(404); } if (! $this->verificationHasSucceeded()) { Notification::make() ->title('Verification required') ->body('Run verification successfully 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), )); if (empty($types)) { Notification::make() ->title('No bootstrap actions selected') ->warning() ->send(); return; } /** @var array{status: 'started', runs: array}|array{status: 'scope_busy', run: OperationRun} $result */ $result = DB::transaction(function () use ($tenant, $connection, $types, $registry, $user): array { $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) { return [ 'status' => 'scope_busy', 'run' => $activeRun, ]; } $runsService = app(OperationRunService::class); $bootstrapRuns = []; 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(); } return [ 'status' => 'started', 'runs' => $bootstrapRuns, ]; }); if ($result['status'] === 'scope_busy') { 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(OperationRunLinks::view($result['run'], $tenant)), ]) ->send(); return; } $bootstrapRuns = $result['runs']; if ($this->onboardingSession instanceof TenantOnboardingSession) { $state = $this->onboardingSession->state ?? []; $existing = $state['bootstrap_operation_runs'] ?? []; $existing = is_array($existing) ? $existing : []; $state['bootstrap_operation_runs'] = array_merge($existing, $bootstrapRuns); $state['bootstrap_operation_types'] = $types; $this->onboardingSession->state = $state; $this->onboardingSession->current_step = 'bootstrap'; $this->onboardingSession->updated_by_user_id = (int) $user->getKey(); $this->onboardingSession->save(); } Notification::make() ->title('Bootstrap started') ->success() ->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}"), }; } public function verificationSucceeded(): bool { return $this->verificationHasSucceeded(); } public function completeOnboarding(): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMutation($user); if (! $this->managedTenant instanceof Tenant) { abort(404); } if (! $this->onboardingSession instanceof TenantOnboardingSession) { abort(404); } if (! $this->verificationHasSucceeded()) { Notification::make() ->title('Verification required') ->body('Complete verification successfully before finishing onboarding.') ->warning() ->send(); return; } $tenant = $this->managedTenant->fresh(); if (! $tenant instanceof Tenant) { abort(404); } DB::transaction(function () use ($tenant, $user): void { $tenant->update(['status' => 'active']); $this->onboardingSession->forceFill([ 'completed_at' => now(), 'current_step' => 'complete', 'updated_by_user_id' => (int) $user->getKey(), ])->save(); }); $this->redirect(TenantDashboard::getUrl(tenant: $tenant)); } private function verificationHasSucceeded(): bool { if (! $this->managedTenant instanceof Tenant) { return false; } if (! $this->onboardingSession instanceof TenantOnboardingSession) { return false; } $runId = $this->onboardingSession->state['verification_operation_run_id'] ?? null; if (! is_int($runId)) { return false; } $run = OperationRun::query() ->where('tenant_id', (int) $this->managedTenant->getKey()) ->whereKey($runId) ->first(); if (! $run instanceof OperationRun) { return false; } return $run->status === 'completed' && $run->outcome === 'succeeded'; } /** * @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('tenant_id', (int) $tenant->getKey()) ->where('is_default', true) ->orderByDesc('id') ->value('id'); if (is_int($id)) { return $id; } $fallback = ProviderConnection::query() ->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('tenant_id', (int) $tenant->getKey()) ->whereKey($providerConnectionId) ->first(); } }