wip: save 069 onboarding wizard v2 worktree state
This commit is contained in:
parent
458a94c6e9
commit
21df2056f1
358
app/Filament/Pages/Onboarding/TenantOnboardingTaskBoard.php
Normal file
358
app/Filament/Pages/Onboarding/TenantOnboardingTaskBoard.php
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Onboarding;
|
||||||
|
|
||||||
|
use App\Jobs\Onboarding\OnboardingConnectionDiagnosticsJob;
|
||||||
|
use App\Jobs\Onboarding\OnboardingConsentStatusJob;
|
||||||
|
use App\Jobs\Onboarding\OnboardingInitialSyncJob;
|
||||||
|
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\OperationRunService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Onboarding\OnboardingFixHints;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskCatalog;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
class TenantOnboardingTaskBoard extends Page
|
||||||
|
{
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'onboarding/tasks';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Onboarding task board';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.onboarding.tenant-onboarding-task-board';
|
||||||
|
|
||||||
|
public ?OnboardingSession $session = null;
|
||||||
|
|
||||||
|
public bool $canStartProviderTasks = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
public array $runUrls = [];
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
$activeSession = OnboardingSession::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->whereIn('status', ['draft', 'in_progress'])
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $activeSession instanceof OnboardingSession) {
|
||||||
|
$this->redirect(TenantOnboardingWizard::getUrl(tenant: $tenant));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->session = $activeSession;
|
||||||
|
|
||||||
|
if ($activeSession->current_step < 4) {
|
||||||
|
$this->redirect(TenantOnboardingWizard::getUrl(tenant: $tenant));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, OnboardingEvidence>
|
||||||
|
*/
|
||||||
|
public function latestEvidenceByTaskType(): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $byTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{
|
||||||
|
* task_type: string,
|
||||||
|
* title: string,
|
||||||
|
* step: int,
|
||||||
|
* prerequisites: array<int, string>,
|
||||||
|
* status: string,
|
||||||
|
* badge: \App\Support\Badges\BadgeSpec,
|
||||||
|
* evidence: OnboardingEvidence|null,
|
||||||
|
* prerequisites_met: bool,
|
||||||
|
* unmet_prerequisites: array<int, string>,
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function taskRows(): array
|
||||||
|
{
|
||||||
|
$statuses = $this->latestEvidenceStatusByTaskType();
|
||||||
|
$evidenceByTask = $this->latestEvidenceByTaskType();
|
||||||
|
|
||||||
|
return collect(OnboardingTaskCatalog::all())
|
||||||
|
->map(function (array $task) use ($statuses, $evidenceByTask): array {
|
||||||
|
$taskType = $task['task_type'];
|
||||||
|
$status = $statuses[$taskType] ?? 'unknown';
|
||||||
|
|
||||||
|
$unmet = OnboardingTaskCatalog::unmetPrerequisites($taskType, $statuses);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'task_type' => $taskType,
|
||||||
|
'title' => $task['title'],
|
||||||
|
'step' => $task['step'],
|
||||||
|
'prerequisites' => $task['prerequisites'],
|
||||||
|
'status' => $status,
|
||||||
|
'badge' => BadgeCatalog::spec(BadgeDomain::OnboardingTaskStatus, $status),
|
||||||
|
'evidence' => $evidenceByTask[$taskType] ?? null,
|
||||||
|
'prerequisites_met' => count($unmet) === 0,
|
||||||
|
'unmet_prerequisites' => $unmet,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function fixHintsFor(?string $reasonCode): array
|
||||||
|
{
|
||||||
|
return OnboardingFixHints::forReason($reasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{
|
||||||
|
* recorded_at: string,
|
||||||
|
* task_type: string,
|
||||||
|
* status: string,
|
||||||
|
* badge: \App\Support\Badges\BadgeSpec,
|
||||||
|
* reason_code: string|null,
|
||||||
|
* message: string|null,
|
||||||
|
* run_url: string|null,
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function recentEvidenceRows(int $limit = 20): array
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$evidence = OnboardingEvidence::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->orderByDesc('recorded_at')
|
||||||
|
->limit($limit)
|
||||||
|
->with('operationRun')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $evidence
|
||||||
|
->map(function (OnboardingEvidence $row) use ($tenant): array {
|
||||||
|
$runUrl = null;
|
||||||
|
|
||||||
|
if ($row->operationRun) {
|
||||||
|
$runUrl = OperationRunLinks::view($row->operationRun, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'recorded_at' => $row->recorded_at?->toDateTimeString() ?? '',
|
||||||
|
'task_type' => $row->task_type,
|
||||||
|
'status' => $row->status,
|
||||||
|
'badge' => BadgeCatalog::spec(BadgeDomain::OnboardingTaskStatus, $row->status),
|
||||||
|
'reason_code' => $row->reason_code,
|
||||||
|
'message' => $row->message,
|
||||||
|
'run_url' => $runUrl,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startTask(string $taskType): 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->session->current_step < 4) {
|
||||||
|
$this->redirect(TenantOnboardingWizard::getUrl(tenant: $tenant));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($taskType, OnboardingTaskType::all(), true)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Unknown task')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestStatuses = $this->latestEvidenceStatusByTaskType();
|
||||||
|
|
||||||
|
if (! OnboardingTaskCatalog::prerequisitesMet($taskType, $latestStatuses)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Prerequisites not met')
|
||||||
|
->body('Complete required tasks 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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->runUrls[$taskType] = OperationRunLinks::view($run, $tenant);
|
||||||
|
|
||||||
|
if (! $run->wasRecentlyCreated) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Task already queued')
|
||||||
|
->body('A run is already queued or running. Use the link to monitor progress.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ($taskType) {
|
||||||
|
OnboardingTaskType::VerifyPermissions => OnboardingVerifyPermissionsJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
onboardingSessionId: (int) $this->session->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
),
|
||||||
|
OnboardingTaskType::ConsentStatus => OnboardingConsentStatusJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
onboardingSessionId: (int) $this->session->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
),
|
||||||
|
OnboardingTaskType::ConnectionDiagnostics => OnboardingConnectionDiagnosticsJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
onboardingSessionId: (int) $this->session->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
),
|
||||||
|
OnboardingTaskType::InitialSync => OnboardingInitialSyncJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
onboardingSessionId: (int) $this->session->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Task queued')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
863
app/Filament/Pages/Onboarding/TenantOnboardingWizard.php
Normal file
863
app/Filament/Pages/Onboarding/TenantOnboardingWizard.php
Normal file
@ -0,0 +1,863 @@
|
|||||||
|
<?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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||||
@ -116,6 +117,18 @@ protected function getHeaderActions(): array
|
|||||||
->visible(false),
|
->visible(false),
|
||||||
|
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('resume_onboarding')
|
||||||
|
->label('Resume onboarding')
|
||||||
|
->icon('heroicon-o-play')
|
||||||
|
->color('gray')
|
||||||
|
->url(fn (): string => TenantOnboardingWizard::getUrl(tenant: Tenant::current()))
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_VIEW)
|
||||||
|
->tooltip('You do not have permission to view provider onboarding.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Action::make('view_last_check_run')
|
Action::make('view_last_check_run')
|
||||||
->label('View last check run')
|
->label('View last check run')
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
@ -10,6 +11,25 @@ class CreateTenant extends CreateRecord
|
|||||||
{
|
{
|
||||||
protected static string $resource = TenantResource::class;
|
protected static string $resource = TenantResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent setting legacy tenant credentials during create.
|
||||||
|
* Credential setup should happen via the onboarding flow.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
unset(
|
||||||
|
$data['app_client_id'],
|
||||||
|
$data['app_client_secret'],
|
||||||
|
$data['app_certificate_thumbprint'],
|
||||||
|
$data['app_notes'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
protected function afterCreate(): void
|
protected function afterCreate(): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -22,4 +42,9 @@ protected function afterCreate(): void
|
|||||||
$this->record->getKey() => ['role' => 'owner'],
|
$this->record->getKey() => ['role' => 'owner'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function getRedirectUrl(): string
|
||||||
|
{
|
||||||
|
return TenantOnboardingWizard::getUrl(tenant: $this->record);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -30,6 +31,17 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('resume_onboarding')
|
||||||
|
->label('Resume onboarding')
|
||||||
|
->icon('heroicon-o-play')
|
||||||
|
->color('gray')
|
||||||
|
->url(fn (Tenant $record): string => TenantOnboardingWizard::getUrl(tenant: $record))
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_VIEW)
|
||||||
|
->tooltip('You do not have permission to view provider onboarding.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('edit')
|
Actions\Action::make('edit')
|
||||||
->label('Edit')
|
->label('Edit')
|
||||||
|
|||||||
142
app/Jobs/Onboarding/OnboardingConnectionDiagnosticsJob.php
Normal file
142
app/Jobs/Onboarding/OnboardingConnectionDiagnosticsJob.php
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs\Onboarding;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Onboarding\OnboardingEvidenceWriter;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class OnboardingConnectionDiagnosticsJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $tenantId,
|
||||||
|
public int $userId,
|
||||||
|
public int $providerConnectionId,
|
||||||
|
public int $onboardingSessionId,
|
||||||
|
?OperationRun $operationRun = null,
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, object>
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(OnboardingEvidenceWriter $evidence, OperationRunService $runs): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::query()->find($this->tenantId);
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new RuntimeException('Tenant not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::query()->find($this->userId);
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
throw new RuntimeException('User not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = OnboardingSession::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->onboardingSessionId);
|
||||||
|
|
||||||
|
if (! $session instanceof OnboardingSession) {
|
||||||
|
throw new RuntimeException('OnboardingSession not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->providerConnectionId);
|
||||||
|
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
throw new RuntimeException('ProviderConnection not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) ($connection->status ?? 'unknown');
|
||||||
|
$health = (string) ($connection->health_status ?? 'unknown');
|
||||||
|
|
||||||
|
$evidenceStatus = 'unknown';
|
||||||
|
$reasonCode = null;
|
||||||
|
$message = 'No health check data available yet.';
|
||||||
|
|
||||||
|
if ($status !== 'connected') {
|
||||||
|
$evidenceStatus = 'blocked';
|
||||||
|
$reasonCode = 'provider.needs_consent';
|
||||||
|
$message = 'Provider connection is not connected. Admin consent may be required.';
|
||||||
|
} elseif ($health === 'healthy') {
|
||||||
|
$evidenceStatus = 'ok';
|
||||||
|
$message = 'Provider connection appears healthy.';
|
||||||
|
} elseif ($health === 'unhealthy') {
|
||||||
|
$evidenceStatus = 'error';
|
||||||
|
$reasonCode = is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : 'provider.outage';
|
||||||
|
$message = is_string($connection->last_error_message) && trim($connection->last_error_message) !== ''
|
||||||
|
? $connection->last_error_message
|
||||||
|
: 'Provider connection health check indicates an error.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$evidence->record(
|
||||||
|
tenant: $tenant,
|
||||||
|
taskType: OnboardingTaskType::ConnectionDiagnostics,
|
||||||
|
status: $evidenceStatus,
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
message: $message,
|
||||||
|
payload: [
|
||||||
|
'status' => $status,
|
||||||
|
'health_status' => $health,
|
||||||
|
'last_health_check_at' => $connection->last_health_check_at?->toIso8601String(),
|
||||||
|
'last_error_reason_code' => $connection->last_error_reason_code,
|
||||||
|
],
|
||||||
|
session: $session,
|
||||||
|
providerConnection: $connection,
|
||||||
|
operationRun: $this->operationRun,
|
||||||
|
recordedBy: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($evidenceStatus === 'ok') {
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
failures: [[
|
||||||
|
'code' => 'onboarding.connection.diagnostics.failed',
|
||||||
|
'reason_code' => $reasonCode ?? 'connection.diagnostics.unknown',
|
||||||
|
'message' => $message,
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
134
app/Jobs/Onboarding/OnboardingConsentStatusJob.php
Normal file
134
app/Jobs/Onboarding/OnboardingConsentStatusJob.php
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs\Onboarding;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Onboarding\OnboardingEvidenceWriter;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class OnboardingConsentStatusJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $tenantId,
|
||||||
|
public int $userId,
|
||||||
|
public int $providerConnectionId,
|
||||||
|
public int $onboardingSessionId,
|
||||||
|
?OperationRun $operationRun = null,
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, object>
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
OnboardingEvidenceWriter $evidence,
|
||||||
|
OperationRunService $runs,
|
||||||
|
): void {
|
||||||
|
$tenant = Tenant::query()->find($this->tenantId);
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new RuntimeException('Tenant not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::query()->find($this->userId);
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
throw new RuntimeException('User not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = OnboardingSession::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->onboardingSessionId);
|
||||||
|
|
||||||
|
if (! $session instanceof OnboardingSession) {
|
||||||
|
throw new RuntimeException('OnboardingSession not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->providerConnectionId);
|
||||||
|
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
throw new RuntimeException('ProviderConnection not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) ($connection->status ?? 'unknown');
|
||||||
|
|
||||||
|
$evidenceStatus = match ($status) {
|
||||||
|
'connected' => 'ok',
|
||||||
|
'needs_consent' => 'blocked',
|
||||||
|
default => 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
$message = match ($status) {
|
||||||
|
'connected' => 'Consent appears granted (connection is connected).',
|
||||||
|
'needs_consent' => 'Consent is missing or credentials are not authorized yet.',
|
||||||
|
default => 'Unable to determine consent status.',
|
||||||
|
};
|
||||||
|
|
||||||
|
$evidence->record(
|
||||||
|
tenant: $tenant,
|
||||||
|
taskType: OnboardingTaskType::ConsentStatus,
|
||||||
|
status: $evidenceStatus,
|
||||||
|
reasonCode: $status === 'needs_consent' ? 'consent.missing' : null,
|
||||||
|
message: $message,
|
||||||
|
payload: [
|
||||||
|
'provider_connection_status' => $status,
|
||||||
|
'provider_connection_health_status' => $connection->health_status,
|
||||||
|
],
|
||||||
|
session: $session,
|
||||||
|
providerConnection: $connection,
|
||||||
|
operationRun: $this->operationRun,
|
||||||
|
recordedBy: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($evidenceStatus === 'ok') {
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
failures: [[
|
||||||
|
'code' => 'onboarding.consent.status.failed',
|
||||||
|
'reason_code' => $status === 'needs_consent' ? 'consent.missing' : 'consent.status.error',
|
||||||
|
'message' => $message,
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
app/Jobs/Onboarding/OnboardingInitialSyncJob.php
Normal file
125
app/Jobs/Onboarding/OnboardingInitialSyncJob.php
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs\Onboarding;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Onboarding\OnboardingEvidenceWriter;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class OnboardingInitialSyncJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $tenantId,
|
||||||
|
public int $userId,
|
||||||
|
public int $providerConnectionId,
|
||||||
|
public int $onboardingSessionId,
|
||||||
|
?OperationRun $operationRun = null,
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, object>
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(OnboardingEvidenceWriter $evidence, OperationRunService $runs): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::query()->find($this->tenantId);
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new RuntimeException('Tenant not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::query()->find($this->userId);
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
throw new RuntimeException('User not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = OnboardingSession::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->onboardingSessionId);
|
||||||
|
|
||||||
|
if (! $session instanceof OnboardingSession) {
|
||||||
|
throw new RuntimeException('OnboardingSession not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->providerConnectionId);
|
||||||
|
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
throw new RuntimeException('ProviderConnection not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$connected = (string) ($connection->status ?? 'unknown') === 'connected';
|
||||||
|
|
||||||
|
$evidenceStatus = $connected ? 'ok' : 'blocked';
|
||||||
|
$reasonCode = $connected ? null : 'provider.not_connected';
|
||||||
|
$message = $connected
|
||||||
|
? 'Prerequisites for initial sync look good.'
|
||||||
|
: 'Provider connection is not connected. Resolve consent/credentials first.';
|
||||||
|
|
||||||
|
$evidence->record(
|
||||||
|
tenant: $tenant,
|
||||||
|
taskType: OnboardingTaskType::InitialSync,
|
||||||
|
status: $evidenceStatus,
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
message: $message,
|
||||||
|
payload: [
|
||||||
|
'provider_connection_status' => $connection->status,
|
||||||
|
],
|
||||||
|
session: $session,
|
||||||
|
providerConnection: $connection,
|
||||||
|
operationRun: $this->operationRun,
|
||||||
|
recordedBy: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($evidenceStatus === 'ok') {
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
failures: [[
|
||||||
|
'code' => 'onboarding.initial_sync.blocked',
|
||||||
|
'reason_code' => $reasonCode ?? 'initial_sync.unknown',
|
||||||
|
'message' => $message,
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
app/Jobs/Onboarding/OnboardingVerifyPermissionsJob.php
Normal file
140
app/Jobs/Onboarding/OnboardingVerifyPermissionsJob.php
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs\Onboarding;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Intune\TenantPermissionService;
|
||||||
|
use App\Services\Onboarding\OnboardingEvidenceWriter;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class OnboardingVerifyPermissionsJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $tenantId,
|
||||||
|
public int $userId,
|
||||||
|
public int $providerConnectionId,
|
||||||
|
public int $onboardingSessionId,
|
||||||
|
?OperationRun $operationRun = null,
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, object>
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
TenantPermissionService $permissions,
|
||||||
|
OnboardingEvidenceWriter $evidence,
|
||||||
|
OperationRunService $runs,
|
||||||
|
): void {
|
||||||
|
$tenant = Tenant::query()->find($this->tenantId);
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new RuntimeException('Tenant not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::query()->find($this->userId);
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
throw new RuntimeException('User not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = OnboardingSession::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->onboardingSessionId);
|
||||||
|
|
||||||
|
if (! $session instanceof OnboardingSession) {
|
||||||
|
throw new RuntimeException('OnboardingSession not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->providerConnectionId);
|
||||||
|
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
throw new RuntimeException('ProviderConnection not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// For onboarding, we default to a safe, non-live permission comparison.
|
||||||
|
// Live Graph calls can be enabled later as a deliberate UX and contract decision.
|
||||||
|
$result = $permissions->compare($tenant, persist: true, liveCheck: false, useConfiguredStub: true);
|
||||||
|
|
||||||
|
$overall = $result['overall_status'] ?? 'error';
|
||||||
|
|
||||||
|
$evidenceStatus = match ($overall) {
|
||||||
|
'granted' => 'ok',
|
||||||
|
'missing' => 'blocked',
|
||||||
|
default => 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
$message = match ($overall) {
|
||||||
|
'granted' => 'All required permissions appear granted.',
|
||||||
|
'missing' => 'Some required permissions are missing.',
|
||||||
|
default => 'Unable to verify permissions.',
|
||||||
|
};
|
||||||
|
|
||||||
|
$evidence->record(
|
||||||
|
tenant: $tenant,
|
||||||
|
taskType: OnboardingTaskType::VerifyPermissions,
|
||||||
|
status: $evidenceStatus,
|
||||||
|
reasonCode: $overall === 'missing' ? 'permissions.missing' : null,
|
||||||
|
message: $message,
|
||||||
|
payload: [
|
||||||
|
'overall_status' => $overall,
|
||||||
|
'permissions' => $result['permissions'] ?? [],
|
||||||
|
],
|
||||||
|
session: $session,
|
||||||
|
providerConnection: $connection,
|
||||||
|
operationRun: $this->operationRun,
|
||||||
|
recordedBy: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($evidenceStatus === 'ok') {
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runs->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
failures: [[
|
||||||
|
'code' => 'onboarding.permissions.verify.failed',
|
||||||
|
'reason_code' => $overall === 'missing' ? 'permissions.missing' : 'permissions.verify.error',
|
||||||
|
'message' => $message,
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Models/OnboardingEvidence.php
Normal file
46
app/Models/OnboardingEvidence.php
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class OnboardingEvidence extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'onboarding_evidence';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'payload' => 'array',
|
||||||
|
'recorded_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onboardingSession(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(OnboardingSession::class, 'onboarding_session_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function providerConnection(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProviderConnection::class, 'provider_connection_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function operationRun(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(OperationRun::class, 'operation_run_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordedBy(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'recorded_by_user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Models/OnboardingSession.php
Normal file
47
app/Models/OnboardingSession.php
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class OnboardingSession extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'current_step' => 'integer',
|
||||||
|
'locked_until' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
'metadata' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function providerConnection(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProviderConnection::class, 'provider_connection_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assignedTo(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'assigned_to_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lockedBy(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'locked_by_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function evidence(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(OnboardingEvidence::class, 'onboarding_session_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Policies/OnboardingEvidencePolicy.php
Normal file
58
app/Policies/OnboardingEvidencePolicy.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\OnboardingEvidence;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
use Illuminate\Auth\Access\Response;
|
||||||
|
|
||||||
|
class OnboardingEvidencePolicy
|
||||||
|
{
|
||||||
|
use HandlesAuthorization;
|
||||||
|
|
||||||
|
public function viewAny(User $user): Response|bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)
|
||||||
|
? true
|
||||||
|
: Response::deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, OnboardingEvidence $evidence): Response|bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $evidence->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)
|
||||||
|
? true
|
||||||
|
: Response::deny();
|
||||||
|
}
|
||||||
|
}
|
||||||
100
app/Policies/OnboardingSessionPolicy.php
Normal file
100
app/Policies/OnboardingSessionPolicy.php
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
use Illuminate\Auth\Access\Response;
|
||||||
|
|
||||||
|
class OnboardingSessionPolicy
|
||||||
|
{
|
||||||
|
use HandlesAuthorization;
|
||||||
|
|
||||||
|
public function viewAny(User $user): Response|bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)
|
||||||
|
? true
|
||||||
|
: Response::deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, OnboardingSession $session): Response|bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $session->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)
|
||||||
|
? true
|
||||||
|
: Response::deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): Response|bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)
|
||||||
|
? true
|
||||||
|
: Response::deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, OnboardingSession $session): Response|bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $session->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)
|
||||||
|
? true
|
||||||
|
: Response::deny();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Models\OnboardingEvidence;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Policies\OnboardingEvidencePolicy;
|
||||||
|
use App\Policies\OnboardingSessionPolicy;
|
||||||
use App\Policies\ProviderConnectionPolicy;
|
use App\Policies\ProviderConnectionPolicy;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -17,6 +21,8 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
protected $policies = [
|
protected $policies = [
|
||||||
ProviderConnection::class => ProviderConnectionPolicy::class,
|
ProviderConnection::class => ProviderConnectionPolicy::class,
|
||||||
|
OnboardingSession::class => OnboardingSessionPolicy::class,
|
||||||
|
OnboardingEvidence::class => OnboardingEvidencePolicy::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
|
|||||||
@ -43,18 +43,18 @@ public function resolve(array $scopeTagIds, ?Tenant $tenant = null): array
|
|||||||
private function fetchAllScopeTags(?Tenant $tenant = null): array
|
private function fetchAllScopeTags(?Tenant $tenant = null): array
|
||||||
{
|
{
|
||||||
$cacheKey = $tenant ? "scope_tags:tenant:{$tenant->id}" : 'scope_tags:all';
|
$cacheKey = $tenant ? "scope_tags:tenant:{$tenant->id}" : 'scope_tags:all';
|
||||||
|
|
||||||
return Cache::remember($cacheKey, 3600, function () use ($tenant) {
|
return Cache::remember($cacheKey, 3600, function () use ($tenant) {
|
||||||
try {
|
try {
|
||||||
$options = ['query' => ['$select' => 'id,displayName']];
|
$options = ['query' => ['$select' => 'id,displayName']];
|
||||||
|
|
||||||
// Add tenant credentials if provided
|
// Add tenant credentials if provided
|
||||||
if ($tenant) {
|
if ($tenant) {
|
||||||
$options['tenant'] = $tenant->external_id ?? $tenant->tenant_id;
|
$options['tenant'] = $tenant->external_id ?? $tenant->tenant_id;
|
||||||
$options['client_id'] = $tenant->app_client_id;
|
$options['client_id'] = $tenant->app_client_id;
|
||||||
$options['client_secret'] = $tenant->app_client_secret;
|
$options['client_secret'] = $tenant->app_client_secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
$graphResponse = $this->graphClient->request(
|
$graphResponse = $this->graphClient->request(
|
||||||
'GET',
|
'GET',
|
||||||
'/deviceManagement/roleScopeTags',
|
'/deviceManagement/roleScopeTags',
|
||||||
|
|||||||
68
app/Services/Onboarding/LegacyTenantCredentialMigrator.php
Normal file
68
app/Services/Onboarding/LegacyTenantCredentialMigrator.php
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Onboarding;
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\ProviderCredential;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Providers\CredentialManager;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class LegacyTenantCredentialMigrator
|
||||||
|
{
|
||||||
|
public function __construct(private readonly CredentialManager $credentials) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{migrated: bool, message: string}
|
||||||
|
*/
|
||||||
|
public function migrate(Tenant $tenant, ProviderConnection $connection): array
|
||||||
|
{
|
||||||
|
if ((int) $connection->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
throw new InvalidArgumentException('Provider connection does not belong to the tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientId = trim((string) ($tenant->app_client_id ?? ''));
|
||||||
|
$clientSecret = trim((string) ($tenant->app_client_secret ?? ''));
|
||||||
|
|
||||||
|
if ($clientId === '' || $clientSecret === '') {
|
||||||
|
return [
|
||||||
|
'migrated' => false,
|
||||||
|
'message' => 'No legacy tenant credentials found to migrate.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $connection->credential;
|
||||||
|
|
||||||
|
if ($existing instanceof ProviderCredential) {
|
||||||
|
if ($existing->type !== 'client_secret') {
|
||||||
|
throw new RuntimeException('Provider connection has unsupported credential type.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $existing->payload;
|
||||||
|
$existingClientId = trim((string) Arr::get(is_array($payload) ? $payload : [], 'client_id'));
|
||||||
|
$existingClientSecret = trim((string) Arr::get(is_array($payload) ? $payload : [], 'client_secret'));
|
||||||
|
|
||||||
|
if ($existingClientId !== '' && $existingClientSecret !== '') {
|
||||||
|
return [
|
||||||
|
'migrated' => false,
|
||||||
|
'message' => 'Provider credentials already exist for this connection.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->credentials->upsertClientSecretCredential(
|
||||||
|
connection: $connection,
|
||||||
|
clientId: $clientId,
|
||||||
|
clientSecret: $clientSecret,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'migrated' => true,
|
||||||
|
'message' => 'Legacy tenant credentials migrated to the provider connection.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/Services/Onboarding/OnboardingEvidenceWriter.php
Normal file
94
app/Services/Onboarding/OnboardingEvidenceWriter.php
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Onboarding;
|
||||||
|
|
||||||
|
use App\Models\OnboardingEvidence;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
|
|
||||||
|
class OnboardingEvidenceWriter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function record(
|
||||||
|
Tenant $tenant,
|
||||||
|
string $taskType,
|
||||||
|
string $status,
|
||||||
|
?string $reasonCode = null,
|
||||||
|
?string $message = null,
|
||||||
|
array $payload = [],
|
||||||
|
?OnboardingSession $session = null,
|
||||||
|
?ProviderConnection $providerConnection = null,
|
||||||
|
?OperationRun $operationRun = null,
|
||||||
|
?User $recordedBy = null,
|
||||||
|
): OnboardingEvidence {
|
||||||
|
$reasonCode = $reasonCode === null ? null : RunFailureSanitizer::normalizeReasonCode($reasonCode);
|
||||||
|
$message = $message === null ? null : RunFailureSanitizer::sanitizeMessage($message);
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $payload */
|
||||||
|
$payload = $this->sanitizePayload($payload);
|
||||||
|
|
||||||
|
return OnboardingEvidence::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'onboarding_session_id' => $session?->getKey(),
|
||||||
|
'provider_connection_id' => $providerConnection?->getKey(),
|
||||||
|
'task_type' => $taskType,
|
||||||
|
'status' => $status,
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'message' => $message,
|
||||||
|
'payload' => $payload,
|
||||||
|
'operation_run_id' => $operationRun?->getKey(),
|
||||||
|
'recorded_at' => now(),
|
||||||
|
'recorded_by_user_id' => $recordedBy?->getKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function sanitizePayload(array $payload): array
|
||||||
|
{
|
||||||
|
$redactedKeys = ['access_token', 'refresh_token', 'client_secret', 'password', 'authorization', 'bearer'];
|
||||||
|
|
||||||
|
$sanitize = function (mixed $value) use (&$sanitize, $redactedKeys): mixed {
|
||||||
|
if (is_array($value)) {
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($value as $k => $v) {
|
||||||
|
$key = is_string($k) ? strtolower($k) : null;
|
||||||
|
|
||||||
|
if ($key !== null) {
|
||||||
|
foreach ($redactedKeys as $needle) {
|
||||||
|
if (str_contains($key, $needle)) {
|
||||||
|
$out[$k] = '[REDACTED]';
|
||||||
|
|
||||||
|
continue 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[$k] = $sanitize($v);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
return RunFailureSanitizer::sanitizeMessage($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $sanitized */
|
||||||
|
$sanitized = $sanitize($payload);
|
||||||
|
|
||||||
|
return $sanitized;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/Services/Onboarding/OnboardingLockService.php
Normal file
89
app/Services/Onboarding/OnboardingLockService.php
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Onboarding;
|
||||||
|
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class OnboardingLockService
|
||||||
|
{
|
||||||
|
public function acquire(OnboardingSession $session, User $user, int $ttlSeconds = 600): bool
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($session, $user, $ttlSeconds): bool {
|
||||||
|
$session = OnboardingSession::query()->lockForUpdate()->findOrFail($session->getKey());
|
||||||
|
|
||||||
|
if ($this->isLockedByOther($session, $user)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->forceFill([
|
||||||
|
'locked_by_user_id' => $user->getKey(),
|
||||||
|
'locked_until' => Carbon::now()->addSeconds($ttlSeconds),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function renew(OnboardingSession $session, User $user, int $ttlSeconds = 600): bool
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($session, $user, $ttlSeconds): bool {
|
||||||
|
$session = OnboardingSession::query()->lockForUpdate()->findOrFail($session->getKey());
|
||||||
|
|
||||||
|
if ((int) $session->locked_by_user_id !== (int) $user->getKey()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->forceFill([
|
||||||
|
'locked_until' => Carbon::now()->addSeconds($ttlSeconds),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function release(OnboardingSession $session, User $user): bool
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($session, $user): bool {
|
||||||
|
$session = OnboardingSession::query()->lockForUpdate()->findOrFail($session->getKey());
|
||||||
|
|
||||||
|
if ((int) $session->locked_by_user_id !== (int) $user->getKey()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->forceFill([
|
||||||
|
'locked_by_user_id' => null,
|
||||||
|
'locked_until' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function takeover(OnboardingSession $session, User $newOwner, int $ttlSeconds = 600): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($session, $newOwner, $ttlSeconds): void {
|
||||||
|
$session = OnboardingSession::query()->lockForUpdate()->findOrFail($session->getKey());
|
||||||
|
|
||||||
|
$session->forceFill([
|
||||||
|
'locked_by_user_id' => $newOwner->getKey(),
|
||||||
|
'locked_until' => Carbon::now()->addSeconds($ttlSeconds),
|
||||||
|
])->save();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -44,9 +44,7 @@ class UiEnforcement
|
|||||||
*/
|
*/
|
||||||
private ?\Closure $bulkPreflight = null;
|
private ?\Closure $bulkPreflight = null;
|
||||||
|
|
||||||
public function __construct(private string $capability)
|
public function __construct(private string $capability) {}
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function for(string $capability): self
|
public static function for(string $capability): self
|
||||||
{
|
{
|
||||||
@ -418,6 +416,7 @@ private function resolveTenantIdsForRecords(Collection $records): array
|
|||||||
|
|
||||||
if ($resolved instanceof Tenant) {
|
if ($resolved instanceof Tenant) {
|
||||||
$ids[] = (int) $resolved->getKey();
|
$ids[] = (int) $resolved->getKey();
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,4 +11,3 @@ public static function insufficientPermission(): string
|
|||||||
return self::INSUFFICIENT_PERMISSION_ASK_OWNER;
|
return self::INSUFFICIENT_PERMISSION_ASK_OWNER;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,7 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
|
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
|
||||||
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
|
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
|
||||||
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
||||||
|
BadgeDomain::OnboardingTaskStatus->value => Domains\OnboardingTaskStatusBadge::class,
|
||||||
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
|
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
|
||||||
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
|
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -26,6 +26,7 @@ enum BadgeDomain: string
|
|||||||
case IgnoredAt = 'ignored_at';
|
case IgnoredAt = 'ignored_at';
|
||||||
case RestorePreviewDecision = 'restore_preview_decision';
|
case RestorePreviewDecision = 'restore_preview_decision';
|
||||||
case RestoreResultStatus = 'restore_result_status';
|
case RestoreResultStatus = 'restore_result_status';
|
||||||
|
case OnboardingTaskStatus = 'onboarding_task.status';
|
||||||
case ProviderConnectionStatus = 'provider_connection.status';
|
case ProviderConnectionStatus = 'provider_connection.status';
|
||||||
case ProviderConnectionHealth = 'provider_connection.health';
|
case ProviderConnectionHealth = 'provider_connection.health';
|
||||||
}
|
}
|
||||||
|
|||||||
23
app/Support/Badges/Domains/OnboardingTaskStatusBadge.php
Normal file
23
app/Support/Badges/Domains/OnboardingTaskStatusBadge.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
|
||||||
|
final class OnboardingTaskStatusBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'ok' => new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'),
|
||||||
|
'warn' => new BadgeSpec('Warning', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||||
|
'fail' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||||
|
'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Support/Onboarding/OnboardingFixHints.php
Normal file
54
app/Support/Onboarding/OnboardingFixHints.php
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Onboarding;
|
||||||
|
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
|
|
||||||
|
final class OnboardingFixHints
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function forReason(?string $reasonCode): array
|
||||||
|
{
|
||||||
|
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = RunFailureSanitizer::normalizeReasonCode($reasonCode);
|
||||||
|
|
||||||
|
return match ($normalized) {
|
||||||
|
RunFailureSanitizer::REASON_PERMISSION_DENIED => [
|
||||||
|
'Confirm admin consent is granted for all required Microsoft Graph permissions.',
|
||||||
|
'Verify the Azure app registration has the correct API permissions assigned.',
|
||||||
|
'Re-run “Verify permissions” after updating consent/permissions.',
|
||||||
|
],
|
||||||
|
RunFailureSanitizer::REASON_PROVIDER_AUTH_FAILED => [
|
||||||
|
'Confirm the client secret is valid and not expired.',
|
||||||
|
'Verify the tenant ID and client ID are correct for this connection.',
|
||||||
|
'Re-save credentials and re-run the task.',
|
||||||
|
],
|
||||||
|
RunFailureSanitizer::REASON_GRAPH_THROTTLED, RunFailureSanitizer::REASON_GRAPH_TIMEOUT => [
|
||||||
|
'Wait a few minutes and try again (transient Graph errors are common).',
|
||||||
|
'Run the task during off-peak hours if throttling persists.',
|
||||||
|
],
|
||||||
|
RunFailureSanitizer::REASON_PROVIDER_OUTAGE => [
|
||||||
|
'Check Microsoft 365 service health / Graph status and try again later.',
|
||||||
|
],
|
||||||
|
RunFailureSanitizer::REASON_VALIDATION_ERROR => [
|
||||||
|
'Double-check the selected provider connection and tenant settings.',
|
||||||
|
'Review the error message for which input is invalid (no secrets are shown).',
|
||||||
|
],
|
||||||
|
RunFailureSanitizer::REASON_CONFLICT_DETECTED => [
|
||||||
|
'Review what changed in the tenant before rerunning.',
|
||||||
|
'If a conflicting configuration exists, resolve it and re-run the task.',
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'Retry the task and review the latest evidence message.',
|
||||||
|
'If the issue persists, ask an Owner to review tenant access and connection settings.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Support/Onboarding/OnboardingTaskCatalog.php
Normal file
86
app/Support/Onboarding/OnboardingTaskCatalog.php
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Onboarding;
|
||||||
|
|
||||||
|
final class OnboardingTaskCatalog
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, array{task_type: string, title: string, step: int, prerequisites: array<int, string>}>
|
||||||
|
*/
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'task_type' => OnboardingTaskType::VerifyPermissions,
|
||||||
|
'title' => 'Verify permissions',
|
||||||
|
'step' => 4,
|
||||||
|
'prerequisites' => [],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task_type' => OnboardingTaskType::ConsentStatus,
|
||||||
|
'title' => 'Check consent status',
|
||||||
|
'step' => 4,
|
||||||
|
'prerequisites' => [OnboardingTaskType::VerifyPermissions],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task_type' => OnboardingTaskType::ConnectionDiagnostics,
|
||||||
|
'title' => 'Run connection diagnostics',
|
||||||
|
'step' => 4,
|
||||||
|
'prerequisites' => [OnboardingTaskType::VerifyPermissions],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'task_type' => OnboardingTaskType::InitialSync,
|
||||||
|
'title' => 'Initial sync',
|
||||||
|
'step' => 5,
|
||||||
|
'prerequisites' => [OnboardingTaskType::VerifyPermissions],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{task_type: string, title: string, step: int, prerequisites: array<int, string>}|null
|
||||||
|
*/
|
||||||
|
public static function find(string $taskType): ?array
|
||||||
|
{
|
||||||
|
foreach (self::all() as $task) {
|
||||||
|
if ($task['task_type'] === $taskType) {
|
||||||
|
return $task;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $latestEvidenceStatusByTaskType
|
||||||
|
*/
|
||||||
|
public static function prerequisitesMet(string $taskType, array $latestEvidenceStatusByTaskType): bool
|
||||||
|
{
|
||||||
|
return count(self::unmetPrerequisites($taskType, $latestEvidenceStatusByTaskType)) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $latestEvidenceStatusByTaskType
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function unmetPrerequisites(string $taskType, array $latestEvidenceStatusByTaskType): array
|
||||||
|
{
|
||||||
|
$task = self::find($taskType);
|
||||||
|
|
||||||
|
if (! $task) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$unmet = [];
|
||||||
|
|
||||||
|
foreach ($task['prerequisites'] as $requiredTaskType) {
|
||||||
|
$status = $latestEvidenceStatusByTaskType[$requiredTaskType] ?? 'unknown';
|
||||||
|
|
||||||
|
if ($status !== 'ok') {
|
||||||
|
$unmet[] = $requiredTaskType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $unmet;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Support/Onboarding/OnboardingTaskType.php
Normal file
32
app/Support/Onboarding/OnboardingTaskType.php
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Onboarding;
|
||||||
|
|
||||||
|
final class OnboardingTaskType
|
||||||
|
{
|
||||||
|
public const string VerifyPermissions = 'onboarding.permissions.verify';
|
||||||
|
|
||||||
|
public const string ConsentStatus = 'onboarding.consent.status';
|
||||||
|
|
||||||
|
public const string ConnectionDiagnostics = 'onboarding.connection.diagnostics';
|
||||||
|
|
||||||
|
public const string InitialSync = 'onboarding.initial_sync';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::VerifyPermissions,
|
||||||
|
self::ConsentStatus,
|
||||||
|
self::ConnectionDiagnostics,
|
||||||
|
self::InitialSync,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isKnown(string $taskType): bool
|
||||||
|
{
|
||||||
|
return in_array($taskType, self::all(), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
database/factories/OnboardingEvidenceFactory.php
Normal file
32
database/factories/OnboardingEvidenceFactory.php
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\OnboardingEvidence;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<OnboardingEvidence>
|
||||||
|
*/
|
||||||
|
class OnboardingEvidenceFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = OnboardingEvidence::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenant_id' => Tenant::factory(),
|
||||||
|
'onboarding_session_id' => null,
|
||||||
|
'provider_connection_id' => null,
|
||||||
|
'task_type' => 'onboarding.unknown',
|
||||||
|
'status' => 'unknown',
|
||||||
|
'reason_code' => null,
|
||||||
|
'message' => null,
|
||||||
|
'payload' => [],
|
||||||
|
'operation_run_id' => null,
|
||||||
|
'recorded_at' => now(),
|
||||||
|
'recorded_by_user_id' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
database/factories/OnboardingSessionFactory.php
Normal file
30
database/factories/OnboardingSessionFactory.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<OnboardingSession>
|
||||||
|
*/
|
||||||
|
class OnboardingSessionFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = OnboardingSession::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenant_id' => Tenant::factory(),
|
||||||
|
'provider_connection_id' => null,
|
||||||
|
'status' => 'draft',
|
||||||
|
'current_step' => 1,
|
||||||
|
'assigned_to_user_id' => null,
|
||||||
|
'locked_by_user_id' => null,
|
||||||
|
'locked_until' => null,
|
||||||
|
'completed_at' => null,
|
||||||
|
'metadata' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,7 +16,7 @@ public function up(): void
|
|||||||
$table->json('scope_tags')->nullable()->after('assignments');
|
$table->json('scope_tags')->nullable()->after('assignments');
|
||||||
$table->string('assignments_hash', 64)->nullable()->after('scope_tags');
|
$table->string('assignments_hash', 64)->nullable()->after('scope_tags');
|
||||||
$table->string('scope_tags_hash', 64)->nullable()->after('assignments_hash');
|
$table->string('scope_tags_hash', 64)->nullable()->after('assignments_hash');
|
||||||
|
|
||||||
$table->index('assignments_hash');
|
$table->index('assignments_hash');
|
||||||
$table->index('scope_tags_hash');
|
$table->index('scope_tags_hash');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('onboarding_sessions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('provider_connection_id')->nullable()->constrained('provider_connections')->nullOnDelete();
|
||||||
|
|
||||||
|
$table->string('status')->default('draft');
|
||||||
|
$table->unsignedSmallInteger('current_step')->default(1);
|
||||||
|
|
||||||
|
$table->foreignId('assigned_to_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->foreignId('locked_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamp('locked_until')->nullable();
|
||||||
|
|
||||||
|
$table->timestamp('completed_at')->nullable();
|
||||||
|
$table->jsonb('metadata')->default('{}');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['tenant_id', 'status', 'created_at']);
|
||||||
|
$table->index(['tenant_id', 'provider_connection_id', 'created_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// At most one active session per tenant.
|
||||||
|
DB::statement("CREATE UNIQUE INDEX onboarding_sessions_active_unique ON onboarding_sessions (tenant_id) WHERE status IN ('draft', 'in_progress')");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::statement('DROP INDEX IF EXISTS onboarding_sessions_active_unique');
|
||||||
|
Schema::dropIfExists('onboarding_sessions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('onboarding_evidence', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('onboarding_session_id')->nullable()->constrained('onboarding_sessions')->nullOnDelete();
|
||||||
|
$table->foreignId('provider_connection_id')->nullable()->constrained('provider_connections')->nullOnDelete();
|
||||||
|
|
||||||
|
$table->string('task_type');
|
||||||
|
$table->string('status')->default('unknown');
|
||||||
|
$table->string('reason_code')->nullable();
|
||||||
|
$table->string('message')->nullable();
|
||||||
|
$table->jsonb('payload')->default('{}');
|
||||||
|
|
||||||
|
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
|
||||||
|
$table->timestamp('recorded_at');
|
||||||
|
$table->foreignId('recorded_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['tenant_id', 'task_type', 'recorded_at']);
|
||||||
|
$table->index(['tenant_id', 'task_type']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('onboarding_evidence');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -20,6 +20,7 @@
|
|||||||
<php>
|
<php>
|
||||||
<ini name="memory_limit" value="512M"/>
|
<ini name="memory_limit" value="512M"/>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
|
<env name="APP_KEY" value="base64:wk3t/HZYDiSsY6aH6NYb7EzMxZJSxVrMX9y4ojujBoU="/>
|
||||||
<env name="INTUNE_TENANT_ID" value="" force="true"/>
|
<env name="INTUNE_TENANT_ID" value="" force="true"/>
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
|
|||||||
@ -0,0 +1,169 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-gray-800">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Onboarding task board</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Run and re-run onboarding tasks. Status comes from stored evidence.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($session)
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Step {{ $session->current_step }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
@foreach ($this->taskRows() as $row)
|
||||||
|
@php
|
||||||
|
$taskType = $row['task_type'];
|
||||||
|
$badge = $row['badge'];
|
||||||
|
$evidence = $row['evidence'];
|
||||||
|
$unmet = $row['unmet_prerequisites'];
|
||||||
|
$disabled = ! $canStartProviderTasks || ! $row['prerequisites_met'];
|
||||||
|
$hints = $this->fixHintsFor($evidence?->reason_code);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-gray-800">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ $row['title'] }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $taskType }} · Step {{ $row['step'] }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (count($unmet) > 0)
|
||||||
|
<div class="mt-2 text-xs text-amber-800 dark:text-amber-200">
|
||||||
|
Blocked by prerequisites: {{ implode(', ', $unmet) }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end gap-3">
|
||||||
|
<x-filament::badge :color="$badge->color" size="sm">
|
||||||
|
{{ $badge->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="startTask('{{ $taskType }}')"
|
||||||
|
@disabled($disabled)
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:bg-gray-400 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white dark:disabled:bg-gray-700"
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (isset($runUrls[$taskType]) && is_string($runUrls[$taskType]) && $runUrls[$taskType] !== '')
|
||||||
|
<a
|
||||||
|
href="{{ $runUrls[$taskType] }}"
|
||||||
|
class="text-sm font-medium text-gray-900 underline underline-offset-4 hover:text-gray-700 dark:text-gray-100 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
View run
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($evidence)
|
||||||
|
<div class="mt-4 rounded-lg bg-gray-50 p-4 text-sm text-gray-800 dark:bg-gray-950 dark:text-gray-100">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Latest evidence</div>
|
||||||
|
|
||||||
|
@if (is_string($evidence->reason_code) && $evidence->reason_code !== '')
|
||||||
|
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
Reason: {{ $evidence->reason_code }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (is_string($evidence->message) && $evidence->message !== '')
|
||||||
|
<div>{{ $evidence->message }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (count($hints) > 0)
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Fix hints</div>
|
||||||
|
<ul class="mt-2 list-disc space-y-1 pl-5 text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
@foreach ($hints as $hint)
|
||||||
|
<li>{{ $hint }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-gray-800">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Evidence history</h2>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Recent onboarding evidence entries across all tasks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@php
|
||||||
|
$recent = $this->recentEvidenceRows();
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if (count($recent) === 0)
|
||||||
|
<div class="mt-4 text-sm text-gray-600 dark:text-gray-300">No evidence recorded yet.</div>
|
||||||
|
@else
|
||||||
|
<div class="mt-4 overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-800">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
<th class="py-2 pr-4">Recorded</th>
|
||||||
|
<th class="py-2 pr-4">Task</th>
|
||||||
|
<th class="py-2 pr-4">Status</th>
|
||||||
|
<th class="py-2 pr-4">Reason</th>
|
||||||
|
<th class="py-2">Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
@foreach ($recent as $row)
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 pr-4 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $row['recorded_at'] }}
|
||||||
|
</td>
|
||||||
|
<td class="py-3 pr-4 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $row['task_type'] }}
|
||||||
|
@if (is_string($row['run_url']) && $row['run_url'] !== '')
|
||||||
|
<div class="mt-1">
|
||||||
|
<a
|
||||||
|
href="{{ $row['run_url'] }}"
|
||||||
|
class="text-xs font-medium text-gray-900 underline underline-offset-4 hover:text-gray-700 dark:text-gray-100 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
View run
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="py-3 pr-4 whitespace-nowrap">
|
||||||
|
<x-filament::badge :color="$row['badge']->color" size="sm">
|
||||||
|
{{ $row['badge']->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 pr-4 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $row['reason_code'] ?? '' }}
|
||||||
|
</td>
|
||||||
|
<td class="py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||||
|
{{ $row['message'] ?? '' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament-panels::page>
|
||||||
@ -0,0 +1,211 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
<div class="space-y-6">
|
||||||
|
@if ($sessionLockedByOther)
|
||||||
|
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-950 dark:text-amber-100">
|
||||||
|
<div class="font-medium">Session locked</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
Onboarding is currently locked by {{ $sessionLockedByLabel ?? 'another user' }}
|
||||||
|
@if (is_string($sessionLockedUntil) && $sessionLockedUntil !== '')
|
||||||
|
(expires {{ $sessionLockedUntil }}).
|
||||||
|
@endif
|
||||||
|
You can view progress, but you can’t make changes unless you take over the lock.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@elseif ($hasSessionLock)
|
||||||
|
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900 dark:border-emerald-900/40 dark:bg-emerald-950 dark:text-emerald-100">
|
||||||
|
<div class="font-medium">You have the lock</div>
|
||||||
|
@if (is_string($sessionLockedUntil) && $sessionLockedUntil !== '')
|
||||||
|
<div class="mt-1">Expires {{ $sessionLockedUntil }}.</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-gray-800">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Onboarding plan</h2>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
This plan is shown before running any tasks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
@foreach ($this->planTasks() as $task)
|
||||||
|
<li class="flex items-start justify-between gap-6 py-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ $task['title'] }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Step {{ $task['step'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $task['task_type'] }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (! $canStartProviderTasks)
|
||||||
|
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-950 dark:text-amber-100">
|
||||||
|
<div class="font-medium">Missing permission</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
You can view onboarding, but running provider tasks requires additional permission.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($session?->current_step !== null && $session->current_step >= 4)
|
||||||
|
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-gray-800">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Consent</h2>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Ensure admin consent is granted for the required Microsoft Graph permissions before running tasks.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-900 dark:text-gray-100">Provider connection</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<select
|
||||||
|
wire:model.blur="selectedProviderConnectionId"
|
||||||
|
@disabled(! $canStartProviderTasks || $sessionLockedByOther)
|
||||||
|
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-gray-900 focus:outline-none focus:ring-0 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">Select a provider connection…</option>
|
||||||
|
@foreach ($this->providerConnections() as $connection)
|
||||||
|
<option value="{{ $connection['id'] }}">{{ $connection['label'] }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
This wizard never shows secrets. Credentials remain managed by the provider credential store.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<a
|
||||||
|
href="{{ $this->createProviderConnectionUrl() }}"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
|
||||||
|
>
|
||||||
|
Create provider connection
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@php
|
||||||
|
$statuses = $this->latestEvidenceStatusByTaskType();
|
||||||
|
$verifyStatus = $statuses[\App\Support\Onboarding\OnboardingTaskType::VerifyPermissions] ?? 'unknown';
|
||||||
|
$verifySpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OnboardingTaskStatus, $verifyStatus);
|
||||||
|
$consentStatus = $statuses[\App\Support\Onboarding\OnboardingTaskType::ConsentStatus] ?? 'unknown';
|
||||||
|
$consentSpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OnboardingTaskStatus, $consentStatus);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-gray-800">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Step 4 tasks</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">Run verification tasks and review evidence-driven status.</p>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<a
|
||||||
|
href="{{ \App\Filament\Pages\Onboarding\TenantOnboardingTaskBoard::getUrl(tenant: \App\Models\Tenant::current()) }}"
|
||||||
|
class="text-sm font-medium text-gray-900 underline underline-offset-4 hover:text-gray-700 dark:text-gray-100 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Open task board
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<x-filament::badge :color="$verifySpec->color" size="sm">
|
||||||
|
Verify permissions: {{ $verifySpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<x-filament::badge :color="$consentSpec->color" size="sm">
|
||||||
|
Consent status: {{ $consentSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="startVerifyPermissions"
|
||||||
|
@disabled(! $canStartProviderTasks || $sessionLockedByOther)
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:bg-gray-400 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white dark:disabled:bg-gray-700"
|
||||||
|
>
|
||||||
|
Verify permissions
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="startConsentStatus"
|
||||||
|
@disabled(! $canStartProviderTasks || $sessionLockedByOther)
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:bg-gray-400 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white dark:disabled:bg-gray-700"
|
||||||
|
>
|
||||||
|
Check consent status
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (is_string($verifyPermissionsRunUrl) && $verifyPermissionsRunUrl !== '')
|
||||||
|
<div class="mt-3">
|
||||||
|
<a
|
||||||
|
href="{{ $verifyPermissionsRunUrl }}"
|
||||||
|
class="text-sm font-medium text-gray-900 underline underline-offset-4 hover:text-gray-700 dark:text-gray-100 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
View run
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (is_string($consentStatusRunUrl) && $consentStatusRunUrl !== '')
|
||||||
|
<div class="mt-3">
|
||||||
|
<a
|
||||||
|
href="{{ $consentStatusRunUrl }}"
|
||||||
|
class="text-sm font-medium text-gray-900 underline underline-offset-4 hover:text-gray-700 dark:text-gray-100 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
View consent status run
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-gray-800">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Provider connection</h2>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Create or select a provider connection before starting tasks.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-900 dark:text-gray-100">Provider connection</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<select
|
||||||
|
wire:model.blur="selectedProviderConnectionId"
|
||||||
|
@disabled(! $canStartProviderTasks || $sessionLockedByOther)
|
||||||
|
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-gray-900 focus:outline-none focus:ring-0 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">Select a provider connection…</option>
|
||||||
|
@foreach ($this->providerConnections() as $connection)
|
||||||
|
<option value="{{ $connection['id'] }}">{{ $connection['label'] }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
This wizard never shows secrets. Credentials remain managed by the provider credential store.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<a
|
||||||
|
href="{{ $this->createProviderConnectionUrl() }}"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
|
||||||
|
>
|
||||||
|
Create provider connection
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-filament-panels::page>
|
||||||
@ -25,34 +25,38 @@ # Tasks: Managed Tenant Onboarding Wizard UI (v2) (069)
|
|||||||
|
|
||||||
## Phase 1: Setup (Shared Infrastructure)
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
- [ ] T001 Create onboarding feature folders `app/Filament/Pages/Onboarding/`, `resources/views/filament/pages/onboarding/`, `tests/Feature/Onboarding/`, `tests/Unit/Onboarding/`
|
- [X] T001 Create onboarding feature folders `app/Filament/Pages/Onboarding/`, `resources/views/filament/pages/onboarding/`, `tests/Feature/Onboarding/`, `tests/Unit/Onboarding/`
|
||||||
- [ ] T002 [P] Add a focused Pest test file scaffold for onboarding in `tests/Feature/Onboarding/OnboardingSmokeTest.php`
|
- [X] T002 [P] Add a focused Pest test file scaffold for onboarding in `tests/Feature/Onboarding/OnboardingSmokeTest.php`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 2: Foundational (Blocking Prerequisites)
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
- [ ] T003 Create onboarding sessions migration in `database/migrations/` (new `onboarding_sessions` table per `specs/069-tenant-onboarding-wizard-v2/data-model.md`)
|
- [X] T003 Create onboarding sessions migration in `database/migrations/` (new `onboarding_sessions` table per `specs/069-tenant-onboarding-wizard-v2/data-model.md`)
|
||||||
- [ ] T004 Create onboarding evidence migration in `database/migrations/` (new `onboarding_evidence` table per `specs/069-tenant-onboarding-wizard-v2/data-model.md`)
|
- [X] T004 Create onboarding evidence migration in `database/migrations/` (new `onboarding_evidence` table per `specs/069-tenant-onboarding-wizard-v2/data-model.md`)
|
||||||
- [ ] T005 [P] Create `OnboardingSession` model in `app/Models/OnboardingSession.php`
|
|
||||||
- [ ] T006 [P] Create `OnboardingEvidence` model in `app/Models/OnboardingEvidence.php`
|
|
||||||
- [ ] T007 [P] Add factories for onboarding models in `database/factories/OnboardingSessionFactory.php` and `database/factories/OnboardingEvidenceFactory.php`
|
|
||||||
- [ ] T008 [P] Add onboarding session policy in `app/Policies/OnboardingSessionPolicy.php` (404 vs 403 semantics, capability-based)
|
|
||||||
- [ ] T009 [P] Add onboarding evidence policy in `app/Policies/OnboardingEvidencePolicy.php` (view-only access, capability-based)
|
|
||||||
- [ ] T010 Register new policies in `app/Providers/AuthServiceProvider.php`
|
|
||||||
|
|
||||||
- [ ] T011 [P] Create task-type enum/keys in `app/Support/Onboarding/OnboardingTaskType.php` (stable `task_type` strings)
|
- [X] T005 [P] Create `OnboardingSession` model in `app/Models/OnboardingSession.php`
|
||||||
- [ ] T012 [P] Create task catalog in `app/Support/Onboarding/OnboardingTaskCatalog.php` (prereqs, evidence types, operation run type/job mapping)
|
- [X] T006 [P] Create `OnboardingEvidence` model in `app/Models/OnboardingEvidence.php`
|
||||||
- [ ] T013 [P] Create evidence writer service in `app/Services/Onboarding/OnboardingEvidenceWriter.php` (sanitization via `App\\Support\\OpsUx\\RunFailureSanitizer`)
|
- [X] T007 [P] Add factories for onboarding models in `database/factories/OnboardingSessionFactory.php` and `database/factories/OnboardingEvidenceFactory.php`
|
||||||
- [ ] T014 [P] Create onboarding lock service in `app/Services/Onboarding/OnboardingLockService.php` (lock acquire/renew/release + takeover)
|
- [X] T008 [P] Add onboarding session policy in `app/Policies/OnboardingSessionPolicy.php` (404 vs 403 semantics, capability-based)
|
||||||
|
- [X] T009 [P] Add onboarding evidence policy in `app/Policies/OnboardingEvidencePolicy.php` (view-only access, capability-based)
|
||||||
|
- [X] T010 Register new policies in `app/Providers/AuthServiceProvider.php`
|
||||||
|
|
||||||
- [ ] T015 [P] Add badge domain for onboarding task status in `app/Support/Badges/BadgeDomain.php`
|
|
||||||
- [ ] T016 [P] Add badge mapper for onboarding task status in `app/Support/Badges/Domains/OnboardingTaskStatusBadge.php`
|
|
||||||
- [ ] T017 Update badge catalog mapping in `app/Support/Badges/BadgeCatalog.php` for the new onboarding domain
|
|
||||||
- [ ] T018 [P] Add badge mapping unit tests in `tests/Unit/Badges/OnboardingBadgesTest.php`
|
|
||||||
|
|
||||||
- [ ] T019 [P] Add onboarding service tests for evidence sanitization in `tests/Unit/Onboarding/OnboardingEvidenceWriterTest.php`
|
- [X] T011 [P] Create task-type enum/keys in `app/Support/Onboarding/OnboardingTaskType.php` (stable `task_type` strings)
|
||||||
- [ ] T020 [P] Add onboarding lock behavior unit tests in `tests/Unit/Onboarding/OnboardingLockServiceTest.php`
|
- [X] T012 [P] Create task catalog in `app/Support/Onboarding/OnboardingTaskCatalog.php` (prereqs, evidence types, operation run type/job mapping)
|
||||||
|
- [X] T013 [P] Create evidence writer service in `app/Services/Onboarding/OnboardingEvidenceWriter.php` (sanitization via `App\\Support\\OpsUx\\RunFailureSanitizer`)
|
||||||
|
- [X] T014 [P] Create onboarding lock service in `app/Services/Onboarding/OnboardingLockService.php` (lock acquire/renew/release + takeover)
|
||||||
|
|
||||||
|
|
||||||
|
- [X] T015 [P] Add badge domain for onboarding task status in `app/Support/Badges/BadgeDomain.php`
|
||||||
|
- [X] T016 [P] Add badge mapper for onboarding task status in `app/Support/Badges/Domains/OnboardingTaskStatusBadge.php`
|
||||||
|
- [X] T017 Update badge catalog mapping in `app/Support/Badges/BadgeCatalog.php` for the new onboarding domain
|
||||||
|
- [X] T018 [P] Add badge mapping unit tests in `tests/Unit/Badges/OnboardingBadgesTest.php`
|
||||||
|
|
||||||
|
|
||||||
|
- [X] T019 [P] Add onboarding service tests for evidence sanitization in `tests/Unit/Onboarding/OnboardingEvidenceWriterTest.php`
|
||||||
|
- [X] T020 [P] Add onboarding lock behavior unit tests in `tests/Unit/Onboarding/OnboardingLockServiceTest.php`
|
||||||
|
|
||||||
**Checkpoint**: DB schema, models, policies, badge semantics, and core services exist.
|
**Checkpoint**: DB schema, models, policies, badge semantics, and core services exist.
|
||||||
|
|
||||||
@ -66,38 +70,41 @@ ## Phase 3: User Story 1 — Onboard a managed tenant with a provider connection
|
|||||||
|
|
||||||
### Tests (write first)
|
### Tests (write first)
|
||||||
|
|
||||||
- [ ] T021 [P] [US1] Feature test: Owner can create/resume onboarding session in `tests/Feature/Onboarding/OnboardingSessionLifecycleTest.php`
|
- [X] T021 [P] [US1] Feature test: Owner can create/resume onboarding session in `tests/Feature/Onboarding/OnboardingSessionLifecycleTest.php`
|
||||||
- [ ] T022 [P] [US1] Feature test: non-member is denied-as-not-found (404) in `tests/Feature/Onboarding/OnboardingAuthorizationTest.php`
|
- [X] T022 [P] [US1] Feature test: non-member is denied-as-not-found (404) in `tests/Feature/Onboarding/OnboardingAuthorizationTest.php`
|
||||||
- [ ] T023 [P] [US1] Feature test: readonly can view but cannot mutate in `tests/Feature/Onboarding/OnboardingReadonlyAccessTest.php`
|
- [X] T023 [P] [US1] Feature test: readonly can view but cannot mutate in `tests/Feature/Onboarding/OnboardingReadonlyAccessTest.php`
|
||||||
- [ ] T059 [P] [US1] Feature test: onboarding plan preview is shown before any task execution in `tests/Feature/Onboarding/OnboardingPlanPreviewTest.php`
|
- [X] T059 [P] [US1] Feature test: onboarding plan preview is shown before any task execution in `tests/Feature/Onboarding/OnboardingPlanPreviewTest.php`
|
||||||
- [ ] T060 [P] [US1] Feature test: duplicate onboarding/session handling navigates to resume/task board safely in `tests/Feature/Onboarding/OnboardingDuplicateHandlingTest.php`
|
- [X] T060 [P] [US1] Feature test: duplicate onboarding/session handling navigates to resume/task board safely in `tests/Feature/Onboarding/OnboardingDuplicateHandlingTest.php`
|
||||||
- [ ] T061 [P] [US1] Feature test: consent guidance is visible in Step 4 and is safe/sanitized in `tests/Feature/Onboarding/OnboardingConsentGuidanceTest.php`
|
- [X] T061 [P] [US1] Feature test: consent guidance is visible in Step 4 and is safe/sanitized in `tests/Feature/Onboarding/OnboardingConsentGuidanceTest.php`
|
||||||
- [ ] T062 [P] [US1] Feature test: role-aware guidance (capability required messaging) renders for tenant members in `tests/Feature/Onboarding/OnboardingRoleGuidanceTest.php`
|
- [X] T062 [P] [US1] Feature test: role-aware guidance (capability required messaging) renders for tenant members in `tests/Feature/Onboarding/OnboardingRoleGuidanceTest.php`
|
||||||
- [ ] T063 [P] [US1] Feature test: user can create a provider connection from onboarding flow (navigate + return) in `tests/Feature/Onboarding/OnboardingCreateProviderConnectionTest.php`
|
- [X] T063 [P] [US1] Feature test: user can create a provider connection from onboarding flow (navigate + return) in `tests/Feature/Onboarding/OnboardingCreateProviderConnectionTest.php`
|
||||||
|
|
||||||
### Implementation
|
### Implementation
|
||||||
|
|
||||||
- [ ] T024 [US1] Add onboarding wizard page in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (5 steps, evidence-driven status)
|
- [X] T024 [US1] Add onboarding wizard page in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (5 steps, evidence-driven status)
|
||||||
- [ ] T025 [US1] Add wizard Blade view in `resources/views/filament/pages/onboarding/tenant-onboarding-wizard.blade.php`
|
- [X] T025 [US1] Add wizard Blade view in `resources/views/filament/pages/onboarding/tenant-onboarding-wizard.blade.php`
|
||||||
|
|
||||||
- [ ] T064 [US1] Implement onboarding plan preview in early steps (Step 1/2) using `OnboardingTaskCatalog` (tasks + prerequisites) in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php`
|
- [X] T064 [US1] Implement onboarding plan preview in early steps (Step 1/2) using `OnboardingTaskCatalog` (tasks + prerequisites) in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php`
|
||||||
- [ ] T065 [US1] Implement duplicate onboarding/session handling: always resume active session; block conflicting session creation in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php`
|
- [X] T065 [US1] Implement duplicate onboarding/session handling: always resume active session; block conflicting session creation in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php`
|
||||||
|
|
||||||
- [ ] T026 [US1] Add “Resume onboarding” entry point on tenant view in `app/Filament/Resources/TenantResource/Pages/ViewTenant.php`
|
|
||||||
- [ ] T027 [US1] Add “Resume onboarding” entry point on provider connection pages in `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`
|
|
||||||
|
|
||||||
- [ ] T028 [US1] Implement provider connection selection/linking in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (uses tenant-scoped `ProviderConnection`, client_secret only)
|
- [X] T026 [US1] Add “Resume onboarding” entry point on tenant view in `app/Filament/Resources/TenantResource/Pages/ViewTenant.php`
|
||||||
- [ ] T029 [US1] Ensure secrets are never displayed by relying on existing Provider Credential patterns in `app/Services/Providers/CredentialManager.php` (wizard renders no secret fields)
|
- [X] T027 [US1] Add “Resume onboarding” entry point on provider connection pages in `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`
|
||||||
|
|
||||||
- [ ] T066 [US1] Add “Create provider connection” path inside onboarding (navigate to ProviderConnection create and return to onboarding) in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php`
|
- [X] T028 [US1] Implement provider connection selection/linking in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (uses tenant-scoped `ProviderConnection`, client_secret only)
|
||||||
- [ ] T067 [US1] Add consent guidance + optional “Check consent state” action in Step 4 in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (sanitized UX only; no secrets)
|
- [X] T029 [US1] Ensure secrets are never displayed by relying on existing Provider Credential patterns in `app/Services/Providers/CredentialManager.php` (wizard renders no secret fields)
|
||||||
|
|
||||||
- [ ] T030 [US1] Add “Verify permissions” onboarding task start action in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (enqueue-only, creates/reuses `OperationRun`)
|
- [X] T066 [US1] Add “Create provider connection” path inside onboarding (navigate to ProviderConnection create and return to onboarding) in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php`
|
||||||
- [ ] T031 [US1] Add onboarding verify-permissions job in `app/Jobs/Onboarding/OnboardingVerifyPermissionsJob.php` (writes `OnboardingEvidence` via `OnboardingEvidenceWriter`)
|
- [X] T067 [US1] Add consent guidance + optional “Check consent state” action in Step 4 in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (sanitized UX only; no secrets)
|
||||||
|
|
||||||
- [ ] T068 [US1] Add onboarding consent status job in `app/Jobs/Onboarding/OnboardingConsentStatusJob.php` (writes evidence)
|
|
||||||
|
|
||||||
- [ ] T032 [US1] Feature test: starting verify-permissions creates/reuses run + evidence in `tests/Feature/Onboarding/OnboardingVerifyPermissionsTaskTest.php`
|
- [X] T030 [US1] Add “Verify permissions” onboarding task start action in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (enqueue-only, creates/reuses `OperationRun`)
|
||||||
|
- [X] T031 [US1] Add onboarding verify-permissions job in `app/Jobs/Onboarding/OnboardingVerifyPermissionsJob.php` (writes `OnboardingEvidence` via `OnboardingEvidenceWriter`)
|
||||||
|
|
||||||
|
- [X] T068 [US1] Add onboarding consent status job in `app/Jobs/Onboarding/OnboardingConsentStatusJob.php` (writes evidence)
|
||||||
|
|
||||||
|
|
||||||
|
- [X] T032 [US1] Feature test: starting verify-permissions creates/reuses run + evidence in `tests/Feature/Onboarding/OnboardingVerifyPermissionsTaskTest.php`
|
||||||
|
|
||||||
**Checkpoint**: US1 usable as MVP.
|
**Checkpoint**: US1 usable as MVP.
|
||||||
|
|
||||||
@ -111,23 +118,27 @@ ## Phase 4: User Story 2 — Operate and recover using a task board (Priority: P
|
|||||||
|
|
||||||
### Tests (write first)
|
### Tests (write first)
|
||||||
|
|
||||||
- [ ] T033 [P] [US2] Feature test: task board visible starting step 4 in `tests/Feature/Onboarding/OnboardingTaskBoardVisibilityTest.php`
|
- [X] T033 [P] [US2] Feature test: task board visible starting step 4 in `tests/Feature/Onboarding/OnboardingTaskBoardVisibilityTest.php`
|
||||||
- [ ] T034 [P] [US2] Feature test: concurrency guard blocks second run in `tests/Feature/Onboarding/OnboardingTaskConcurrencyTest.php`
|
- [X] T034 [P] [US2] Feature test: concurrency guard blocks second run in `tests/Feature/Onboarding/OnboardingTaskConcurrencyTest.php`
|
||||||
- [ ] T035 [P] [US2] Feature test: failing task shows sanitized reason + hints in `tests/Feature/Onboarding/OnboardingFixHintsTest.php`
|
- [X] T035 [P] [US2] Feature test: failing task shows sanitized reason + hints in `tests/Feature/Onboarding/OnboardingFixHintsTest.php`
|
||||||
|
|
||||||
### Implementation
|
### Implementation
|
||||||
|
|
||||||
- [ ] T036 [US2] Add task board page in `app/Filament/Pages/Onboarding/TenantOnboardingTaskBoard.php` (lists catalog tasks + latest evidence)
|
|
||||||
- [ ] T037 [US2] Add task board Blade view in `resources/views/filament/pages/onboarding/tenant-onboarding-task-board.blade.php`
|
|
||||||
|
|
||||||
- [ ] T038 [US2] Implement “Start task” actions (enqueue-only) in `app/Filament/Pages/Onboarding/TenantOnboardingTaskBoard.php` using `app/Services/OperationRunService.php` identity `{tenant_id, task_type}`
|
- [X] T036 [US2] Add task board page in `app/Filament/Pages/Onboarding/TenantOnboardingTaskBoard.php` (lists catalog tasks + latest evidence)
|
||||||
- [ ] T039 [US2] Implement prerequisite evaluation + disabled actions in `app/Support/Onboarding/OnboardingTaskCatalog.php`
|
- [X] T037 [US2] Add task board Blade view in `resources/views/filament/pages/onboarding/tenant-onboarding-task-board.blade.php`
|
||||||
- [ ] T040 [US2] Implement fix-hints mapping from reason codes in `app/Support/Onboarding/OnboardingFixHints.php`
|
|
||||||
|
|
||||||
- [ ] T041 [US2] Add onboarding connection diagnostics job in `app/Jobs/Onboarding/OnboardingConnectionDiagnosticsJob.php` (writes evidence)
|
|
||||||
- [ ] T042 [US2] Add onboarding initial sync job in `app/Jobs/Onboarding/OnboardingInitialSyncJob.php` (writes evidence)
|
|
||||||
|
|
||||||
- [ ] T043 [US2] Ensure “View run” links use existing operation hub routing via `app/Support/OperationRunLinks.php`
|
- [X] T038 [US2] Implement “Start task” actions (enqueue-only) in `app/Filament/Pages/Onboarding/TenantOnboardingTaskBoard.php` using `app/Services/OperationRunService.php` identity `{tenant_id, task_type}`
|
||||||
|
- [X] T039 [US2] Implement prerequisite evaluation + disabled actions in `app/Support/Onboarding/OnboardingTaskCatalog.php`
|
||||||
|
- [X] T040 [US2] Implement fix-hints mapping from reason codes in `app/Support/Onboarding/OnboardingFixHints.php`
|
||||||
|
|
||||||
|
|
||||||
|
- [X] T041 [US2] Add onboarding connection diagnostics job in `app/Jobs/Onboarding/OnboardingConnectionDiagnosticsJob.php` (writes evidence)
|
||||||
|
- [X] T042 [US2] Add onboarding initial sync job in `app/Jobs/Onboarding/OnboardingInitialSyncJob.php` (writes evidence)
|
||||||
|
|
||||||
|
|
||||||
|
- [X] T043 [US2] Ensure “View run” links use existing operation hub routing via `app/Support/OperationRunLinks.php`
|
||||||
|
|
||||||
**Checkpoint**: Task board supports reruns, history, prereqs, and concurrency dedupe.
|
**Checkpoint**: Task board supports reruns, history, prereqs, and concurrency dedupe.
|
||||||
|
|
||||||
@ -141,14 +152,14 @@ ## Phase 5: User Story 3 — Collaborate safely across multiple users (Priority:
|
|||||||
|
|
||||||
### Tests (write first)
|
### Tests (write first)
|
||||||
|
|
||||||
- [ ] T044 [P] [US3] Feature test: lock acquisition and read-only behavior in `tests/Feature/Onboarding/OnboardingSessionLockTest.php`
|
- [X] T044 [P] [US3] Feature test: lock acquisition and read-only behavior in `tests/Feature/Onboarding/OnboardingSessionLockTest.php`
|
||||||
- [ ] T045 [P] [US3] Feature test: takeover allowed for Owner/Manager only in `tests/Feature/Onboarding/OnboardingSessionTakeoverAuthorizationTest.php`
|
- [X] T045 [P] [US3] Feature test: takeover allowed for Owner/Manager only in `tests/Feature/Onboarding/OnboardingSessionTakeoverAuthorizationTest.php`
|
||||||
|
|
||||||
### Implementation
|
### Implementation
|
||||||
|
|
||||||
- [ ] T046 [US3] Add lock UI banner + renew-on-interaction behavior in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php`
|
- [X] T046 [US3] Add lock UI banner + renew-on-interaction behavior in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php`
|
||||||
- [ ] T047 [US3] Implement takeover + handoff actions in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (capability-gated, uses `OnboardingLockService`)
|
- [X] T047 [US3] Implement takeover + handoff actions in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (capability-gated, uses `OnboardingLockService`)
|
||||||
- [ ] T048 [US3] Add audit log entries for takeover/handoff in `app/Services/Intune/AuditLogger.php` (new actions `onboarding.takeover`, `onboarding.handoff`)
|
- [X] T048 [US3] Add audit log entries for takeover/handoff in `app/Services/Intune/AuditLogger.php` (new actions `onboarding.takeover`, `onboarding.handoff`)
|
||||||
|
|
||||||
**Checkpoint**: Collaboration is safe and auditable.
|
**Checkpoint**: Collaboration is safe and auditable.
|
||||||
|
|
||||||
@ -162,12 +173,12 @@ ## Phase 6: User Story 4 — Review onboarding evidence and history (Priority: P
|
|||||||
|
|
||||||
### Tests (write first)
|
### Tests (write first)
|
||||||
|
|
||||||
- [ ] T049 [P] [US4] Feature test: readonly can view evidence list but cannot start runs in `tests/Feature/Onboarding/OnboardingEvidenceReadonlyTest.php`
|
- [X] T049 [P] [US4] Feature test: readonly can view evidence list but cannot start runs in `tests/Feature/Onboarding/OnboardingEvidenceReadonlyTest.php`
|
||||||
|
|
||||||
### Implementation
|
### Implementation
|
||||||
|
|
||||||
- [ ] T050 [US4] Add evidence history section to task board UI in `resources/views/filament/pages/onboarding/tenant-onboarding-task-board.blade.php`
|
- [X] T050 [US4] Add evidence history section to task board UI in `resources/views/filament/pages/onboarding/tenant-onboarding-task-board.blade.php`
|
||||||
- [ ] T051 [US4] Ensure global search does not expose onboarding sessions by avoiding a Resource for sessions (no changes needed outside `app/Filament/Pages/Onboarding/`)
|
- [X] T051 [US4] Ensure global search does not expose onboarding sessions by avoiding a Resource for sessions (no changes needed outside `app/Filament/Pages/Onboarding/`)
|
||||||
|
|
||||||
**Checkpoint**: Evidence/history supports audit use cases.
|
**Checkpoint**: Evidence/history supports audit use cases.
|
||||||
|
|
||||||
@ -175,18 +186,18 @@ ### Implementation
|
|||||||
|
|
||||||
## Phase 7: Polish & Cross-Cutting Concerns
|
## Phase 7: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
- [ ] T052 [P] Add v1-to-v2 credential migration action in `app/Services/Onboarding/LegacyTenantCredentialMigrator.php` (move `Tenant.app_client_secret` into `provider_credentials`)
|
- [X] T052 [P] Add v1-to-v2 credential migration action in `app/Services/Onboarding/LegacyTenantCredentialMigrator.php` (move `Tenant.app_client_secret` into `provider_credentials`)
|
||||||
- [ ] T053 Add v1 migration UI action (Owner only, requires confirmation) in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php`
|
- [X] T053 Add v1 migration UI action (Owner only, requires confirmation) in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php`
|
||||||
- [ ] T054 Update tenant creation flow to steer into onboarding in `app/Filament/Resources/TenantResource/Pages/CreateTenant.php` (redirect to wizard; prevent credential setup outside onboarding)
|
- [X] T054 Update tenant creation flow to steer into onboarding in `app/Filament/Resources/TenantResource/Pages/CreateTenant.php` (redirect to wizard; prevent credential setup outside onboarding)
|
||||||
|
|
||||||
- [ ] T055 [P] Add regression test: no secrets rendered in onboarding pages in `tests/Feature/Onboarding/OnboardingNoSecretsLeakTest.php`
|
- [X] T055 [P] Add regression test: no secrets rendered in onboarding pages in `tests/Feature/Onboarding/OnboardingNoSecretsLeakTest.php`
|
||||||
- [ ] T056 [P] Add regression test: onboarding actions use `->requiresConfirmation()` when destructive-like in `tests/Feature/Onboarding/OnboardingDestructiveActionConfirmationTest.php`
|
- [X] T056 [P] Add regression test: onboarding actions use `->requiresConfirmation()` when destructive-like in `tests/Feature/Onboarding/OnboardingDestructiveActionConfirmationTest.php`
|
||||||
|
|
||||||
- [ ] T069 [P] Confirm Graph contract registry coverage for new onboarding jobs; update `config/graph_contracts.php` if any new Graph calls are introduced (and add tests) in `tests/Feature/Onboarding/OnboardingGraphContractCoverageTest.php`
|
- [X] T069 [P] Confirm Graph contract registry coverage for new onboarding jobs; update `config/graph_contracts.php` if any new Graph calls are introduced (and add tests) in `tests/Feature/Onboarding/OnboardingGraphContractCoverageTest.php`
|
||||||
- [ ] T070 [P] Implement explicit v1-to-v2 “resume” semantics (define what v1 means; create v2 session when tenant has legacy credential; migrate credential) in `app/Services/Onboarding/LegacyTenantCredentialMigrator.php` + wizard entry points
|
- [X] T070 [P] Implement explicit v1-to-v2 “resume” semantics (define what v1 means; create v2 session when tenant has legacy credential; migrate credential) in `app/Services/Onboarding/LegacyTenantCredentialMigrator.php` + wizard entry points
|
||||||
|
|
||||||
- [ ] T057 Run formatter on changed files (Pint) via `composer.json` scripts (validate using `vendor/bin/sail bin pint`)
|
- [X] T057 Run formatter on changed files (Pint) via `composer.json` scripts (validate using `vendor/bin/sail bin pint`)
|
||||||
- [ ] T058 Run onboarding test subset via `tests/Feature/Onboarding/` using `vendor/bin/sail artisan test --compact`
|
- [X] T058 Run onboarding test subset via `tests/Feature/Onboarding/` using `vendor/bin/sail artisan test --compact`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -51,19 +51,19 @@
|
|||||||
|
|
||||||
// Get available policies (should be empty since policy is already in backup)
|
// Get available policies (should be empty since policy is already in backup)
|
||||||
$existingPolicyIds = $this->backupSet->items()->pluck('policy_id')->filter()->all();
|
$existingPolicyIds = $this->backupSet->items()->pluck('policy_id')->filter()->all();
|
||||||
|
|
||||||
expect($existingPolicyIds)->toContain($this->policy->id);
|
expect($existingPolicyIds)->toContain($this->policy->id);
|
||||||
|
|
||||||
// Soft-delete the backup item
|
// Soft-delete the backup item
|
||||||
$backupItem->delete();
|
$backupItem->delete();
|
||||||
|
|
||||||
// Verify it's soft-deleted
|
// Verify it's soft-deleted
|
||||||
expect($this->backupSet->items()->count())->toBe(0);
|
expect($this->backupSet->items()->count())->toBe(0);
|
||||||
expect($this->backupSet->items()->withTrashed()->count())->toBe(1);
|
expect($this->backupSet->items()->withTrashed()->count())->toBe(1);
|
||||||
|
|
||||||
// Get available policies again - soft-deleted items should NOT be in the list (UI can re-add them)
|
// Get available policies again - soft-deleted items should NOT be in the list (UI can re-add them)
|
||||||
$existingPolicyIds = $this->backupSet->items()->pluck('policy_id')->filter()->all();
|
$existingPolicyIds = $this->backupSet->items()->pluck('policy_id')->filter()->all();
|
||||||
|
|
||||||
expect($existingPolicyIds)->not->toContain($this->policy->id)
|
expect($existingPolicyIds)->not->toContain($this->policy->id)
|
||||||
->and($existingPolicyIds)->toHaveCount(0);
|
->and($existingPolicyIds)->toHaveCount(0);
|
||||||
});
|
});
|
||||||
@ -86,7 +86,7 @@
|
|||||||
|
|
||||||
// Try to add the same policy again via BackupService
|
// Try to add the same policy again via BackupService
|
||||||
$service = app(BackupService::class);
|
$service = app(BackupService::class);
|
||||||
|
|
||||||
$result = $service->addPoliciesToSet(
|
$result = $service->addPoliciesToSet(
|
||||||
tenant: $this->tenant,
|
tenant: $this->tenant,
|
||||||
backupSet: $this->backupSet->refresh(),
|
backupSet: $this->backupSet->refresh(),
|
||||||
@ -129,7 +129,7 @@
|
|||||||
|
|
||||||
// Check available policies - should include the new one but not the deleted one
|
// Check available policies - should include the new one but not the deleted one
|
||||||
$existingPolicyIds = $this->backupSet->items()->withTrashed()->pluck('policy_id')->filter()->all();
|
$existingPolicyIds = $this->backupSet->items()->withTrashed()->pluck('policy_id')->filter()->all();
|
||||||
|
|
||||||
expect($existingPolicyIds)->toContain($this->policy->id)
|
expect($existingPolicyIds)->toContain($this->policy->id)
|
||||||
->and($existingPolicyIds)->not->toContain($otherPolicy->id);
|
->and($existingPolicyIds)->not->toContain($otherPolicy->id);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,6 +11,8 @@
|
|||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
test('policy sync updates selected policies from graph and updates the operation run', function () {
|
test('policy sync updates selected policies from graph and updates the operation run', function () {
|
||||||
|
config(['graph.enabled' => true]);
|
||||||
|
|
||||||
$tenant = Tenant::factory()->create([
|
$tenant = Tenant::factory()->create([
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
|
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\Auth\UiTooltips;
|
use App\Support\Auth\UiTooltips;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@ -68,4 +67,3 @@
|
|||||||
|
|
||||||
expect($backupSet->fresh()->trashed())->toBeTrue();
|
expect($backupSet->fresh()->trashed())->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -80,4 +80,3 @@
|
|||||||
|
|
||||||
expect($restoreRun->fresh()->trashed())->toBeTrue();
|
expect($restoreRun->fresh()->trashed())->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -7,8 +7,8 @@
|
|||||||
use App\Support\Auth\UiTooltips;
|
use App\Support\Auth\UiTooltips;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|||||||
20
tests/Feature/Onboarding/OnboardingAuthorizationTest.php
Normal file
20
tests/Feature/Onboarding/OnboardingAuthorizationTest.php
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('denies non-member access as not found (404)', function () {
|
||||||
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
$onboardingUrl = "/admin/t/{$tenant->external_id}/onboarding";
|
||||||
|
|
||||||
|
$nonMember = User::factory()->create();
|
||||||
|
$this->actingAs($nonMember);
|
||||||
|
|
||||||
|
$this->get($onboardingUrl)
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Jobs\Onboarding\OnboardingConnectionDiagnosticsJob;
|
||||||
|
use App\Models\OnboardingEvidence;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Services\Onboarding\OnboardingEvidenceWriter;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('writes connection diagnostics evidence and completes the run', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'status' => 'needs_consent',
|
||||||
|
'health_status' => 'unknown',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = OnboardingSession::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'current_step' => 4,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'type' => OnboardingTaskType::ConnectionDiagnostics,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$job = new OnboardingConnectionDiagnosticsJob(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
onboardingSessionId: (int) $session->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job->handle(
|
||||||
|
evidence: app(OnboardingEvidenceWriter::class),
|
||||||
|
runs: app(OperationRunService::class),
|
||||||
|
);
|
||||||
|
|
||||||
|
$evidence = OnboardingEvidence::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('onboarding_session_id', $session->getKey())
|
||||||
|
->where('task_type', OnboardingTaskType::ConnectionDiagnostics)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($evidence)->not->toBeNull();
|
||||||
|
expect($evidence?->status)->toBe('blocked');
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('completed');
|
||||||
|
expect($run->outcome)->toBe('failed');
|
||||||
|
});
|
||||||
42
tests/Feature/Onboarding/OnboardingConsentGuidanceTest.php
Normal file
42
tests/Feature/Onboarding/OnboardingConsentGuidanceTest.php
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantMembership;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('shows consent guidance in step 4 without leaking secrets', function () {
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'app_client_secret' => 'should-not-leak',
|
||||||
|
]);
|
||||||
|
$onboardingUrl = "/admin/t/{$tenant->external_id}/onboarding";
|
||||||
|
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
TenantMembership::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $owner->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
'source' => 'manual',
|
||||||
|
'source_ref' => null,
|
||||||
|
'created_by_user_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
OnboardingSession::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'current_step' => 4,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
$this->get($onboardingUrl)
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('consent', escape: false)
|
||||||
|
->assertDontSee('should-not-leak', escape: false);
|
||||||
|
});
|
||||||
69
tests/Feature/Onboarding/OnboardingConsentStatusJobTest.php
Normal file
69
tests/Feature/Onboarding/OnboardingConsentStatusJobTest.php
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Jobs\Onboarding\OnboardingConsentStatusJob;
|
||||||
|
use App\Models\OnboardingEvidence;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
|
||||||
|
it('writes consent status evidence and completes the run', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'status' => 'connected',
|
||||||
|
'health_status' => 'ok',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = OnboardingSession::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'provider_connection_id' => $connection->getKey(),
|
||||||
|
'assigned_to_user_id' => $user->getKey(),
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'current_step' => 4,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'initiator_name' => $user->name,
|
||||||
|
'type' => OnboardingTaskType::ConsentStatus,
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$job = new OnboardingConsentStatusJob(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
onboardingSessionId: (int) $session->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job->handle(
|
||||||
|
evidence: app(\App\Services\Onboarding\OnboardingEvidenceWriter::class),
|
||||||
|
runs: app(\App\Services\OperationRunService::class),
|
||||||
|
);
|
||||||
|
|
||||||
|
$evidence = OnboardingEvidence::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('onboarding_session_id', $session->getKey())
|
||||||
|
->where('provider_connection_id', $connection->getKey())
|
||||||
|
->where('task_type', OnboardingTaskType::ConsentStatus)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($evidence)->not->toBeNull();
|
||||||
|
expect($evidence?->status)->toBe('ok');
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe(OperationRunStatus::Completed->value);
|
||||||
|
expect($run->outcome)->toBe(OperationRunOutcome::Succeeded->value);
|
||||||
|
});
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource\Pages\CreateProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantMembership;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('offers a create-provider-connection path from onboarding and allows returning', function () {
|
||||||
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
$onboardingUrl = "/admin/t/{$tenant->external_id}/onboarding";
|
||||||
|
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
TenantMembership::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $owner->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
'source' => 'manual',
|
||||||
|
'source_ref' => null,
|
||||||
|
'created_by_user_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
$response = $this->get($onboardingUrl)
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$response->assertSee(CreateProviderConnection::getUrl(tenant: $tenant), escape: false);
|
||||||
|
});
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('mounts destructive-like onboarding actions for modal confirmation', function () {
|
||||||
|
[$userA, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
[$userB] = createUserWithTenant(tenant: $tenant, role: 'operator');
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->for($tenant)->create([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = OnboardingSession::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'provider_connection_id' => $connection->getKey(),
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'current_step' => 4,
|
||||||
|
'assigned_to_user_id' => $userA->getKey(),
|
||||||
|
'locked_by_user_id' => $userB->getKey(),
|
||||||
|
'locked_until' => now()->addMinutes(10),
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($userA);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(TenantOnboardingWizard::class)
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSet('session.id', (int) $session->getKey())
|
||||||
|
->assertActionVisible('takeover_onboarding_session')
|
||||||
|
->mountAction('takeover_onboarding_session')
|
||||||
|
->assertActionMounted('takeover_onboarding_session');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mounts legacy credential migration action for modal confirmation', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$tenant->forceFill([
|
||||||
|
'app_client_id' => '00000000-0000-0000-0000-000000000000',
|
||||||
|
'app_client_secret' => 'TENANT_SECRET_NOT_RENDERED',
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->for($tenant)->create([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
OnboardingSession::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'provider_connection_id' => $connection->getKey(),
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'current_step' => 2,
|
||||||
|
'assigned_to_user_id' => $user->getKey(),
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(TenantOnboardingWizard::class)
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertActionVisible('migrate_legacy_credentials')
|
||||||
|
->mountAction('migrate_legacy_credentials')
|
||||||
|
->assertActionMounted('migrate_legacy_credentials');
|
||||||
|
});
|
||||||
39
tests/Feature/Onboarding/OnboardingDuplicateHandlingTest.php
Normal file
39
tests/Feature/Onboarding/OnboardingDuplicateHandlingTest.php
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantMembership;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('always resumes the active onboarding session safely', function () {
|
||||||
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
$onboardingUrl = "/admin/t/{$tenant->external_id}/onboarding";
|
||||||
|
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
TenantMembership::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $owner->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
'source' => 'manual',
|
||||||
|
'source_ref' => null,
|
||||||
|
'created_by_user_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
OnboardingSession::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'status' => 'draft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
$this->get($onboardingUrl)
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
expect(OnboardingSession::query()->where('tenant_id', $tenant->getKey())->count())
|
||||||
|
->toBe(1);
|
||||||
|
});
|
||||||
55
tests/Feature/Onboarding/OnboardingEvidenceReadonlyTest.php
Normal file
55
tests/Feature/Onboarding/OnboardingEvidenceReadonlyTest.php
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Onboarding\TenantOnboardingTaskBoard;
|
||||||
|
use App\Models\OnboardingEvidence;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('allows readonly users to view evidence history but blocks task runs', function () {
|
||||||
|
[$readonly, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = OnboardingSession::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'current_step' => 4,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'assigned_to_user_id' => (int) $readonly->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$evidence = OnboardingEvidence::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'onboarding_session_id' => (int) $session->getKey(),
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'task_type' => OnboardingTaskType::VerifyPermissions,
|
||||||
|
'status' => 'error',
|
||||||
|
'reason_code' => 'provider_auth_failed',
|
||||||
|
'message' => 'Authentication failed. Please re-consent the app.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($readonly);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$this->get(TenantOnboardingTaskBoard::getUrl(tenant: $tenant))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Evidence history')
|
||||||
|
->assertSee($evidence->reason_code)
|
||||||
|
->assertSee($evidence->message);
|
||||||
|
|
||||||
|
Livewire::test(TenantOnboardingTaskBoard::class)
|
||||||
|
->call('startTask', OnboardingTaskType::VerifyPermissions)
|
||||||
|
->assertStatus(403);
|
||||||
|
});
|
||||||
58
tests/Feature/Onboarding/OnboardingFixHintsTest.php
Normal file
58
tests/Feature/Onboarding/OnboardingFixHintsTest.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Onboarding\TenantOnboardingTaskBoard;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Services\Onboarding\OnboardingEvidenceWriter;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('shows sanitized failure reason and fix hints for a failing task', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = OnboardingSession::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'current_step' => 4,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var OnboardingEvidenceWriter $writer */
|
||||||
|
$writer = app(OnboardingEvidenceWriter::class);
|
||||||
|
|
||||||
|
$writer->record(
|
||||||
|
tenant: $tenant,
|
||||||
|
taskType: OnboardingTaskType::VerifyPermissions,
|
||||||
|
status: 'error',
|
||||||
|
reasonCode: 'permissions.missing',
|
||||||
|
message: 'Authorization: Bearer abc client_secret=supersecret user@example.com',
|
||||||
|
payload: ['client_secret' => 'supersecret'],
|
||||||
|
session: $session,
|
||||||
|
providerConnection: $connection,
|
||||||
|
operationRun: null,
|
||||||
|
recordedBy: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$this->get(TenantOnboardingTaskBoard::getUrl(tenant: $tenant))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Fix hints')
|
||||||
|
->assertDontSee('supersecret')
|
||||||
|
->assertDontSee('user@example.com')
|
||||||
|
->assertDontSee('Bearer abc');
|
||||||
|
});
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders onboarding pages without triggering Graph calls at render time', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->for($tenant)->create([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
OnboardingSession::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'provider_connection_id' => $connection->getKey(),
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'current_step' => 4,
|
||||||
|
'assigned_to_user_id' => $user->getKey(),
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$wizardUrl = "/admin/t/{$tenant->external_id}/onboarding";
|
||||||
|
$taskBoardUrl = "/admin/t/{$tenant->external_id}/onboarding/tasks";
|
||||||
|
|
||||||
|
$this->get($wizardUrl)->assertSuccessful();
|
||||||
|
$this->get($taskBoardUrl)->assertSuccessful();
|
||||||
|
});
|
||||||
69
tests/Feature/Onboarding/OnboardingInitialSyncJobTest.php
Normal file
69
tests/Feature/Onboarding/OnboardingInitialSyncJobTest.php
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Jobs\Onboarding\OnboardingInitialSyncJob;
|
||||||
|
use App\Models\OnboardingEvidence;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Services\Onboarding\OnboardingEvidenceWriter;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('writes initial sync evidence and completes the run', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'status' => 'connected',
|
||||||
|
'health_status' => 'healthy',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = OnboardingSession::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'current_step' => 5,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'type' => OnboardingTaskType::InitialSync,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$job = new OnboardingInitialSyncJob(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
onboardingSessionId: (int) $session->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job->handle(
|
||||||
|
evidence: app(OnboardingEvidenceWriter::class),
|
||||||
|
runs: app(OperationRunService::class),
|
||||||
|
);
|
||||||
|
|
||||||
|
$evidence = OnboardingEvidence::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('onboarding_session_id', $session->getKey())
|
||||||
|
->where('task_type', OnboardingTaskType::InitialSync)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($evidence)->not->toBeNull();
|
||||||
|
expect($evidence?->status)->toBe('ok');
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('completed');
|
||||||
|
expect($run->outcome)->toBe('succeeded');
|
||||||
|
});
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\ProviderCredential;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('migrates legacy tenant app credentials into provider credentials when confirmed', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$tenant->forceFill([
|
||||||
|
'app_client_id' => '00000000-0000-0000-0000-000000000000',
|
||||||
|
'app_client_secret' => 'TENANT_SECRET_FOR_MIGRATION',
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->for($tenant)->create([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
OnboardingSession::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'provider_connection_id' => $connection->getKey(),
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'current_step' => 2,
|
||||||
|
'assigned_to_user_id' => $user->getKey(),
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(TenantOnboardingWizard::class)
|
||||||
|
->assertActionVisible('migrate_legacy_credentials')
|
||||||
|
->mountAction('migrate_legacy_credentials')
|
||||||
|
->callMountedAction()
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$credential = ProviderCredential::query()
|
||||||
|
->where('provider_connection_id', $connection->getKey())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($credential)->not->toBeNull();
|
||||||
|
expect($credential?->type)->toBe('client_secret');
|
||||||
|
expect($credential?->payload)->toBe([
|
||||||
|
'client_id' => '00000000-0000-0000-0000-000000000000',
|
||||||
|
'client_secret' => 'TENANT_SECRET_FOR_MIGRATION',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->refresh();
|
||||||
|
expect($tenant->app_client_secret)->toBe('TENANT_SECRET_FOR_MIGRATION');
|
||||||
|
});
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('auto-links the default provider connection to the active session when legacy credentials exist', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$tenant->forceFill([
|
||||||
|
'app_client_id' => '00000000-0000-0000-0000-000000000000',
|
||||||
|
'app_client_secret' => 'TENANT_SECRET_FOR_RESUME',
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->for($tenant)->create([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = OnboardingSession::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'provider_connection_id' => null,
|
||||||
|
'status' => 'draft',
|
||||||
|
'current_step' => 1,
|
||||||
|
'assigned_to_user_id' => $user->getKey(),
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(TenantOnboardingWizard::class)
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$session->refresh();
|
||||||
|
|
||||||
|
expect($session->provider_connection_id)->toBe((int) $connection->getKey());
|
||||||
|
});
|
||||||
53
tests/Feature/Onboarding/OnboardingNoSecretsLeakTest.php
Normal file
53
tests/Feature/Onboarding/OnboardingNoSecretsLeakTest.php
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\ProviderCredential;
|
||||||
|
|
||||||
|
it('does not render tenant legacy app client secret in onboarding wizard or task board', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$tenant->forceFill([
|
||||||
|
'app_client_id' => '00000000-0000-0000-0000-000000000000',
|
||||||
|
'app_client_secret' => 'TENANT_SECRET_SHOULD_NEVER_RENDER',
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->for($tenant)->create([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProviderCredential::factory()->for($connection, 'providerConnection')->create([
|
||||||
|
'type' => 'client_secret',
|
||||||
|
'payload' => [
|
||||||
|
'client_id' => '11111111-1111-1111-1111-111111111111',
|
||||||
|
'client_secret' => 'PROVIDER_SECRET_SHOULD_NEVER_RENDER',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
OnboardingSession::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'provider_connection_id' => $connection->getKey(),
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'current_step' => 4,
|
||||||
|
'assigned_to_user_id' => $user->getKey(),
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$wizardUrl = "/admin/t/{$tenant->external_id}/onboarding";
|
||||||
|
$taskBoardUrl = "/admin/t/{$tenant->external_id}/onboarding/tasks";
|
||||||
|
|
||||||
|
$this->get($wizardUrl)
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertDontSee('TENANT_SECRET_SHOULD_NEVER_RENDER')
|
||||||
|
->assertDontSee('PROVIDER_SECRET_SHOULD_NEVER_RENDER');
|
||||||
|
|
||||||
|
$this->get($taskBoardUrl)
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertDontSee('TENANT_SECRET_SHOULD_NEVER_RENDER')
|
||||||
|
->assertDontSee('PROVIDER_SECRET_SHOULD_NEVER_RENDER');
|
||||||
|
});
|
||||||
31
tests/Feature/Onboarding/OnboardingPlanPreviewTest.php
Normal file
31
tests/Feature/Onboarding/OnboardingPlanPreviewTest.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantMembership;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('shows an onboarding plan preview before any task execution', function () {
|
||||||
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
$onboardingUrl = "/admin/t/{$tenant->external_id}/onboarding";
|
||||||
|
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
TenantMembership::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $owner->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
'source' => 'manual',
|
||||||
|
'source_ref' => null,
|
||||||
|
'created_by_user_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
$this->get($onboardingUrl)
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Verify permissions');
|
||||||
|
});
|
||||||
34
tests/Feature/Onboarding/OnboardingReadonlyAccessTest.php
Normal file
34
tests/Feature/Onboarding/OnboardingReadonlyAccessTest.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantMembership;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('allows readonly users to view but does not auto-create sessions', function () {
|
||||||
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
$onboardingUrl = "/admin/t/{$tenant->external_id}/onboarding";
|
||||||
|
|
||||||
|
$readonly = User::factory()->create();
|
||||||
|
TenantMembership::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $readonly->getKey(),
|
||||||
|
'role' => 'readonly',
|
||||||
|
'source' => 'manual',
|
||||||
|
'source_ref' => null,
|
||||||
|
'created_by_user_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($readonly);
|
||||||
|
|
||||||
|
$this->get($onboardingUrl)
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
expect(OnboardingSession::query()->where('tenant_id', $tenant->getKey())->count())
|
||||||
|
->toBe(0);
|
||||||
|
});
|
||||||
38
tests/Feature/Onboarding/OnboardingRoleGuidanceTest.php
Normal file
38
tests/Feature/Onboarding/OnboardingRoleGuidanceTest.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantMembership;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('renders role-aware guidance for tenant members without run permission', function () {
|
||||||
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
$onboardingUrl = "/admin/t/{$tenant->external_id}/onboarding";
|
||||||
|
|
||||||
|
$readonly = User::factory()->create();
|
||||||
|
TenantMembership::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $readonly->getKey(),
|
||||||
|
'role' => 'readonly',
|
||||||
|
'source' => 'manual',
|
||||||
|
'source_ref' => null,
|
||||||
|
'created_by_user_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
OnboardingSession::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'current_step' => 4,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($readonly);
|
||||||
|
|
||||||
|
$this->get($onboardingUrl)
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('permission', escape: false);
|
||||||
|
});
|
||||||
40
tests/Feature/Onboarding/OnboardingSessionLifecycleTest.php
Normal file
40
tests/Feature/Onboarding/OnboardingSessionLifecycleTest.php
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantMembership;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('allows an owner to create and resume an onboarding session', function () {
|
||||||
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
$onboardingUrl = "/admin/t/{$tenant->external_id}/onboarding";
|
||||||
|
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
TenantMembership::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $owner->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
'source' => 'manual',
|
||||||
|
'source_ref' => null,
|
||||||
|
'created_by_user_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
$this->get($onboardingUrl)
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
expect(OnboardingSession::query()->where('tenant_id', $tenant->getKey())->count())
|
||||||
|
->toBe(1);
|
||||||
|
|
||||||
|
$this->get($onboardingUrl)
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
expect(OnboardingSession::query()->where('tenant_id', $tenant->getKey())->count())
|
||||||
|
->toBe(1);
|
||||||
|
});
|
||||||
56
tests/Feature/Onboarding/OnboardingSessionLockTest.php
Normal file
56
tests/Feature/Onboarding/OnboardingSessionLockTest.php
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\User;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('acquires a session lock for the first operator and blocks mutations for other operators', function () {
|
||||||
|
[$userA, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
|
||||||
|
$userB = User::factory()->create();
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $userB, role: 'operator');
|
||||||
|
|
||||||
|
$connectionA = ProviderConnection::factory()->for($tenant)->create([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$connectionB = ProviderConnection::factory()->for($tenant)->create([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($userA);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(TenantOnboardingWizard::class)
|
||||||
|
->set('selectedProviderConnectionId', (int) $connectionA->getKey())
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$session = OnboardingSession::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($session)->not->toBeNull();
|
||||||
|
expect($session?->provider_connection_id)->toBe((int) $connectionA->getKey());
|
||||||
|
expect($session?->locked_by_user_id)->toBe((int) $userA->getKey());
|
||||||
|
expect($session?->locked_until)->not->toBeNull();
|
||||||
|
expect($session?->locked_until?->isFuture())->toBeTrue();
|
||||||
|
|
||||||
|
$this->actingAs($userB);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(TenantOnboardingWizard::class)
|
||||||
|
->set('selectedProviderConnectionId', (int) $connectionB->getKey())
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$session->refresh();
|
||||||
|
|
||||||
|
expect($session->provider_connection_id)->toBe((int) $connectionA->getKey());
|
||||||
|
expect($session->locked_by_user_id)->toBe((int) $userA->getKey());
|
||||||
|
});
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\User;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('allows takeover for owner and manager only', function (string $role, bool $shouldBeAllowed) {
|
||||||
|
[$lockHolder, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
|
||||||
|
$actor = User::factory()->create();
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $actor, role: $role);
|
||||||
|
|
||||||
|
ProviderConnection::factory()->for($tenant)->create([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($lockHolder);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(TenantOnboardingWizard::class)
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$session = OnboardingSession::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($session)->not->toBeNull();
|
||||||
|
expect($session?->locked_by_user_id)->toBe((int) $lockHolder->getKey());
|
||||||
|
|
||||||
|
$this->actingAs($actor);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
if (! $shouldBeAllowed) {
|
||||||
|
Livewire::test(TenantOnboardingWizard::class)
|
||||||
|
->callAction('takeover_onboarding_session');
|
||||||
|
|
||||||
|
$session->refresh();
|
||||||
|
expect($session->locked_by_user_id)->toBe((int) $lockHolder->getKey());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Livewire::test(TenantOnboardingWizard::class)
|
||||||
|
->callAction('takeover_onboarding_session');
|
||||||
|
|
||||||
|
$session->refresh();
|
||||||
|
expect($session->locked_by_user_id)->toBe((int) $actor->getKey());
|
||||||
|
})->with([
|
||||||
|
'owner' => ['owner', true],
|
||||||
|
'manager' => ['manager', true],
|
||||||
|
'operator' => ['operator', false],
|
||||||
|
'readonly' => ['readonly', false],
|
||||||
|
]);
|
||||||
5
tests/Feature/Onboarding/OnboardingSmokeTest.php
Normal file
5
tests/Feature/Onboarding/OnboardingSmokeTest.php
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('boots onboarding test suite', function () {
|
||||||
|
expect(true)->toBeTrue();
|
||||||
|
});
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Onboarding\TenantOnboardingTaskBoard;
|
||||||
|
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('shows the task board starting step 4', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$session = OnboardingSession::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'current_step' => 3,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$wizardUrl = TenantOnboardingWizard::getUrl(tenant: $tenant);
|
||||||
|
$taskBoardUrl = TenantOnboardingTaskBoard::getUrl(tenant: $tenant);
|
||||||
|
|
||||||
|
$this->get($wizardUrl)
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertDontSee('Open task board');
|
||||||
|
|
||||||
|
$this->get($taskBoardUrl)
|
||||||
|
->assertRedirect($wizardUrl);
|
||||||
|
|
||||||
|
$session->update(['current_step' => 4]);
|
||||||
|
|
||||||
|
$this->get($wizardUrl)
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Open task board');
|
||||||
|
|
||||||
|
$this->get($taskBoardUrl)
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Onboarding task board');
|
||||||
|
});
|
||||||
68
tests/Feature/Onboarding/OnboardingTaskConcurrencyTest.php
Normal file
68
tests/Feature/Onboarding/OnboardingTaskConcurrencyTest.php
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Onboarding\TenantOnboardingTaskBoard;
|
||||||
|
use App\Jobs\Onboarding\OnboardingVerifyPermissionsJob;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('blocks starting a second run for the same task while one is active', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = OnboardingSession::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'current_step' => 4,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(TenantOnboardingTaskBoard::class)
|
||||||
|
->call('startTask', OnboardingTaskType::VerifyPermissions)
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', OnboardingTaskType::VerifyPermissions)
|
||||||
|
->count()
|
||||||
|
)->toBe(1);
|
||||||
|
|
||||||
|
Queue::assertPushed(OnboardingVerifyPermissionsJob::class);
|
||||||
|
|
||||||
|
// Attempt to start again while still active should dedupe.
|
||||||
|
Livewire::test(TenantOnboardingTaskBoard::class)
|
||||||
|
->call('startTask', OnboardingTaskType::VerifyPermissions)
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', OnboardingTaskType::VerifyPermissions)
|
||||||
|
->count()
|
||||||
|
)->toBe(1);
|
||||||
|
|
||||||
|
expect(Queue::pushed(OnboardingVerifyPermissionsJob::class))->toHaveCount(1);
|
||||||
|
|
||||||
|
// Keep session referenced so it can't be optimized away.
|
||||||
|
expect($session->getKey())->toBeInt();
|
||||||
|
});
|
||||||
102
tests/Feature/Onboarding/OnboardingVerifyPermissionsTaskTest.php
Normal file
102
tests/Feature/Onboarding/OnboardingVerifyPermissionsTaskTest.php
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
|
||||||
|
use App\Jobs\Onboarding\OnboardingVerifyPermissionsJob;
|
||||||
|
use App\Models\OnboardingEvidence;
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Support\Onboarding\OnboardingTaskType;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('starts verify-permissions with OperationRun dedupe and writes evidence', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$requiredKeys = collect(config('intune_permissions.permissions', []))
|
||||||
|
->pluck('key')
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
config()->set('intune_permissions.granted_stub', $requiredKeys);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(TenantOnboardingWizard::class)
|
||||||
|
->set('selectedProviderConnectionId', (int) $connection->getKey())
|
||||||
|
->call('startVerifyPermissions')
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$session = OnboardingSession::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($session)->not->toBeNull();
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', OnboardingTaskType::VerifyPermissions)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
|
||||||
|
Queue::assertPushed(OnboardingVerifyPermissionsJob::class);
|
||||||
|
|
||||||
|
// Calling start again while the run is still active should dedupe.
|
||||||
|
Livewire::test(TenantOnboardingWizard::class)
|
||||||
|
->call('startVerifyPermissions')
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', OnboardingTaskType::VerifyPermissions)
|
||||||
|
->count()
|
||||||
|
)->toBe(1);
|
||||||
|
|
||||||
|
expect(Queue::pushed(OnboardingVerifyPermissionsJob::class))->toHaveCount(1);
|
||||||
|
|
||||||
|
// Execute the job inline to assert evidence write behavior.
|
||||||
|
$job = new OnboardingVerifyPermissionsJob(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
onboardingSessionId: (int) $session->getKey(),
|
||||||
|
operationRun: $run,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job->handle(
|
||||||
|
permissions: app(\App\Services\Intune\TenantPermissionService::class),
|
||||||
|
evidence: app(\App\Services\Onboarding\OnboardingEvidenceWriter::class),
|
||||||
|
runs: app(\App\Services\OperationRunService::class),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(OnboardingEvidence::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('onboarding_session_id', $session->getKey())
|
||||||
|
->where('task_type', OnboardingTaskType::VerifyPermissions)
|
||||||
|
->exists()
|
||||||
|
)->toBeTrue();
|
||||||
|
|
||||||
|
$evidence = OnboardingEvidence::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('onboarding_session_id', $session->getKey())
|
||||||
|
->where('task_type', OnboardingTaskType::VerifyPermissions)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($evidence?->status)->toBe('ok');
|
||||||
|
});
|
||||||
@ -33,4 +33,3 @@
|
|||||||
expect($enforcement->bulkSelectionIsAuthorized($user, $tenants))->toBeTrue();
|
expect($enforcement->bulkSelectionIsAuthorized($user, $tenants))->toBeTrue();
|
||||||
expect($membershipQueries)->toBe(1);
|
expect($membershipQueries)->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -125,4 +125,3 @@
|
|||||||
|
|
||||||
expect($enforcement->bulkSelectionIsAuthorized($user, collect([$tenantA, $tenantB])))->toBeTrue();
|
expect($enforcement->bulkSelectionIsAuthorized($user, collect([$tenantA, $tenantB])))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
24
tests/Unit/Badges/OnboardingBadgesTest.php
Normal file
24
tests/Unit/Badges/OnboardingBadgesTest.php
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
|
||||||
|
it('maps onboarding task status safely', function (): void {
|
||||||
|
$ok = BadgeCatalog::spec(BadgeDomain::OnboardingTaskStatus, 'ok');
|
||||||
|
expect($ok->color)->toBe('success');
|
||||||
|
expect($ok->label)->toBe('OK');
|
||||||
|
|
||||||
|
$warn = BadgeCatalog::spec(BadgeDomain::OnboardingTaskStatus, 'warn');
|
||||||
|
expect($warn->color)->toBe('warning');
|
||||||
|
expect($warn->label)->toBe('Warning');
|
||||||
|
|
||||||
|
$fail = BadgeCatalog::spec(BadgeDomain::OnboardingTaskStatus, 'fail');
|
||||||
|
expect($fail->color)->toBe('danger');
|
||||||
|
expect($fail->label)->toBe('Failed');
|
||||||
|
|
||||||
|
$unknown = BadgeCatalog::spec(BadgeDomain::OnboardingTaskStatus, 'unknown');
|
||||||
|
expect($unknown->color)->toBe('gray');
|
||||||
|
expect($unknown->label)->toBe('Unknown');
|
||||||
|
});
|
||||||
@ -1,11 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\Operations\PolicyVersionForceDeleteWorkerJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Jobs\Operations\PolicyVersionForceDeleteWorkerJob;
|
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\Operations\PolicyVersionPruneWorkerJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Jobs\Operations\PolicyVersionPruneWorkerJob;
|
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|||||||
41
tests/Unit/Onboarding/OnboardingEvidenceWriterTest.php
Normal file
41
tests/Unit/Onboarding/OnboardingEvidenceWriterTest.php
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OnboardingEvidence;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Onboarding\OnboardingEvidenceWriter;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('records sanitized evidence message and payload', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
$writer = app(OnboardingEvidenceWriter::class);
|
||||||
|
|
||||||
|
$evidence = $writer->record(
|
||||||
|
tenant: $tenant,
|
||||||
|
taskType: 'onboarding.permissions.verify',
|
||||||
|
status: 'fail',
|
||||||
|
reasonCode: 'invalid_client',
|
||||||
|
message: 'Authorization: Bearer abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz',
|
||||||
|
payload: [
|
||||||
|
'access_token' => 'super-secret-token',
|
||||||
|
'nested' => [
|
||||||
|
'client_secret' => 'dont-store-this',
|
||||||
|
'detail' => 'user@example.com',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($evidence)->toBeInstanceOf(OnboardingEvidence::class);
|
||||||
|
expect($evidence->tenant_id)->toBe($tenant->getKey());
|
||||||
|
|
||||||
|
expect($evidence->reason_code)->toBe('provider_auth_failed');
|
||||||
|
expect($evidence->message)->toContain('[REDACTED_AUTH]');
|
||||||
|
|
||||||
|
expect($evidence->payload['access_token'])->toBe('[REDACTED]');
|
||||||
|
expect($evidence->payload['nested']['client_secret'])->toBe('[REDACTED]');
|
||||||
|
expect($evidence->payload['nested']['detail'])->toBe('[REDACTED_EMAIL]');
|
||||||
|
});
|
||||||
47
tests/Unit/Onboarding/OnboardingLockServiceTest.php
Normal file
47
tests/Unit/Onboarding/OnboardingLockServiceTest.php
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OnboardingSession;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Onboarding\OnboardingLockService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('acquires and blocks lock for other users', function (): void {
|
||||||
|
$userA = User::factory()->create();
|
||||||
|
$userB = User::factory()->create();
|
||||||
|
|
||||||
|
$session = OnboardingSession::factory()->create([
|
||||||
|
'locked_by_user_id' => null,
|
||||||
|
'locked_until' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$locks = app(OnboardingLockService::class);
|
||||||
|
|
||||||
|
expect($locks->acquire($session, $userA, ttlSeconds: 600))->toBeTrue();
|
||||||
|
|
||||||
|
$session->refresh();
|
||||||
|
expect((int) $session->locked_by_user_id)->toBe((int) $userA->getKey());
|
||||||
|
expect($session->locked_until)->not->toBeNull();
|
||||||
|
|
||||||
|
expect($locks->acquire($session, $userB, ttlSeconds: 600))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows takeover', function (): void {
|
||||||
|
$userA = User::factory()->create();
|
||||||
|
$userB = User::factory()->create();
|
||||||
|
|
||||||
|
$session = OnboardingSession::factory()->create([
|
||||||
|
'locked_by_user_id' => $userA->getKey(),
|
||||||
|
'locked_until' => now()->addMinutes(5),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$locks = app(OnboardingLockService::class);
|
||||||
|
|
||||||
|
$locks->takeover($session, $userB, ttlSeconds: 600);
|
||||||
|
|
||||||
|
$session->refresh();
|
||||||
|
expect((int) $session->locked_by_user_id)->toBe((int) $userB->getKey());
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user