Some checks are pending
Main Confidence / confidence (push) Waiting to run
## Summary - add a shared provider target-scope descriptor, normalizer, identity-context metadata, and surface-summary layer - update provider connection list, detail, create, edit, and onboarding surfaces to use neutral target-scope vocabulary while keeping Microsoft identity contextual - align provider connection audit and resolver output with the neutral target-scope contract and add focused guard/unit/feature coverage for regressions ## Validation - browser smoke: opened the tenant-scoped provider connection list, drilled into detail, and verified the edit/create surfaces in local admin context ## Notes - this PR comes from the session branch created for the active feature work - no additional runtime or persistence layer was introduced in this slice Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #274
4537 lines
180 KiB
PHP
4537 lines
180 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Pages\Workspaces;
|
|
|
|
use App\Exceptions\Onboarding\OnboardingDraftConflictException;
|
|
use App\Exceptions\Onboarding\OnboardingDraftImmutableException;
|
|
use App\Filament\Pages\TenantDashboard;
|
|
use App\Filament\Resources\TenantResource;
|
|
use App\Filament\Support\VerificationReportChangeIndicator;
|
|
use App\Filament\Support\VerificationReportViewer;
|
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
|
use App\Jobs\ProviderInventorySyncJob;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantMembership;
|
|
use App\Models\TenantOnboardingSession;
|
|
use App\Models\User;
|
|
use App\Models\VerificationCheckAcknowledgement;
|
|
use App\Models\Workspace;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Services\Auth\TenantMembershipManager;
|
|
use App\Services\Intune\AuditLogger;
|
|
use App\Services\Onboarding\OnboardingDraftMutationService;
|
|
use App\Services\Onboarding\OnboardingDraftResolver;
|
|
use App\Services\Onboarding\OnboardingDraftStageResolver;
|
|
use App\Services\Onboarding\OnboardingLifecycleService;
|
|
use App\Services\OperationRunService;
|
|
use App\Services\Providers\ProviderConnectionMutationService;
|
|
use App\Services\Providers\ProviderOperationRegistry;
|
|
use App\Services\Providers\ProviderOperationStartGate;
|
|
use App\Services\Tenants\TenantOperabilityService;
|
|
use App\Services\Verification\VerificationCheckAcknowledgementService;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Badges\BadgeCatalog;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Livewire\TrustedState\TrustedStateResolver;
|
|
use App\Support\Onboarding\OnboardingCheckpoint;
|
|
use App\Support\Onboarding\OnboardingDraftStage;
|
|
use App\Support\Onboarding\OnboardingLifecycleState;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
use App\Support\Providers\ProviderConnectionType;
|
|
use App\Support\Providers\ProviderConsentStatus;
|
|
use App\Support\Providers\ProviderReasonCodes;
|
|
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
|
|
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
|
|
use App\Support\Providers\ProviderVerificationStatus;
|
|
use App\Support\Tenants\TenantInteractionLane;
|
|
use App\Support\Tenants\TenantLifecyclePresentation;
|
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
|
use App\Support\Verification\VerificationAssistViewModelBuilder;
|
|
use App\Support\Verification\VerificationCheckStatus;
|
|
use App\Support\Verification\VerificationReportOverall;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Filament\Actions\Action;
|
|
use Filament\Forms\Components\CheckboxList;
|
|
use Filament\Forms\Components\Radio;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Forms\Components\Toggle;
|
|
use Filament\Forms\Components\ViewField;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Filament\Schemas\Components\Actions as SchemaActions;
|
|
use Filament\Schemas\Components\Callout;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Schemas\Components\Text;
|
|
use Filament\Schemas\Components\UnorderedList;
|
|
use Filament\Schemas\Components\Utilities\Get;
|
|
use Filament\Schemas\Components\View as SchemaView;
|
|
use Filament\Schemas\Components\Wizard;
|
|
use Filament\Schemas\Components\Wizard\Step;
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Support\Enums\FontWeight;
|
|
use Filament\Support\Enums\Width;
|
|
use Filament\Support\Exceptions\Halt;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
use InvalidArgumentException;
|
|
use Livewire\Attributes\Locked;
|
|
use RuntimeException;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class ManagedTenantOnboardingWizard extends Page
|
|
{
|
|
use AuthorizesRequests;
|
|
|
|
protected static string $layout = 'filament-panels::components.layout.simple';
|
|
|
|
protected Width|string|null $maxContentWidth = Width::ScreenExtraLarge;
|
|
|
|
protected static bool $shouldRegisterNavigation = false;
|
|
|
|
protected static bool $isDiscovered = false;
|
|
|
|
protected static ?string $title = 'Managed tenant onboarding';
|
|
|
|
protected static ?string $slug = 'onboarding';
|
|
|
|
/**
|
|
* Disable the simple-layout topbar to prevent lazy-loaded
|
|
* DatabaseNotifications from triggering Livewire update 404s
|
|
* on this workspace-scoped route.
|
|
*/
|
|
protected function getLayoutData(): array
|
|
{
|
|
return [
|
|
'hasTopbar' => 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<string, mixed>
|
|
*/
|
|
public array $data = [];
|
|
|
|
/**
|
|
* @var array<int, string>
|
|
*/
|
|
public array $selectedBootstrapOperationTypes = [];
|
|
|
|
/**
|
|
* @return array<Action>
|
|
*/
|
|
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('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('Target scope ID')
|
|
->helperText('Provider-owned Microsoft tenant detail for this selected target scope.')
|
|
->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.target_scope_id')
|
|
->label('Target scope ID')
|
|
->default(fn (): string => $this->currentManagedTenantRecord()?->tenant_id ?? '')
|
|
->disabled()
|
|
->dehydrated(false)
|
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'new')
|
|
->helperText('The provider connection will point to this tenant target scope.'),
|
|
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).')
|
|
->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 operation. 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->managedTenant instanceof Tenant),
|
|
]),
|
|
])
|
|
->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 (Get $get) => $this->startBootstrap(
|
|
$this->normalizeBootstrapOperationTypes((array) ($get('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 = $this->normalizeBootstrapOperationTypes(
|
|
is_array($types) ? $types : [],
|
|
);
|
|
|
|
$this->persistBootstrapSelection($this->selectedBootstrapOperationTypes);
|
|
|
|
$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('Bootstrap needs attention')
|
|
->description(fn (): string => $this->completionSummaryBootstrapRecoveryMessage())
|
|
->warning()
|
|
->visible(fn (): bool => $this->showCompletionSummaryBootstrapRecovery()),
|
|
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 provider 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)
|
|
? $this->normalizeBootstrapOperationTypes($bootstrapTypes)
|
|
: [];
|
|
}
|
|
|
|
/**
|
|
* @param array<int|string, mixed> $operationTypes
|
|
* @return array<int, string>
|
|
*/
|
|
private function normalizeBootstrapOperationTypes(array $operationTypes): array
|
|
{
|
|
$supportedTypes = array_keys($this->supportedBootstrapCapabilities());
|
|
$normalized = [];
|
|
|
|
foreach ($operationTypes as $key => $value) {
|
|
if (is_string($value)) {
|
|
$normalizedValue = trim($value);
|
|
|
|
if ($normalizedValue !== '' && in_array($normalizedValue, $supportedTypes, true)) {
|
|
$normalized[] = $normalizedValue;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (! is_string($key) || trim($key) === '') {
|
|
continue;
|
|
}
|
|
|
|
$isSelected = match (true) {
|
|
is_bool($value) => $value,
|
|
is_int($value) => $value === 1,
|
|
is_string($value) => in_array(strtolower(trim($value)), ['1', 'true', 'on', 'yes'], true),
|
|
default => false,
|
|
};
|
|
|
|
$normalizedKey = trim($key);
|
|
|
|
if ($isSelected && in_array($normalizedKey, $supportedTypes, true)) {
|
|
$normalized[] = $normalizedKey;
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($normalized));
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function supportedBootstrapCapabilities(): array
|
|
{
|
|
return [
|
|
'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
|
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $operationTypes
|
|
*/
|
|
private function persistBootstrapSelection(array $operationTypes): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return;
|
|
}
|
|
|
|
$normalized = $this->normalizeBootstrapOperationTypes($operationTypes);
|
|
$existing = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
|
|
$existing = is_array($existing)
|
|
? $this->normalizeBootstrapOperationTypes($existing)
|
|
: [];
|
|
|
|
if ($normalized === $existing) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->setOnboardingSession($this->mutationService()->mutate(
|
|
draft: $this->onboardingSession,
|
|
actor: $user,
|
|
expectedVersion: $this->expectedDraftVersion(),
|
|
incrementVersion: false,
|
|
mutator: function (TenantOnboardingSession $draft) use ($normalized): void {
|
|
$state = is_array($draft->state) ? $draft->state : [];
|
|
$state['bootstrap_operation_types'] = $normalized;
|
|
|
|
$draft->state = $state;
|
|
},
|
|
));
|
|
} catch (OnboardingDraftConflictException) {
|
|
$this->handleDraftConflict();
|
|
|
|
return;
|
|
} catch (OnboardingDraftImmutableException) {
|
|
$this->handleImmutableDraft();
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, TenantOnboardingSession>
|
|
*/
|
|
private function availableDraftsFor(User $user): Collection
|
|
{
|
|
return app(OnboardingDraftResolver::class)->resumableDraftsFor($user, $this->workspace);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, \Filament\Schemas\Components\Component>
|
|
*/
|
|
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<int, \Filament\Schemas\Components\Component>
|
|
*/
|
|
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<int, \Filament\Schemas\Components\Component>
|
|
*/
|
|
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['bootstrap_operation_types'] ??= [];
|
|
$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['new_connection']['target_scope_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'] = $this->normalizeBootstrapOperationTypes($types);
|
|
}
|
|
}
|
|
|
|
$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<int, string>
|
|
*/
|
|
private function providerConnectionOptions(): array
|
|
{
|
|
$tenant = $this->currentManagedTenantRecord();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return [];
|
|
}
|
|
|
|
return ProviderConnection::query()
|
|
->with('tenant')
|
|
->where('workspace_id', (int) $this->workspace->getKey())
|
|
->where('tenant_id', $tenant->getKey())
|
|
->orderByDesc('is_default')
|
|
->orderBy('display_name')
|
|
->get()
|
|
->mapWithKeys(fn (ProviderConnection $connection): array => [
|
|
(int) $connection->getKey() => sprintf(
|
|
'%s — %s',
|
|
(string) $connection->display_name,
|
|
$this->providerConnectionTargetScopeSummary($connection),
|
|
),
|
|
])
|
|
->all();
|
|
}
|
|
|
|
private function providerConnectionTargetScopeSummary(ProviderConnection $connection): string
|
|
{
|
|
try {
|
|
return ProviderConnectionSurfaceSummary::forConnection($connection)->targetScopeSummary();
|
|
} catch (InvalidArgumentException) {
|
|
return 'Target scope needs review';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $extra
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function providerConnectionTargetScopeAuditMetadata(ProviderConnection $connection, array $extra = []): array
|
|
{
|
|
try {
|
|
return app(ProviderConnectionTargetScopeNormalizer::class)->auditMetadataForConnection($connection, $extra);
|
|
} catch (InvalidArgumentException) {
|
|
return array_merge([
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'provider' => (string) $connection->provider,
|
|
'target_scope' => [
|
|
'provider' => (string) $connection->provider,
|
|
'scope_kind' => ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
|
'scope_identifier' => (string) $connection->entra_tenant_id,
|
|
'scope_display_name' => (string) ($connection->tenant?->name ?? $connection->display_name ?? $connection->entra_tenant_id),
|
|
'shared_label' => 'Target scope',
|
|
'shared_help_text' => 'The platform scope this provider connection represents.',
|
|
],
|
|
'provider_identity_context' => [],
|
|
], $extra);
|
|
}
|
|
}
|
|
|
|
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
|
|
{
|
|
$run = $this->verificationRun();
|
|
|
|
if (! $run instanceof OperationRun) {
|
|
return null;
|
|
}
|
|
|
|
if (! $this->canInspectOperationRun($run)) {
|
|
return null;
|
|
}
|
|
|
|
return $this->tenantlessOperationRunUrl((int) $run->getKey());
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* run: array<string, mixed>|null,
|
|
* runUrl: string|null,
|
|
* advancedRunUrl: string|null,
|
|
* report: array<string, mixed>|null,
|
|
* fingerprint: string|null,
|
|
* changeIndicator: array{state: 'no_changes'|'changed', previous_report_id: int}|null,
|
|
* previousRunUrl: string|null,
|
|
* canAcknowledge: bool,
|
|
* acknowledgements: array<string, array{
|
|
* check_key: string,
|
|
* ack_reason: string,
|
|
* acknowledged_at: string|null,
|
|
* expires_at: string|null,
|
|
* acknowledged_by: array{id: int, name: string}|null
|
|
* }>,
|
|
* assistVisibility: array{is_visible: bool, reason: 'permission_blocked'|'permission_attention'|'hidden_ready'|'hidden_irrelevant'},
|
|
* assistActionName: string,
|
|
* technicalDetailsActionName: string,
|
|
* runState: 'no_run'|'active'|'completed'
|
|
* }
|
|
*/
|
|
private function verificationReportViewData(): array
|
|
{
|
|
$run = $this->verificationRun();
|
|
$runUrl = $this->verificationRunUrl();
|
|
$assistVisibility = $this->verificationAssistVisibility();
|
|
|
|
if (! $run instanceof OperationRun) {
|
|
return [
|
|
'run' => null,
|
|
'runUrl' => $runUrl,
|
|
'advancedRunUrl' => null,
|
|
'report' => null,
|
|
'fingerprint' => null,
|
|
'changeIndicator' => null,
|
|
'previousRunUrl' => null,
|
|
'canAcknowledge' => false,
|
|
'acknowledgements' => [],
|
|
'surface' => [],
|
|
'redactionNotes' => [],
|
|
'assistVisibility' => $assistVisibility,
|
|
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
|
|
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
|
|
'runState' => 'no_run',
|
|
];
|
|
}
|
|
|
|
$report = VerificationReportViewer::report($run);
|
|
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
|
|
|
|
$changeIndicator = VerificationReportChangeIndicator::forRun($run);
|
|
$previousRunUrl = $this->verificationPreviousRunUrl($changeIndicator);
|
|
|
|
$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 : [];
|
|
$surface = VerificationReportViewer::surface($run, $acknowledgements, [
|
|
'hostKind' => 'onboarding_wizard',
|
|
'changeIndicator' => $changeIndicator,
|
|
'previousRunUrl' => $previousRunUrl,
|
|
'nextStepPlacement' => ($assistVisibility['is_visible'] ?? false) ? 'host_action_zone' : 'shared_zone',
|
|
'hostActions' => array_values(array_filter([
|
|
($assistVisibility['is_visible'] ?? false)
|
|
? ['kind' => 'assist', 'label' => 'View required permissions', 'ownedByHost' => true]
|
|
: null,
|
|
['kind' => 'technical_details', 'label' => 'Technical details', 'ownedByHost' => true],
|
|
$canAcknowledge
|
|
? ['kind' => 'acknowledge', 'label' => 'Acknowledge', 'ownedByHost' => true]
|
|
: null,
|
|
])),
|
|
'hostVariation' => [
|
|
'ownsNoRunState' => true,
|
|
'ownsActiveState' => true,
|
|
'supportsAssist' => (bool) ($assistVisibility['is_visible'] ?? false),
|
|
'supportsAcknowledge' => $canAcknowledge,
|
|
'supportsTechnicalDetailsTrigger' => true,
|
|
],
|
|
]);
|
|
|
|
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(),
|
|
'updated_at' => $run->updated_at?->toJSON(),
|
|
'completed_at' => $run->completed_at?->toJSON(),
|
|
'target_scope' => $targetScope,
|
|
'failures' => $failures,
|
|
],
|
|
'runUrl' => $runUrl,
|
|
'advancedRunUrl' => $runUrl,
|
|
'report' => $report,
|
|
'fingerprint' => $fingerprint,
|
|
'changeIndicator' => $changeIndicator,
|
|
'previousRunUrl' => $previousRunUrl,
|
|
'canAcknowledge' => $canAcknowledge,
|
|
'acknowledgements' => $acknowledgements,
|
|
'surface' => $surface,
|
|
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
|
'assistVisibility' => $assistVisibility,
|
|
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
|
|
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
|
|
'runState' => (string) $run->status === OperationRunStatus::Completed->value ? 'completed' : 'active',
|
|
];
|
|
}
|
|
|
|
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 wizardVerificationTechnicalDetailsAction(): Action
|
|
{
|
|
return Action::make('wizardVerificationTechnicalDetails')
|
|
->label('Technical details')
|
|
->icon('heroicon-m-information-circle')
|
|
->color('gray')
|
|
->modal()
|
|
->slideOver()
|
|
->stickyModalHeader()
|
|
->modalHeading('Verification technical details')
|
|
->modalDescription('Diagnostics-only details for the current verification run.')
|
|
->modalSubmitAction(false)
|
|
->modalCancelAction(fn (Action $action): Action => $action->label('Close'))
|
|
->modalContent(fn (): View => view(
|
|
'filament.modals.onboarding-verification-technical-details',
|
|
$this->verificationTechnicalDetailsViewData(),
|
|
))
|
|
->visible(fn (): bool => $this->verificationRun() instanceof OperationRun);
|
|
}
|
|
|
|
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(s).', count($activeRuns));
|
|
}
|
|
|
|
if ($failedRuns !== []) {
|
|
return sprintf('Bootstrap needs attention for %d operation(s).', count($failedRuns));
|
|
}
|
|
|
|
return sprintf('Bootstrap completed across %d operation(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' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'tenant_db_id' => (int) $tenant->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);
|
|
}
|
|
|
|
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
|
|
provider: 'microsoft',
|
|
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
|
|
scopeIdentifier: (string) $tenant->tenant_id,
|
|
scopeDisplayName: $displayName,
|
|
providerSpecificIdentity: [
|
|
'microsoft_tenant_id' => (string) $tenant->tenant_id,
|
|
],
|
|
);
|
|
|
|
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
|
|
throw ValidationException::withMessages([
|
|
'new_connection.target_scope_id' => $targetScope['message'],
|
|
]);
|
|
}
|
|
|
|
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 {
|
|
$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,
|
|
'is_enabled' => true,
|
|
'connection_type' => ProviderConnectionType::Platform->value,
|
|
'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,
|
|
'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' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'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' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'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(),
|
|
);
|
|
}
|
|
|
|
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
|
|
result: $result,
|
|
blockedTitle: 'Verification blocked',
|
|
runUrl: $this->tenantlessOperationRunUrl((int) $result->run->getKey()),
|
|
);
|
|
|
|
if ($result->status === 'scope_busy') {
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
|
|
$notification->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if ($result->status === 'blocked') {
|
|
$notification->send();
|
|
|
|
return;
|
|
}
|
|
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
|
|
if ($result->status === 'deduped') {
|
|
$notification->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$notification->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<int, mixed> $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 = $this->normalizeBootstrapOperationTypes($operationTypes);
|
|
|
|
$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 {
|
|
$nextOperationType = $this->nextBootstrapOperationType($draft, $types, (int) $connection->getKey());
|
|
|
|
if ($nextOperationType === null) {
|
|
$result = [
|
|
'status' => 'already_completed',
|
|
'operation_type' => null,
|
|
'remaining_types' => [],
|
|
];
|
|
|
|
return;
|
|
}
|
|
|
|
$capability = $this->resolveBootstrapCapability($nextOperationType);
|
|
|
|
if ($capability === null) {
|
|
throw new RuntimeException("Unsupported bootstrap operation type: {$nextOperationType}");
|
|
}
|
|
|
|
$startResult = app(ProviderOperationStartGate::class)->start(
|
|
tenant: $tenant,
|
|
connection: $connection,
|
|
operationType: $nextOperationType,
|
|
dispatcher: function (OperationRun $run) use ($tenant, $user, $connection, $nextOperationType): void {
|
|
$this->dispatchBootstrapJob(
|
|
operationType: $nextOperationType,
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) $user->getKey(),
|
|
providerConnectionId: (int) $connection->getKey(),
|
|
run: $run,
|
|
);
|
|
},
|
|
initiator: $user,
|
|
extraContext: [
|
|
'wizard' => [
|
|
'flow' => 'managed_tenant_onboarding',
|
|
'step' => 'bootstrap',
|
|
],
|
|
'required_capability' => $capability,
|
|
],
|
|
);
|
|
|
|
$state = $draft->state ?? [];
|
|
$existing = $state['bootstrap_operation_runs'] ?? [];
|
|
$existing = is_array($existing) ? $existing : [];
|
|
|
|
if ($startResult->status !== 'scope_busy') {
|
|
$existing[$nextOperationType] = (int) $startResult->run->getKey();
|
|
}
|
|
|
|
$state['bootstrap_operation_runs'] = $existing;
|
|
$state['bootstrap_operation_types'] = $types;
|
|
|
|
$draft->state = $state;
|
|
$draft->current_step = 'bootstrap';
|
|
|
|
$remainingTypes = array_values(array_filter(
|
|
$types,
|
|
fn (string $candidate): bool => $candidate !== $nextOperationType
|
|
&& ! $this->bootstrapOperationSucceeded($draft, $candidate, (int) $connection->getKey()),
|
|
));
|
|
|
|
$result = [
|
|
'status' => $startResult->status,
|
|
'start_result' => $startResult,
|
|
'operation_type' => $nextOperationType,
|
|
'run' => $startResult->run,
|
|
'remaining_types' => $remainingTypes,
|
|
];
|
|
},
|
|
));
|
|
} 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'] === 'already_completed') {
|
|
Notification::make()
|
|
->title('Bootstrap already completed')
|
|
->body('All selected bootstrap actions have already finished successfully for this provider connection.')
|
|
->info()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$operationType = (string) ($result['operation_type'] ?? '');
|
|
$startResult = $result['start_result'] ?? null;
|
|
$run = $result['run'] ?? null;
|
|
|
|
if (! $startResult instanceof \App\Services\Providers\ProviderOperationStartResult || ! $run instanceof OperationRun || $operationType === '') {
|
|
throw new RuntimeException('Bootstrap start did not return a canonical run result.');
|
|
}
|
|
|
|
$remainingTypes = is_array($result['remaining_types'] ?? null)
|
|
? array_values(array_filter($result['remaining_types'], static fn (mixed $value): bool => is_string($value) && $value !== ''))
|
|
: [];
|
|
|
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$auditStatus = match ($result['status']) {
|
|
'started' => 'success',
|
|
'deduped' => 'deduped',
|
|
'scope_busy' => 'blocked',
|
|
default => 'success',
|
|
};
|
|
|
|
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,
|
|
'started_operation_type' => $operationType,
|
|
'operation_run_id' => (int) $run->getKey(),
|
|
'result' => (string) $result['status'],
|
|
],
|
|
],
|
|
actor: $user,
|
|
status: $auditStatus,
|
|
resourceType: 'managed_tenant_onboarding_session',
|
|
resourceId: (string) $this->onboardingSession->getKey(),
|
|
);
|
|
}
|
|
|
|
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
|
|
result: $startResult,
|
|
blockedTitle: 'Bootstrap action blocked',
|
|
runUrl: $this->tenantlessOperationRunUrl((int) $run->getKey()),
|
|
scopeBusyTitle: 'Bootstrap action busy',
|
|
scopeBusyBody: $remainingTypes !== []
|
|
? 'Another provider-backed bootstrap action is already running for this provider connection. Open the active operation, then continue with the remaining bootstrap actions after it finishes.'
|
|
: 'Another provider-backed bootstrap action is already running for this provider connection. Open the active operation for progress and next steps.',
|
|
);
|
|
|
|
if (in_array($result['status'], ['started', 'deduped', 'scope_busy'], true)) {
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
}
|
|
|
|
$notification->send();
|
|
|
|
if ($remainingTypes !== [] && in_array($result['status'], ['started', 'deduped'], true)) {
|
|
Notification::make()
|
|
->title('Continue bootstrap after this run finishes')
|
|
->body(sprintf('%d additional bootstrap action(s) remain selected for this provider connection.', count($remainingTypes)))
|
|
->info()
|
|
->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,
|
|
)->afterCommit(),
|
|
'compliance.snapshot' => ProviderComplianceSnapshotJob::dispatch(
|
|
tenantId: $tenantId,
|
|
userId: $userId,
|
|
providerConnectionId: $providerConnectionId,
|
|
operationRun: $run,
|
|
)->afterCommit(),
|
|
default => throw new RuntimeException("Unsupported bootstrap operation type: {$operationType}"),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $types
|
|
*/
|
|
private function nextBootstrapOperationType(TenantOnboardingSession $draft, array $types, int $providerConnectionId): ?string
|
|
{
|
|
foreach ($types as $type) {
|
|
if (! $this->bootstrapOperationSucceeded($draft, $type, $providerConnectionId)) {
|
|
return $type;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function bootstrapOperationSucceeded(TenantOnboardingSession $draft, string $type, int $providerConnectionId): bool
|
|
{
|
|
$state = is_array($draft->state) ? $draft->state : [];
|
|
$runMap = $state['bootstrap_operation_runs'] ?? [];
|
|
|
|
if (! is_array($runMap)) {
|
|
return false;
|
|
}
|
|
|
|
$runId = $runMap[$type] ?? null;
|
|
|
|
if (! is_numeric($runId)) {
|
|
return false;
|
|
}
|
|
|
|
$run = OperationRun::query()->whereKey((int) $runId)->first();
|
|
|
|
if (! $run instanceof OperationRun) {
|
|
return false;
|
|
}
|
|
|
|
$context = is_array($run->context ?? null) ? $run->context : [];
|
|
$runProviderConnectionId = is_numeric($context['provider_connection_id'] ?? null)
|
|
? (int) $context['provider_connection_id']
|
|
: null;
|
|
|
|
if ($runProviderConnectionId !== $providerConnectionId) {
|
|
return false;
|
|
}
|
|
|
|
return $run->status === OperationRunStatus::Completed->value
|
|
&& $run->outcome === OperationRunOutcome::Succeeded->value;
|
|
}
|
|
|
|
private function resolveBootstrapCapability(string $operationType): ?string
|
|
{
|
|
return $this->supportedBootstrapCapabilities()[$operationType] ?? null;
|
|
}
|
|
|
|
private function canStartAnyBootstrap(): bool
|
|
{
|
|
foreach ($this->supportedBootstrapCapabilities() as $capability) {
|
|
if ($this->currentUserCan($capability)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* @param array{state: 'no_changes'|'changed', previous_report_id: int}|null $changeIndicator
|
|
*/
|
|
private function verificationPreviousRunUrl(?array $changeIndicator): ?string
|
|
{
|
|
if (! is_array($changeIndicator)) {
|
|
return null;
|
|
}
|
|
|
|
$previousRunId = $changeIndicator['previous_report_id'] ?? null;
|
|
|
|
if (! is_int($previousRunId)) {
|
|
return null;
|
|
}
|
|
|
|
$previousRun = OperationRun::query()->whereKey($previousRunId)->first();
|
|
|
|
if (! $previousRun instanceof OperationRun) {
|
|
return null;
|
|
}
|
|
|
|
if (! $this->canInspectOperationRun($previousRun)) {
|
|
return null;
|
|
}
|
|
|
|
return $this->tenantlessOperationRunUrl((int) $previousRun->getKey());
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* run: array<string, mixed>|null,
|
|
* runUrl: string|null,
|
|
* previousRunUrl: string|null,
|
|
* hasReport: bool
|
|
* }
|
|
*/
|
|
private function verificationTechnicalDetailsViewData(): array
|
|
{
|
|
$run = $this->verificationRun();
|
|
|
|
if (! $run instanceof OperationRun) {
|
|
return [
|
|
'run' => null,
|
|
'runUrl' => null,
|
|
'previousRunUrl' => null,
|
|
'hasReport' => false,
|
|
];
|
|
}
|
|
|
|
$report = VerificationReportViewer::report($run);
|
|
$changeIndicator = VerificationReportChangeIndicator::forRun($run);
|
|
$context = is_array($run->context ?? null) ? $run->context : [];
|
|
$targetScope = $context['target_scope'] ?? [];
|
|
$targetScope = is_array($targetScope) ? $targetScope : [];
|
|
|
|
return [
|
|
'run' => [
|
|
'id' => (int) $run->getKey(),
|
|
'type' => (string) $run->type,
|
|
'status' => (string) $run->status,
|
|
'outcome' => (string) $run->outcome,
|
|
'started_at' => $run->started_at?->toJSON(),
|
|
'updated_at' => $run->updated_at?->toJSON(),
|
|
'completed_at' => $run->completed_at?->toJSON(),
|
|
'target_scope' => $targetScope,
|
|
],
|
|
'runUrl' => $this->verificationRunUrl(),
|
|
'previousRunUrl' => $this->verificationPreviousRunUrl($changeIndicator),
|
|
'hasReport' => is_array($report),
|
|
];
|
|
}
|
|
|
|
private function canInspectOperationRun(OperationRun $run): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
return $user->can('view', $run);
|
|
}
|
|
|
|
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 $this->completionSummarySelectedBootstrapTypes() === []
|
|
? 'Skipped'
|
|
: 'Selected';
|
|
}
|
|
|
|
if ($this->completionSummaryBootstrapActionRequiredDetail() !== null) {
|
|
return 'Action required';
|
|
}
|
|
|
|
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
|
$runs = is_array($runs) ? $runs : [];
|
|
|
|
if ($runs !== []) {
|
|
return 'Started';
|
|
}
|
|
|
|
return $this->completionSummarySelectedBootstrapTypes() === []
|
|
? 'Skipped'
|
|
: 'Selected';
|
|
}
|
|
|
|
private function completionSummaryBootstrapDetail(): string
|
|
{
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$selectedTypes = $this->completionSummarySelectedBootstrapTypes();
|
|
|
|
return $selectedTypes === []
|
|
? 'No bootstrap actions selected'
|
|
: sprintf('%d action(s) selected', count($selectedTypes));
|
|
}
|
|
|
|
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
|
$runs = is_array($runs) ? $runs : [];
|
|
$selectedTypes = $this->completionSummarySelectedBootstrapTypes();
|
|
$actionRequiredDetail = $this->completionSummaryBootstrapActionRequiredDetail();
|
|
|
|
if ($selectedTypes === []) {
|
|
return 'No bootstrap actions selected';
|
|
}
|
|
|
|
if ($actionRequiredDetail !== null) {
|
|
return $actionRequiredDetail;
|
|
}
|
|
|
|
if ($runs === []) {
|
|
return sprintf('%d action(s) selected', count($selectedTypes));
|
|
}
|
|
|
|
if (count($runs) < count($selectedTypes)) {
|
|
return sprintf('%d of %d action(s) started', count($runs), count($selectedTypes));
|
|
}
|
|
|
|
return sprintf('%d action(s) started', count($runs));
|
|
}
|
|
|
|
private function completionSummaryBootstrapSummary(): string
|
|
{
|
|
return sprintf(
|
|
'%s - %s',
|
|
$this->completionSummaryBootstrapLabel(),
|
|
$this->completionSummaryBootstrapDetail(),
|
|
);
|
|
}
|
|
|
|
private function showCompletionSummaryBootstrapRecovery(): bool
|
|
{
|
|
return $this->completionSummaryBootstrapActionRequiredDetail() !== null;
|
|
}
|
|
|
|
private function completionSummaryBootstrapRecoveryMessage(): string
|
|
{
|
|
return 'Selected bootstrap actions must complete before activation. Return to Bootstrap to remove the selected actions and skip this optional step, or resolve the required permission and start the blocked action again.';
|
|
}
|
|
|
|
private function completionSummaryBootstrapColor(): string
|
|
{
|
|
return match ($this->completionSummaryBootstrapLabel()) {
|
|
'Action required' => 'warning',
|
|
'Started' => 'info',
|
|
'Selected' => 'warning',
|
|
default => 'gray',
|
|
};
|
|
}
|
|
|
|
private function completionSummaryBootstrapActionRequiredDetail(): ?string
|
|
{
|
|
$reasonCode = $this->completionSummaryBootstrapReasonCode();
|
|
|
|
if (! in_array($reasonCode, ['bootstrap_failed', 'bootstrap_partial_failure'], true)) {
|
|
return null;
|
|
}
|
|
|
|
$run = $this->completionSummaryBootstrapFailedRun();
|
|
|
|
if (! $run instanceof OperationRun) {
|
|
return $reasonCode === 'bootstrap_partial_failure'
|
|
? 'A bootstrap action needs attention'
|
|
: 'A bootstrap action failed';
|
|
}
|
|
|
|
$context = is_array($run->context ?? null) ? $run->context : [];
|
|
$operatorLabel = data_get($context, 'reason_translation.operator_label');
|
|
|
|
if (is_string($operatorLabel) && trim($operatorLabel) !== '') {
|
|
return trim($operatorLabel);
|
|
}
|
|
|
|
return match ($run->outcome) {
|
|
OperationRunOutcome::PartiallySucceeded->value => 'A bootstrap action needs attention',
|
|
OperationRunOutcome::Blocked->value => 'A bootstrap action was blocked',
|
|
default => 'A bootstrap action failed',
|
|
};
|
|
}
|
|
|
|
private function completionSummaryBootstrapReasonCode(): ?string
|
|
{
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return null;
|
|
}
|
|
|
|
$reasonCode = $this->lifecycleService()->snapshot($this->onboardingSession)['reason_code'] ?? null;
|
|
|
|
return is_string($reasonCode) ? $reasonCode : null;
|
|
}
|
|
|
|
private function completionSummaryBootstrapFailedRun(): ?OperationRun
|
|
{
|
|
return once(function (): ?OperationRun {
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return null;
|
|
}
|
|
|
|
$runMap = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
|
|
|
if (! is_array($runMap)) {
|
|
return null;
|
|
}
|
|
|
|
$runIds = array_values(array_filter(array_map(
|
|
static fn (mixed $value): ?int => is_numeric($value) ? (int) $value : null,
|
|
$runMap,
|
|
)));
|
|
|
|
if ($runIds === []) {
|
|
return null;
|
|
}
|
|
|
|
return OperationRun::query()
|
|
->whereIn('id', $runIds)
|
|
->where('status', OperationRunStatus::Completed->value)
|
|
->whereIn('outcome', [
|
|
OperationRunOutcome::Blocked->value,
|
|
OperationRunOutcome::Failed->value,
|
|
OperationRunOutcome::PartiallySucceeded->value,
|
|
])
|
|
->latest('id')
|
|
->first();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function completionSummarySelectedBootstrapTypes(): array
|
|
{
|
|
$selectedTypes = $this->data['bootstrap_operation_types'] ?? null;
|
|
|
|
if (is_array($selectedTypes)) {
|
|
$normalized = $this->normalizeBootstrapOperationTypes($selectedTypes);
|
|
|
|
if ($normalized !== []) {
|
|
return $normalized;
|
|
}
|
|
}
|
|
|
|
if ($this->selectedBootstrapOperationTypes !== []) {
|
|
return $this->normalizeBootstrapOperationTypes($this->selectedBootstrapOperationTypes);
|
|
}
|
|
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return [];
|
|
}
|
|
|
|
$persistedTypes = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
|
|
|
|
return is_array($persistedTypes)
|
|
? $this->normalizeBootstrapOperationTypes($persistedTypes)
|
|
: [];
|
|
}
|
|
|
|
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<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}>,
|
|
* delegated: array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}>
|
|
* },
|
|
* copy: array{application:string,delegated:string},
|
|
* actions: array<string, mixed>,
|
|
* 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 operation. 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<string, mixed> $state
|
|
* @return array<string, mixed>
|
|
*/
|
|
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' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'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' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'fields' => app(ProviderConnectionTargetScopeNormalizer::class)->auditFieldNames($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<string, string>
|
|
*/
|
|
private function bootstrapOperationOptions(): array
|
|
{
|
|
$registry = app(ProviderOperationRegistry::class);
|
|
$supportedTypes = array_keys($this->supportedBootstrapCapabilities());
|
|
|
|
return collect($registry->all())
|
|
->filter(fn (array $definition, string $type): bool => in_array($type, $supportedTypes, true))
|
|
->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;
|
|
}
|
|
}
|