wip: save 069 onboarding wizard v2 worktree state

This commit is contained in:
Ahmed Darrazi 2026-02-01 12:20:18 +01:00
parent 458a94c6e9
commit 21df2056f1
72 changed files with 4456 additions and 96 deletions

View 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();
}
}

View 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());
}
}

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
use App\Filament\Resources\ProviderConnectionResource;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderConnectionHealthCheckJob;
@ -116,6 +117,18 @@ protected function getHeaderActions(): array
->visible(false),
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(
Action::make('view_last_check_run')
->label('View last check run')

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
use App\Filament\Resources\TenantResource;
use App\Models\User;
use Filament\Resources\Pages\CreateRecord;
@ -10,6 +11,25 @@ class CreateTenant extends CreateRecord
{
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
{
$user = auth()->user();
@ -22,4 +42,9 @@ protected function afterCreate(): void
$this->record->getKey() => ['role' => 'owner'],
]);
}
protected function getRedirectUrl(): string
{
return TenantOnboardingWizard::getUrl(tenant: $this->record);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Pages\Onboarding\TenantOnboardingWizard;
use App\Filament\Resources\TenantResource;
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
use App\Models\Tenant;
@ -30,6 +31,17 @@ protected function getHeaderActions(): array
{
return [
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(
Actions\Action::make('edit')
->label('Edit')

View 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,
]],
);
}
}

View 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,
]],
);
}
}

View 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,
]],
);
}
}

View 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,
]],
);
}
}

View 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');
}
}

View 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');
}
}

View 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();
}
}

View 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();
}
}

View File

@ -2,10 +2,14 @@
namespace App\Providers;
use App\Models\OnboardingEvidence;
use App\Models\OnboardingSession;
use App\Models\PlatformUser;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Policies\OnboardingEvidencePolicy;
use App\Policies\OnboardingSessionPolicy;
use App\Policies\ProviderConnectionPolicy;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
@ -17,6 +21,8 @@ class AuthServiceProvider extends ServiceProvider
{
protected $policies = [
ProviderConnection::class => ProviderConnectionPolicy::class,
OnboardingSession::class => OnboardingSessionPolicy::class,
OnboardingEvidence::class => OnboardingEvidencePolicy::class,
];
public function boot(): void

View File

@ -43,18 +43,18 @@ public function resolve(array $scopeTagIds, ?Tenant $tenant = null): array
private function fetchAllScopeTags(?Tenant $tenant = null): array
{
$cacheKey = $tenant ? "scope_tags:tenant:{$tenant->id}" : 'scope_tags:all';
return Cache::remember($cacheKey, 3600, function () use ($tenant) {
try {
$options = ['query' => ['$select' => 'id,displayName']];
// Add tenant credentials if provided
if ($tenant) {
$options['tenant'] = $tenant->external_id ?? $tenant->tenant_id;
$options['client_id'] = $tenant->app_client_id;
$options['client_secret'] = $tenant->app_client_secret;
}
$graphResponse = $this->graphClient->request(
'GET',
'/deviceManagement/roleScopeTags',

View 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.',
];
}
}

View 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;
}
}

View 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();
}
}

View File

@ -44,9 +44,7 @@ class UiEnforcement
*/
private ?\Closure $bulkPreflight = null;
public function __construct(private string $capability)
{
}
public function __construct(private string $capability) {}
public static function for(string $capability): self
{
@ -418,6 +416,7 @@ private function resolveTenantIdsForRecords(Collection $records): array
if ($resolved instanceof Tenant) {
$ids[] = (int) $resolved->getKey();
continue;
}

View File

@ -11,4 +11,3 @@ public static function insufficientPermission(): string
return self::INSUFFICIENT_PERMISSION_ASK_OWNER;
}
}

View File

@ -34,6 +34,7 @@ final class BadgeCatalog
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
BadgeDomain::OnboardingTaskStatus->value => Domains\OnboardingTaskStatusBadge::class,
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
];

View File

@ -26,6 +26,7 @@ enum BadgeDomain: string
case IgnoredAt = 'ignored_at';
case RestorePreviewDecision = 'restore_preview_decision';
case RestoreResultStatus = 'restore_result_status';
case OnboardingTaskStatus = 'onboarding_task.status';
case ProviderConnectionStatus = 'provider_connection.status';
case ProviderConnectionHealth = 'provider_connection.health';
}

View 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(),
};
}
}

View 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.',
],
};
}
}

View 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;
}
}

View 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);
}
}

View 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,
];
}
}

View 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' => [],
];
}
}

View File

@ -16,7 +16,7 @@ public function up(): void
$table->json('scope_tags')->nullable()->after('assignments');
$table->string('assignments_hash', 64)->nullable()->after('scope_tags');
$table->string('scope_tags_hash', 64)->nullable()->after('assignments_hash');
$table->index('assignments_hash');
$table->index('scope_tags_hash');
});

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -20,6 +20,7 @@
<php>
<ini name="memory_limit" value="512M"/>
<env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:wk3t/HZYDiSsY6aH6NYb7EzMxZJSxVrMX9y4ojujBoU="/>
<env name="INTUNE_TENANT_ID" value="" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>

View File

@ -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>

View File

@ -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 cant 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>

View File

@ -25,34 +25,38 @@ # Tasks: Managed Tenant Onboarding Wizard UI (v2) (069)
## 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/`
- [ ] T002 [P] Add a focused Pest test file scaffold for onboarding in `tests/Feature/Onboarding/OnboardingSmokeTest.php`
- [X] T001 Create onboarding feature folders `app/Filament/Pages/Onboarding/`, `resources/views/filament/pages/onboarding/`, `tests/Feature/Onboarding/`, `tests/Unit/Onboarding/`
- [X] T002 [P] Add a focused Pest test file scaffold for onboarding in `tests/Feature/Onboarding/OnboardingSmokeTest.php`
---
## 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`)
- [ ] 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`
- [X] T003 Create onboarding sessions migration in `database/migrations/` (new `onboarding_sessions` 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`)
- [ ] T011 [P] Create task-type enum/keys in `app/Support/Onboarding/OnboardingTaskType.php` (stable `task_type` strings)
- [ ] T012 [P] Create task catalog in `app/Support/Onboarding/OnboardingTaskCatalog.php` (prereqs, evidence types, operation run type/job mapping)
- [ ] T013 [P] Create evidence writer service in `app/Services/Onboarding/OnboardingEvidenceWriter.php` (sanitization via `App\\Support\\OpsUx\\RunFailureSanitizer`)
- [ ] T014 [P] Create onboarding lock service in `app/Services/Onboarding/OnboardingLockService.php` (lock acquire/renew/release + takeover)
- [X] T005 [P] Create `OnboardingSession` model in `app/Models/OnboardingSession.php`
- [X] T006 [P] Create `OnboardingEvidence` model in `app/Models/OnboardingEvidence.php`
- [X] T007 [P] Add factories for onboarding models in `database/factories/OnboardingSessionFactory.php` and `database/factories/OnboardingEvidenceFactory.php`
- [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`
- [ ] T020 [P] Add onboarding lock behavior unit tests in `tests/Unit/Onboarding/OnboardingLockServiceTest.php`
- [X] T011 [P] Create task-type enum/keys in `app/Support/Onboarding/OnboardingTaskType.php` (stable `task_type` strings)
- [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.
@ -66,38 +70,41 @@ ## Phase 3: User Story 1 — Onboard a managed tenant with a provider connection
### Tests (write first)
- [ ] 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`
- [ ] 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`
- [ ] 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`
- [ ] 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] T021 [P] [US1] Feature test: Owner can create/resume onboarding session in `tests/Feature/Onboarding/OnboardingSessionLifecycleTest.php`
- [X] T022 [P] [US1] Feature test: non-member is denied-as-not-found (404) in `tests/Feature/Onboarding/OnboardingAuthorizationTest.php`
- [X] T023 [P] [US1] Feature test: readonly can view but cannot mutate in `tests/Feature/Onboarding/OnboardingReadonlyAccessTest.php`
- [X] T059 [P] [US1] Feature test: onboarding plan preview is shown before any task execution in `tests/Feature/Onboarding/OnboardingPlanPreviewTest.php`
- [X] T060 [P] [US1] Feature test: duplicate onboarding/session handling navigates to resume/task board safely in `tests/Feature/Onboarding/OnboardingDuplicateHandlingTest.php`
- [X] T061 [P] [US1] Feature test: consent guidance is visible in Step 4 and is safe/sanitized in `tests/Feature/Onboarding/OnboardingConsentGuidanceTest.php`
- [X] T062 [P] [US1] Feature test: role-aware guidance (capability required messaging) renders for tenant members in `tests/Feature/Onboarding/OnboardingRoleGuidanceTest.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
- [ ] 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] T024 [US1] Add onboarding wizard page in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (5 steps, evidence-driven status)
- [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`
- [ ] T065 [US1] Implement duplicate onboarding/session handling: always resume active session; block conflicting session creation 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`
- [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)
- [ ] 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] T026 [US1] Add “Resume onboarding” entry point on tenant view in `app/Filament/Resources/TenantResource/Pages/ViewTenant.php`
- [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`
- [ ] 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] T028 [US1] Implement provider connection selection/linking in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (uses tenant-scoped `ProviderConnection`, client_secret only)
- [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`)
- [ ] T031 [US1] Add onboarding verify-permissions job in `app/Jobs/Onboarding/OnboardingVerifyPermissionsJob.php` (writes `OnboardingEvidence` via `OnboardingEvidenceWriter`)
- [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`
- [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.
@ -111,23 +118,27 @@ ## Phase 4: User Story 2 — Operate and recover using a task board (Priority: P
### Tests (write first)
- [ ] 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`
- [ ] T035 [P] [US2] Feature test: failing task shows sanitized reason + hints in `tests/Feature/Onboarding/OnboardingFixHintsTest.php`
- [X] T033 [P] [US2] Feature test: task board visible starting step 4 in `tests/Feature/Onboarding/OnboardingTaskBoardVisibilityTest.php`
- [X] T034 [P] [US2] Feature test: concurrency guard blocks second run in `tests/Feature/Onboarding/OnboardingTaskConcurrencyTest.php`
- [X] T035 [P] [US2] Feature test: failing task shows sanitized reason + hints in `tests/Feature/Onboarding/OnboardingFixHintsTest.php`
### 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}`
- [ ] T039 [US2] Implement prerequisite evaluation + disabled actions in `app/Support/Onboarding/OnboardingTaskCatalog.php`
- [ ] T040 [US2] Implement fix-hints mapping from reason codes in `app/Support/Onboarding/OnboardingFixHints.php`
- [X] T036 [US2] Add task board page in `app/Filament/Pages/Onboarding/TenantOnboardingTaskBoard.php` (lists catalog tasks + latest evidence)
- [X] T037 [US2] Add task board Blade view in `resources/views/filament/pages/onboarding/tenant-onboarding-task-board.blade.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.
@ -141,14 +152,14 @@ ## Phase 5: User Story 3 — Collaborate safely across multiple users (Priority:
### Tests (write first)
- [ ] 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] T044 [P] [US3] Feature test: lock acquisition and read-only behavior in `tests/Feature/Onboarding/OnboardingSessionLockTest.php`
- [X] T045 [P] [US3] Feature test: takeover allowed for Owner/Manager only in `tests/Feature/Onboarding/OnboardingSessionTakeoverAuthorizationTest.php`
### Implementation
- [ ] 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`)
- [ ] T048 [US3] Add audit log entries for takeover/handoff in `app/Services/Intune/AuditLogger.php` (new actions `onboarding.takeover`, `onboarding.handoff`)
- [X] T046 [US3] Add lock UI banner + renew-on-interaction behavior in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php`
- [X] T047 [US3] Implement takeover + handoff actions in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (capability-gated, uses `OnboardingLockService`)
- [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.
@ -162,12 +173,12 @@ ## Phase 6: User Story 4 — Review onboarding evidence and history (Priority: P
### 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
- [ ] 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] T050 [US4] Add evidence history section to task board UI in `resources/views/filament/pages/onboarding/tenant-onboarding-task-board.blade.php`
- [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.
@ -175,18 +186,18 @@ ### Implementation
## 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`)
- [ ] 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] T052 [P] Add v1-to-v2 credential migration action in `app/Services/Onboarding/LegacyTenantCredentialMigrator.php` (move `Tenant.app_client_secret` into `provider_credentials`)
- [X] T053 Add v1 migration UI action (Owner only, requires confirmation) in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php`
- [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`
- [ ] T056 [P] Add regression test: onboarding actions use `->requiresConfirmation()` when destructive-like in `tests/Feature/Onboarding/OnboardingDestructiveActionConfirmationTest.php`
- [X] T055 [P] Add regression test: no secrets rendered in onboarding pages in `tests/Feature/Onboarding/OnboardingNoSecretsLeakTest.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`
- [ ] 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] 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] 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`)
- [ ] T058 Run onboarding test subset via `tests/Feature/Onboarding/` using `vendor/bin/sail artisan test --compact`
- [X] T057 Run formatter on changed files (Pint) via `composer.json` scripts (validate using `vendor/bin/sail bin pint`)
- [X] T058 Run onboarding test subset via `tests/Feature/Onboarding/` using `vendor/bin/sail artisan test --compact`
---

View File

@ -51,19 +51,19 @@
// Get available policies (should be empty since policy is already in backup)
$existingPolicyIds = $this->backupSet->items()->pluck('policy_id')->filter()->all();
expect($existingPolicyIds)->toContain($this->policy->id);
// Soft-delete the backup item
$backupItem->delete();
// Verify it's soft-deleted
expect($this->backupSet->items()->count())->toBe(0);
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)
$existingPolicyIds = $this->backupSet->items()->pluck('policy_id')->filter()->all();
expect($existingPolicyIds)->not->toContain($this->policy->id)
->and($existingPolicyIds)->toHaveCount(0);
});
@ -86,7 +86,7 @@
// Try to add the same policy again via BackupService
$service = app(BackupService::class);
$result = $service->addPoliciesToSet(
tenant: $this->tenant,
backupSet: $this->backupSet->refresh(),
@ -129,7 +129,7 @@
// Check available policies - should include the new one but not the deleted one
$existingPolicyIds = $this->backupSet->items()->withTrashed()->pluck('policy_id')->filter()->all();
expect($existingPolicyIds)->toContain($this->policy->id)
->and($existingPolicyIds)->not->toContain($otherPolicy->id);
});

View File

@ -11,6 +11,8 @@
uses(RefreshDatabase::class);
test('policy sync updates selected policies from graph and updates the operation run', function () {
config(['graph.enabled' => true]);
$tenant = Tenant::factory()->create([
'status' => 'active',
]);

View File

@ -5,7 +5,6 @@
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);

View File

@ -4,7 +4,6 @@
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\UiTooltips;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -68,4 +67,3 @@
expect($backupSet->fresh()->trashed())->toBeTrue();
});

View File

@ -80,4 +80,3 @@
expect($restoreRun->fresh()->trashed())->toBeTrue();
});

View File

@ -7,8 +7,8 @@
use App\Support\Auth\UiTooltips;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);

View 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();
});

View File

@ -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');
});

View 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);
});

View 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);
});

View File

@ -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);
});

View File

@ -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');
});

View 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);
});

View 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);
});

View 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');
});

View File

@ -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();
});

View 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');
});

View 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\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');
});

View File

@ -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());
});

View 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');
});

View 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');
});

View 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);
});

View 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);
});

View 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);
});

View 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());
});

View File

@ -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],
]);

View File

@ -0,0 +1,5 @@
<?php
it('boots onboarding test suite', function () {
expect(true)->toBeTrue();
});

View File

@ -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');
});

View 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();
});

View 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');
});

View File

@ -33,4 +33,3 @@
expect($enforcement->bulkSelectionIsAuthorized($user, $tenants))->toBeTrue();
expect($membershipQueries)->toBe(1);
});

View File

@ -125,4 +125,3 @@
expect($enforcement->bulkSelectionIsAuthorized($user, collect([$tenantA, $tenantB])))->toBeTrue();
});

View 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');
});

View File

@ -1,11 +1,11 @@
<?php
use App\Jobs\Operations\PolicyVersionForceDeleteWorkerJob;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Jobs\Operations\PolicyVersionForceDeleteWorkerJob;
use App\Services\OperationRunService;
use App\Services\Operations\TargetScopeConcurrencyLimiter;
use Illuminate\Foundation\Testing\RefreshDatabase;

View File

@ -1,11 +1,11 @@
<?php
use App\Jobs\Operations\PolicyVersionPruneWorkerJob;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Jobs\Operations\PolicyVersionPruneWorkerJob;
use App\Services\OperationRunService;
use App\Services\Operations\TargetScopeConcurrencyLimiter;
use Illuminate\Foundation\Testing\RefreshDatabase;

View 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]');
});

View 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());
});