TenantAtlas/app/Filament/Pages/Onboarding/TenantOnboardingWizard.php
2026-02-01 12:20:18 +01:00

864 lines
27 KiB
PHP

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