*/ public array $handoffUserOptions = []; public function mount(): void { $tenant = Tenant::current(); $user = auth()->user(); if (! $user instanceof User) { abort(403, 'Not allowed'); } /** @var CapabilityResolver $resolver */ $resolver = app(CapabilityResolver::class); $this->canStartProviderTasks = $resolver->can($user, $tenant, Capabilities::PROVIDER_RUN); $this->canManageProviderConnections = $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE); $this->canManageTenant = $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE); $activeSession = OnboardingSession::query() ->where('tenant_id', $tenant->getKey()) ->whereIn('status', ['draft', 'in_progress']) ->latest('id') ->first(); if (! $activeSession instanceof OnboardingSession && $this->canStartProviderTasks) { $activeSession = OnboardingSession::query()->create([ 'tenant_id' => $tenant->getKey(), 'status' => 'draft', 'current_step' => 1, 'assigned_to_user_id' => $user->getKey(), 'metadata' => [], ]); } $this->session = $activeSession; $defaultConnectionId = ProviderConnection::query() ->where('tenant_id', $tenant->getKey()) ->where('is_default', true) ->value('id'); $this->selectedProviderConnectionId = $this->session?->provider_connection_id ?? (is_int($defaultConnectionId) ? $defaultConnectionId : null); $this->refreshCollaborationState(attemptAcquire: $this->canStartProviderTasks); if ($this->session instanceof OnboardingSession && $this->hasSessionLock && $this->session->provider_connection_id === null && is_int($this->selectedProviderConnectionId) && $this->tenantHasLegacyCredentials($tenant) ) { $connection = ProviderConnection::query() ->where('tenant_id', $tenant->getKey()) ->whereKey($this->selectedProviderConnectionId) ->first(); if ($connection instanceof ProviderConnection) { $this->session->update(['provider_connection_id' => $connection->getKey()]); $this->session->refresh(); } } } private function tenantHasLegacyCredentials(Tenant $tenant): bool { return trim((string) ($tenant->app_client_id ?? '')) !== '' && trim((string) ($tenant->app_client_secret ?? '')) !== ''; } /** * @return array */ protected function getHeaderActions(): array { $tenant = Tenant::current(); return [ UiEnforcement::forAction( Action::make('takeover_onboarding_session') ->label('Take over') ->color('warning') ->requiresConfirmation() ->modalHeading('Take over onboarding session') ->modalDescription('This will take over the onboarding session lock. Use when the current lock holder is unavailable.') ->action(function (): void { $this->takeoverSession(); }), ) ->requireCapability(Capabilities::TENANT_MANAGE) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->apply() ->visible(fn (): bool => $this->session instanceof OnboardingSession && $this->sessionLockedByOther), UiEnforcement::forAction( Action::make('handoff_onboarding_session') ->label('Handoff') ->color('gray') ->requiresConfirmation() ->modalHeading('Handoff onboarding session') ->modalDescription('Assign onboarding to another tenant member and release your lock.') ->form([ Select::make('assigned_to_user_id') ->label('Assign to') ->options(fn (): array => $this->handoffUserOptions) ->searchable() ->required(), ]) ->action(function (array $data): void { $assignedToUserId = (int) ($data['assigned_to_user_id'] ?? 0); $this->handoffSession($assignedToUserId); }), ) ->requireCapability(Capabilities::TENANT_MANAGE) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->apply() ->visible(fn (): bool => $this->session instanceof OnboardingSession && $this->hasSessionLock), Action::make('release_onboarding_lock') ->label('Release lock') ->color('gray') ->visible(fn (): bool => $this->session instanceof OnboardingSession && $this->hasSessionLock) ->requiresConfirmation() ->modalHeading('Release onboarding lock') ->modalDescription('This will release your lock so another user can take over onboarding.') ->action(function (): void { $this->releaseSessionLock(); }), UiEnforcement::forAction( Action::make('migrate_legacy_credentials') ->label('Migrate legacy credentials') ->color('warning') ->requiresConfirmation() ->modalHeading('Migrate legacy tenant credentials') ->modalDescription('This will copy the tenant\'s legacy app client secret into the selected provider connection credentials. The secret is never displayed.') ->action(function (): void { $this->migrateLegacyCredentials(); }), ) ->requireCapability(Capabilities::PROVIDER_MANAGE) ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) ->apply() ->visible(fn (): bool => $this->canOfferLegacyCredentialMigration()), ]; } private function refreshCollaborationState(bool $attemptAcquire = false): void { $tenant = Tenant::current(); $user = auth()->user(); if (! $user instanceof User) { abort(403, 'Not allowed'); } $this->hasSessionLock = false; $this->sessionLockedByOther = false; $this->sessionLockedByLabel = null; $this->sessionLockedUntil = null; $this->handoffUserOptions = []; if (! $this->session instanceof OnboardingSession) { return; } if ($this->canManageTenant) { $this->handoffUserOptions = $tenant->users() ->orderBy('name') ->orderBy('email') ->get(['users.id', 'users.name', 'users.email']) ->mapWithKeys(function (User $member): array { $label = trim((string) $member->name) !== '' ? (string) $member->name : (string) $member->email; if (trim((string) $member->email) !== '') { $label .= ' <'.$member->email.'>'; } return [(int) $member->getKey() => $label]; }) ->all(); } if ($attemptAcquire) { app(OnboardingLockService::class)->acquire($this->session, $user); } $this->session->refresh(); $this->session->loadMissing(['lockedBy']); $this->hasSessionLock = (int) ($this->session->locked_by_user_id ?? 0) === (int) $user->getKey() && $this->session->locked_until?->isFuture(); $this->sessionLockedByOther = $this->isLockedByOther($this->session, $user); if ($this->sessionLockedByOther) { $lockedBy = $this->session->lockedBy; $this->sessionLockedByLabel = $lockedBy instanceof User ? (trim((string) $lockedBy->name) !== '' ? (string) $lockedBy->name : (string) $lockedBy->email) : 'another user'; } $this->sessionLockedUntil = $this->session->locked_until?->diffForHumans(); } private function ensureLockForMutation(): bool { $user = auth()->user(); if (! $user instanceof User) { abort(403, 'Not allowed'); } if (! $this->session instanceof OnboardingSession) { return false; } $acquired = app(OnboardingLockService::class)->acquire($this->session, $user); $this->refreshCollaborationState(attemptAcquire: false); if ($acquired) { return true; } Notification::make() ->title('Session is locked') ->body('Another user is currently editing onboarding. Take over the lock to make changes.') ->warning() ->send(); return false; } private function isLockedByOther(OnboardingSession $session, User $user): bool { if ($session->locked_by_user_id === null || $session->locked_until === null) { return false; } if ($session->locked_until->isPast()) { return false; } return (int) $session->locked_by_user_id !== (int) $user->getKey(); } private function takeoverSession(): void { $tenant = Tenant::current(); $user = auth()->user(); if (! $user instanceof User) { abort(403, 'Not allowed'); } if (! $this->canManageTenant) { abort(403); } if (! $this->session instanceof OnboardingSession) { return; } $previousLockHolderId = $this->session->locked_by_user_id; app(OnboardingLockService::class)->takeover($this->session, $user); app(AuditLogger::class)->log( tenant: $tenant, action: 'onboarding.takeover', context: [ 'onboarding_session_id' => (int) $this->session->getKey(), 'previous_locked_by_user_id' => is_int($previousLockHolderId) ? $previousLockHolderId : null, ], actorId: (int) $user->getKey(), actorEmail: $user->email, actorName: $user->name, status: 'success', resourceType: 'onboarding_session', resourceId: (string) $this->session->getKey(), ); $this->refreshCollaborationState(attemptAcquire: false); Notification::make() ->title('Lock taken over') ->success() ->send(); } private function handoffSession(int $assignedToUserId): void { $tenant = Tenant::current(); $user = auth()->user(); if (! $user instanceof User) { abort(403, 'Not allowed'); } if (! $this->canManageTenant) { abort(403); } if (! $this->session instanceof OnboardingSession) { return; } if (! $this->ensureLockForMutation()) { return; } $assignee = $tenant->users()->whereKey($assignedToUserId)->first(); if (! $assignee instanceof User) { Notification::make() ->title('Assignee not found') ->danger() ->send(); return; } $this->session->update(['assigned_to_user_id' => (int) $assignee->getKey()]); app(OnboardingLockService::class)->release($this->session, $user); app(AuditLogger::class)->log( tenant: $tenant, action: 'onboarding.handoff', context: [ 'onboarding_session_id' => (int) $this->session->getKey(), 'assigned_to_user_id' => (int) $assignee->getKey(), ], actorId: (int) $user->getKey(), actorEmail: $user->email, actorName: $user->name, status: 'success', resourceType: 'onboarding_session', resourceId: (string) $this->session->getKey(), ); $this->refreshCollaborationState(attemptAcquire: false); Notification::make() ->title('Onboarding handed off') ->success() ->send(); } private function releaseSessionLock(): void { $user = auth()->user(); if (! $user instanceof User) { abort(403, 'Not allowed'); } if (! $this->session instanceof OnboardingSession) { return; } app(OnboardingLockService::class)->release($this->session, $user); $this->refreshCollaborationState(attemptAcquire: false); Notification::make() ->title('Lock released') ->success() ->send(); } private function canOfferLegacyCredentialMigration(): bool { if (! $this->session instanceof OnboardingSession) { return false; } $tenant = Tenant::current(); if (! $this->tenantHasLegacyCredentials($tenant)) { return false; } $connectionId = $this->session->provider_connection_id; if (! is_int($connectionId)) { return false; } $connection = ProviderConnection::query() ->where('tenant_id', $tenant->getKey()) ->with('credential') ->whereKey($connectionId) ->first(); if (! $connection instanceof ProviderConnection) { return false; } $credential = $connection->credential; if ($credential === null) { return true; } if ($credential->type !== 'client_secret') { return false; } $payload = $credential->payload; if (! is_array($payload)) { return true; } $clientId = trim((string) Arr::get($payload, 'client_id')); $clientSecret = trim((string) Arr::get($payload, 'client_secret')); return $clientId === '' || $clientSecret === ''; } private function migrateLegacyCredentials(): void { if (! $this->canManageProviderConnections) { abort(403); } if (! $this->session instanceof OnboardingSession) { return; } if (! $this->ensureLockForMutation()) { return; } $tenant = Tenant::current(); $connectionId = $this->session->provider_connection_id; if (! is_int($connectionId)) { Notification::make() ->title('Select a provider connection first') ->warning() ->send(); return; } $connection = ProviderConnection::query() ->where('tenant_id', $tenant->getKey()) ->whereKey($connectionId) ->first(); if (! $connection instanceof ProviderConnection) { Notification::make() ->title('Selected provider connection not found') ->danger() ->send(); return; } $outcome = app(LegacyTenantCredentialMigrator::class)->migrate($tenant, $connection); $this->refreshCollaborationState(attemptAcquire: false); Notification::make() ->title($outcome['migrated'] ? 'Credentials migrated' : 'Migration not needed') ->body($outcome['message']) ->color($outcome['migrated'] ? 'success' : 'gray') ->send(); } /** * @return array */ public function providerConnections(): array { $tenant = Tenant::current(); return ProviderConnection::query() ->where('tenant_id', $tenant->getKey()) ->orderByDesc('is_default') ->orderBy('provider') ->orderBy('display_name') ->get() ->map(function (ProviderConnection $connection): array { $entraName = Arr::get(is_array($connection->metadata) ? $connection->metadata : [], 'entra_tenant_name'); $entraSuffix = is_string($entraName) && trim($entraName) !== '' ? ' — '.trim($entraName) : ''; $label = ($connection->display_name ?: ucfirst($connection->provider)) .$entraSuffix .($connection->is_default ? ' (default)' : ''); return [ 'id' => (int) $connection->getKey(), 'label' => $label, ]; }) ->values() ->all(); } public function updatedSelectedProviderConnectionId(): void { if (! $this->canStartProviderTasks) { abort(403); } $tenant = Tenant::current(); if (! $this->session instanceof OnboardingSession) { return; } if (! $this->ensureLockForMutation()) { return; } if (! is_int($this->selectedProviderConnectionId)) { $this->session->update(['provider_connection_id' => null]); $this->session->refresh(); return; } $connection = ProviderConnection::query() ->where('tenant_id', $tenant->getKey()) ->whereKey($this->selectedProviderConnectionId) ->first(); if (! $connection instanceof ProviderConnection) { Notification::make() ->title('Connection not found') ->danger() ->send(); return; } $this->session->update([ 'provider_connection_id' => $connection->getKey(), ]); $this->session->refresh(); $this->refreshCollaborationState(attemptAcquire: false); Notification::make() ->title('Provider connection selected') ->success() ->send(); } /** * @return array}> */ public function planTasks(): array { return OnboardingTaskCatalog::all(); } /** * @return array */ public function latestEvidenceStatusByTaskType(): array { $tenant = Tenant::current(); $evidence = OnboardingEvidence::query() ->where('tenant_id', $tenant->getKey()) ->whereIn('task_type', OnboardingTaskType::all()) ->orderByDesc('recorded_at') ->get(); $byTask = []; foreach ($evidence as $row) { if (! isset($byTask[$row->task_type])) { $byTask[$row->task_type] = $row->status; } } return $byTask; } public function startVerifyPermissions(): void { if (! $this->canStartProviderTasks) { abort(403); } $tenant = Tenant::current(); $user = auth()->user(); if (! $user instanceof User) { abort(403, 'Not allowed'); } if (! $this->session instanceof OnboardingSession) { Notification::make() ->title('No onboarding session') ->danger() ->send(); return; } if (! $this->ensureLockForMutation()) { return; } $connectionId = $this->session->provider_connection_id; if (! is_int($connectionId)) { Notification::make() ->title('Select a provider connection first') ->warning() ->send(); return; } $connection = ProviderConnection::query() ->where('tenant_id', $tenant->getKey()) ->whereKey($connectionId) ->first(); if (! $connection instanceof ProviderConnection) { Notification::make() ->title('Selected provider connection not found') ->danger() ->send(); return; } if ($this->session->current_step < 4) { $this->session->update(['current_step' => 4]); $this->session->refresh(); } $taskType = OnboardingTaskType::VerifyPermissions; /** @var OperationRunService $runs */ $runs = app(OperationRunService::class); $run = $runs->ensureRunWithIdentity( tenant: $tenant, type: $taskType, identityInputs: [ 'task_type' => $taskType, ], context: [ 'task_type' => $taskType, 'onboarding_session_id' => (int) $this->session->getKey(), 'provider_connection_id' => (int) $connection->getKey(), ], initiator: $user, ); $this->verifyPermissionsRunUrl = OperationRunLinks::view($run, $tenant); if ($run->wasRecentlyCreated) { OnboardingVerifyPermissionsJob::dispatch( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), providerConnectionId: (int) $connection->getKey(), onboardingSessionId: (int) $this->session->getKey(), operationRun: $run, ); Notification::make() ->title('Verify permissions queued') ->body('Run queued. Use the link below to monitor progress.') ->success() ->send(); return; } Notification::make() ->title('Verify permissions already queued') ->body('A run is already queued or running. Use the link below to monitor progress.') ->warning() ->send(); } public function startConsentStatus(): void { if (! $this->canStartProviderTasks) { abort(403); } $tenant = Tenant::current(); $user = auth()->user(); if (! $user instanceof User) { abort(403, 'Not allowed'); } if (! $this->session instanceof OnboardingSession) { return; } if (! $this->ensureLockForMutation()) { return; } if ($this->session->current_step < 4) { $this->session->update(['current_step' => 4]); $this->session->refresh(); } if (! OnboardingTaskCatalog::prerequisitesMet( taskType: OnboardingTaskType::ConsentStatus, latestEvidenceStatusByTaskType: $this->latestEvidenceStatusByTaskType(), )) { Notification::make() ->title('Prerequisites not met') ->body('Run “Verify permissions” first.') ->warning() ->send(); return; } $connectionId = $this->session->provider_connection_id; if (! is_int($connectionId)) { Notification::make() ->title('Select a provider connection first') ->warning() ->send(); return; } $connection = ProviderConnection::query() ->where('tenant_id', $tenant->getKey()) ->whereKey($connectionId) ->first(); if (! $connection instanceof ProviderConnection) { Notification::make() ->title('Selected provider connection not found') ->danger() ->send(); return; } $taskType = OnboardingTaskType::ConsentStatus; /** @var OperationRunService $runs */ $runs = app(OperationRunService::class); $run = $runs->ensureRunWithIdentity( tenant: $tenant, type: $taskType, identityInputs: [ 'task_type' => $taskType, ], context: [ 'task_type' => $taskType, 'onboarding_session_id' => (int) $this->session->getKey(), 'provider_connection_id' => (int) $connection->getKey(), ], initiator: $user, ); $this->consentStatusRunUrl = OperationRunLinks::view($run, $tenant); if ($run->wasRecentlyCreated) { OnboardingConsentStatusJob::dispatch( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), providerConnectionId: (int) $connection->getKey(), onboardingSessionId: (int) $this->session->getKey(), operationRun: $run, ); Notification::make() ->title('Consent status queued') ->success() ->send(); return; } Notification::make() ->title('Consent status already queued') ->warning() ->send(); } public function createProviderConnectionUrl(): string { return CreateProviderConnection::getUrl(tenant: Tenant::current()); } }