Compare commits
1 Commits
dev
...
145-tenant
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a33a41be39 |
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -79,6 +79,8 @@ ## Active Technologies
|
|||||||
- PostgreSQL via Laravel Eloquent models and workspace/tenant scoped tables (143-tenant-lifecycle-operability-context-semantics)
|
- PostgreSQL via Laravel Eloquent models and workspace/tenant scoped tables (143-tenant-lifecycle-operability-context-semantics)
|
||||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Gates and Policies, `OperateHubShell`, `OperationRunLinks` (144-canonical-operation-viewer-context-decoupling)
|
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Gates and Policies, `OperateHubShell`, `OperationRunLinks` (144-canonical-operation-viewer-context-decoupling)
|
||||||
- PostgreSQL plus session-backed workspace and remembered tenant context (no schema changes) (144-canonical-operation-viewer-context-decoupling)
|
- PostgreSQL plus session-backed workspace and remembered tenant context (no schema changes) (144-canonical-operation-viewer-context-decoupling)
|
||||||
|
- PHP 8.4.15 with Laravel 12, Filament v5, Livewire v4.0+ + Filament Actions/Tables/Infolists, Laravel Gates/Policies, `UiEnforcement`, `WorkspaceUiEnforcement`, `ActionSurfaceDeclaration`, `BadgeCatalog`, `TenantOperabilityService`, `OnboardingLifecycleService` (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
||||||
|
- PostgreSQL for tenants, onboarding sessions, audit logs, operation runs, and workspace membership data (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -98,8 +100,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 145-tenant-action-taxonomy-lifecycle-safe-visibility: Added PHP 8.4.15 with Laravel 12, Filament v5, Livewire v4.0+ + Filament Actions/Tables/Infolists, Laravel Gates/Policies, `UiEnforcement`, `WorkspaceUiEnforcement`, `ActionSurfaceDeclaration`, `BadgeCatalog`, `TenantOperabilityService`, `OnboardingLifecycleService`
|
||||||
- 144-canonical-operation-viewer-context-decoupling: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Gates and Policies, `OperateHubShell`, `OperationRunLinks`
|
- 144-canonical-operation-viewer-context-decoupling: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Gates and Policies, `OperateHubShell`, `OperationRunLinks`
|
||||||
- 143-tenant-lifecycle-operability-context-semantics: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4
|
- 143-tenant-lifecycle-operability-context-semantics: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4
|
||||||
- 142-rbac-role-definition-diff-ux-upgrade: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, shared `App\Support\Diff` foundation from Spec 141
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -151,7 +151,7 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
if ($this->shouldShowDraftLandingAction()) {
|
if ($this->shouldShowDraftLandingAction()) {
|
||||||
$actions[] = Action::make('back_to_onboarding_landing')
|
$actions[] = Action::make('back_to_onboarding_landing')
|
||||||
->label('All onboarding drafts')
|
->label($this->onboardingDraftLandingActionLabel())
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(route('admin.onboarding'));
|
->url(route('admin.onboarding'));
|
||||||
}
|
}
|
||||||
@ -561,10 +561,10 @@ public function content(Schema $schema): Schema
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
Step::make('Complete')
|
Step::make('Complete')
|
||||||
->description('Review configuration and activate the tenant.')
|
->description('Review configuration and complete onboarding for this tenant.')
|
||||||
->schema([
|
->schema([
|
||||||
Section::make('Review & Activate')
|
Section::make('Review & Complete onboarding')
|
||||||
->description('Review the onboarding summary before activating this tenant.')
|
->description('Review the onboarding summary before completing onboarding for this tenant.')
|
||||||
->schema([
|
->schema([
|
||||||
Section::make('Onboarding summary')
|
Section::make('Onboarding summary')
|
||||||
->compact()
|
->compact()
|
||||||
@ -589,7 +589,7 @@ public function content(Schema $schema): Schema
|
|||||||
->badge()
|
->badge()
|
||||||
->color(fn (): string => $this->completionSummaryBootstrapColor()),
|
->color(fn (): string => $this->completionSummaryBootstrapColor()),
|
||||||
]),
|
]),
|
||||||
Callout::make('After activation')
|
Callout::make('After completion')
|
||||||
->description('This action is recorded in the audit log and cannot be undone from this wizard.')
|
->description('This action is recorded in the audit log and cannot be undone from this wizard.')
|
||||||
->info()
|
->info()
|
||||||
->footer([
|
->footer([
|
||||||
@ -612,19 +612,19 @@ public function content(Schema $schema): Schema
|
|||||||
->maxLength(500),
|
->maxLength(500),
|
||||||
SchemaActions::make([
|
SchemaActions::make([
|
||||||
Action::make('wizardCompleteOnboarding')
|
Action::make('wizardCompleteOnboarding')
|
||||||
->label('Activate tenant')
|
->label('Complete onboarding')
|
||||||
->color('success')
|
->color('success')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading('Activate tenant')
|
->modalHeading('Complete onboarding')
|
||||||
->modalDescription(fn (): string => $this->managedTenant instanceof Tenant
|
->modalDescription(fn (): string => $this->managedTenant instanceof Tenant
|
||||||
? sprintf('Are you sure you want to activate "%s"? This will make the tenant operational.', $this->managedTenant->name)
|
? sprintf('Are you sure you want to complete onboarding for "%s"? This will make the tenant operational.', $this->managedTenant->name)
|
||||||
: 'Are you sure you want to activate this tenant?')
|
: 'Are you sure you want to complete onboarding for this tenant?')
|
||||||
->modalSubmitActionLabel('Yes, activate')
|
->modalSubmitActionLabel('Yes, complete onboarding')
|
||||||
->disabled(fn (): bool => ! $this->canCompleteOnboarding()
|
->disabled(fn (): bool => ! $this->canCompleteOnboarding()
|
||||||
|| ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE))
|
|| ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE))
|
||||||
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)
|
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)
|
||||||
? null
|
? null
|
||||||
: 'Owner required to activate.')
|
: 'Owner required to complete onboarding.')
|
||||||
->action(fn () => $this->completeOnboarding()),
|
->action(fn () => $this->completeOnboarding()),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
@ -743,7 +743,7 @@ private function draftPickerSchema(): array
|
|||||||
Text::make(fn (): string => $draft->created_at?->diffForHumans() ?? '—'),
|
Text::make(fn (): string => $draft->created_at?->diffForHumans() ?? '—'),
|
||||||
SchemaActions::make([
|
SchemaActions::make([
|
||||||
Action::make('resume_draft_'.$draft->getKey())
|
Action::make('resume_draft_'.$draft->getKey())
|
||||||
->label('Resume onboarding draft')
|
->label($this->resumeOnboardingActionLabel())
|
||||||
->action(fn () => $this->resumeOnboardingDraft((int) $draft->getKey(), true)),
|
->action(fn () => $this->resumeOnboardingDraft((int) $draft->getKey(), true)),
|
||||||
Action::make('view_draft_'.$draft->getKey())
|
Action::make('view_draft_'.$draft->getKey())
|
||||||
->label('View summary')
|
->label('View summary')
|
||||||
@ -972,6 +972,27 @@ private function showsNonResumableSummary(): bool
|
|||||||
&& ! $this->canResumeDraft($this->onboardingSession);
|
&& ! $this->canResumeDraft($this->onboardingSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function onboardingDraftLandingActionLabel(): string
|
||||||
|
{
|
||||||
|
$user = $this->currentUser();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return 'Choose onboarding draft';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->onboardingEntryActionDescriptor($this->availableDraftsFor($user)->count())->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resumeOnboardingActionLabel(): string
|
||||||
|
{
|
||||||
|
return $this->onboardingEntryActionDescriptor(1)->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function onboardingEntryActionDescriptor(int $resumableDraftCount): \App\Support\Tenants\TenantActionDescriptor
|
||||||
|
{
|
||||||
|
return TenantResource::tenantActionPolicy()->onboardingEntryDescriptor($resumableDraftCount);
|
||||||
|
}
|
||||||
|
|
||||||
private function shouldShowDraftLandingAction(): bool
|
private function shouldShowDraftLandingAction(): bool
|
||||||
{
|
{
|
||||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
@ -3103,7 +3124,7 @@ public function completeOnboarding(): void
|
|||||||
if (! $overrideBlocked) {
|
if (! $overrideBlocked) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification required')
|
->title('Verification required')
|
||||||
->body('Complete verification for the selected provider connection before finishing onboarding.')
|
->body('Complete verification for the selected provider connection before completing onboarding.')
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -3136,7 +3157,7 @@ public function completeOnboarding(): void
|
|||||||
if (! $connection instanceof ProviderConnection) {
|
if (! $connection instanceof ProviderConnection) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Provider connection required')
|
->title('Provider connection required')
|
||||||
->body('Create or select a provider connection before finishing onboarding.')
|
->body('Create or select a provider connection before completing onboarding.')
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -3168,7 +3189,7 @@ public function completeOnboarding(): void
|
|||||||
$tenant->update(['status' => Tenant::STATUS_ONBOARDING]);
|
$tenant->update(['status' => Tenant::STATUS_ONBOARDING]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->handleDraftConflict('Activation was blocked because the onboarding draft changed.');
|
$this->handleDraftConflict('Completing onboarding was blocked because the onboarding draft changed.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
} catch (OnboardingDraftImmutableException) {
|
} catch (OnboardingDraftImmutableException) {
|
||||||
@ -3178,7 +3199,7 @@ public function completeOnboarding(): void
|
|||||||
$tenant->update(['status' => Tenant::STATUS_ONBOARDING]);
|
$tenant->update(['status' => Tenant::STATUS_ONBOARDING]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->handleImmutableDraft('Activation was blocked because the onboarding draft is no longer editable.');
|
$this->handleImmutableDraft('Completing onboarding was blocked because the onboarding draft is no longer editable.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -673,7 +673,8 @@ public static function table(Table $table): Table
|
|||||||
->label('Migration review')
|
->label('Migration review')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (bool $state): string => $state ? 'Review required' : 'Clear')
|
->formatStateUsing(fn (bool $state): string => $state ? 'Review required' : 'Clear')
|
||||||
->color(fn (bool $state): string => $state ? 'warning' : 'success'),
|
->color(fn (bool $state): string => $state ? 'warning' : 'success')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->sortable(),
|
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->sortable(),
|
||||||
Tables\Columns\TextColumn::make('last_error_reason_code')
|
Tables\Columns\TextColumn::make('last_error_reason_code')
|
||||||
->label('Last error reason')
|
->label('Last error reason')
|
||||||
|
|||||||
@ -24,6 +24,7 @@
|
|||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
use App\Services\Providers\AdminConsentUrlFactory;
|
use App\Services\Providers\AdminConsentUrlFactory;
|
||||||
|
use App\Services\Tenants\TenantActionPolicySurface;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Services\Verification\StartVerification;
|
use App\Services\Verification\StartVerification;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
@ -38,6 +39,8 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Tenants\TenantActionDescriptor;
|
||||||
|
use App\Support\Tenants\TenantActionSurface;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -61,7 +64,6 @@
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -82,6 +84,11 @@ class TenantResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, Collection<int, TenantActionDescriptor>>
|
||||||
|
*/
|
||||||
|
protected static array $tenantActionCatalogCache = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tenant creation is handled exclusively by the onboarding wizard.
|
* Tenant creation is handled exclusively by the onboarding wizard.
|
||||||
* The CRUD create page has been removed.
|
* The CRUD create page has been removed.
|
||||||
@ -135,9 +142,10 @@ public static function canDeleteAny(): bool
|
|||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
|
->withListRowPrimaryActionLimit(2)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Row-level secondary actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most two row actions stay primary; lifecycle-adjacent and contextual secondary actions move under "More".')
|
||||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.');
|
||||||
@ -298,19 +306,56 @@ public static function table(Table $table): Table
|
|||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
|
Actions\Action::make('view')
|
||||||
|
->label('View')
|
||||||
|
->icon('heroicon-o-eye')
|
||||||
|
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])),
|
||||||
|
Actions\Action::make('related_onboarding')
|
||||||
|
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'Resume onboarding')
|
||||||
|
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path')
|
||||||
|
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||||
|
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('restore')
|
||||||
|
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore')
|
||||||
|
->color('success')
|
||||||
|
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
||||||
|
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant')
|
||||||
|
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
||||||
|
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'restore')
|
||||||
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
|
static::restoreTenant($record, $auditLogger);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('archive')
|
||||||
|
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive')
|
||||||
|
->color('danger')
|
||||||
|
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
||||||
|
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant')
|
||||||
|
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
||||||
|
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'archive')
|
||||||
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
|
static::archiveTenant($record, $auditLogger);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->apply(),
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
Actions\Action::make('view')
|
Actions\Action::make('related_onboarding_overflow')
|
||||||
->label('View')
|
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding')
|
||||||
->icon('heroicon-o-eye')
|
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-eye')
|
||||||
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])),
|
|
||||||
Actions\Action::make('related_onboarding')
|
|
||||||
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record) ?? 'View related onboarding')
|
|
||||||
->icon(fn (Tenant $record): string => static::relatedOnboardingDraft($record)?->isResumable() === true
|
|
||||||
&& static::tenantOperability()->canResumeOnboarding($record)
|
|
||||||
? 'heroicon-o-arrow-path'
|
|
||||||
: 'heroicon-o-eye')
|
|
||||||
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||||
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraft($record) instanceof TenantOnboardingSession),
|
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
|
||||||
|
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('syncTenant')
|
Actions\Action::make('syncTenant')
|
||||||
->label('Sync')
|
->label('Sync')
|
||||||
@ -437,47 +482,6 @@ public static function table(Table $table): Table
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('restore')
|
|
||||||
->label('Restore')
|
|
||||||
->color('success')
|
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
|
||||||
->successNotificationTitle('Tenant restored')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (Tenant $record): bool => $record->trashed())
|
|
||||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->restore();
|
|
||||||
|
|
||||||
$auditLogger->logTenantLifecycleAction(
|
|
||||||
tenant: $record,
|
|
||||||
action: AuditActionId::TenantRestored,
|
|
||||||
actor: $user,
|
|
||||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Tenant restored')
|
|
||||||
->body('The tenant is available again in administrative and operating flows.')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('admin_consent')
|
Actions\Action::make('admin_consent')
|
||||||
->label('Grant admin consent')
|
->label('Grant admin consent')
|
||||||
@ -618,46 +622,6 @@ public static function table(Table $table): Table
|
|||||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
->apply(),
|
->apply(),
|
||||||
static::rbacAction(),
|
static::rbacAction(),
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('archive')
|
|
||||||
->label('Archive')
|
|
||||||
->color('danger')
|
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
|
||||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->delete();
|
|
||||||
|
|
||||||
$auditLogger->logTenantLifecycleAction(
|
|
||||||
tenant: $record,
|
|
||||||
action: AuditActionId::TenantArchived,
|
|
||||||
actor: $user,
|
|
||||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Tenant archived')
|
|
||||||
->body('The tenant remains viewable in management and audit flows, but it is no longer selectable as active context.')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('forceDelete')
|
Actions\Action::make('forceDelete')
|
||||||
->label('Force delete')
|
->label('Force delete')
|
||||||
@ -1042,43 +1006,51 @@ public static function tenantOperability(): TenantOperabilityService
|
|||||||
return app(TenantOperabilityService::class);
|
return app(TenantOperabilityService::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function relatedOnboardingDraft(Tenant $tenant): ?TenantOnboardingSession
|
public static function tenantActionPolicy(): TenantActionPolicySurface
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
return app(TenantActionPolicySurface::class);
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TenantOnboardingSession::query()
|
|
||||||
->where('workspace_id', (int) $tenant->workspace_id)
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->orderByDesc('updated_at')
|
|
||||||
->get()
|
|
||||||
->first(fn (TenantOnboardingSession $draft): bool => Gate::forUser($user)->allows('view', $draft));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function relatedOnboardingDraftActionLabel(Tenant $tenant): ?string
|
/**
|
||||||
|
* @return Collection<int, TenantActionDescriptor>
|
||||||
|
*/
|
||||||
|
public static function tenantActionCatalog(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantIndexRow): Collection
|
||||||
{
|
{
|
||||||
$draft = static::relatedOnboardingDraft($tenant);
|
$cacheKey = static::tenantActionCatalogCacheKey($tenant, $surface);
|
||||||
|
|
||||||
if (! $draft instanceof TenantOnboardingSession) {
|
if (! array_key_exists($cacheKey, static::$tenantActionCatalogCache)) {
|
||||||
return null;
|
static::$tenantActionCatalogCache[$cacheKey] = collect(static::tenantActionPolicy()->catalogForTenant($tenant, $surface))->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($draft->isResumable() && static::tenantOperability()->canResumeOnboarding($tenant)) {
|
return static::$tenantActionCatalogCache[$cacheKey];
|
||||||
return 'Resume onboarding';
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if ($draft->isCancelled()) {
|
public static function lifecycleActionDescriptor(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantIndexRow): ?TenantActionDescriptor
|
||||||
return 'View cancelled onboarding';
|
{
|
||||||
}
|
return static::tenantActionDescriptorForSurface($tenant, $surface, 'restore')
|
||||||
|
?? static::tenantActionDescriptorForSurface($tenant, $surface, 'archive');
|
||||||
|
}
|
||||||
|
|
||||||
if ($draft->isCompleted()) {
|
public static function tenantIndexPrimaryAction(Tenant $tenant): ?TenantActionDescriptor
|
||||||
return 'View completed onboarding';
|
{
|
||||||
}
|
$catalog = static::tenantActionCatalog($tenant, TenantActionSurface::TenantIndexRow);
|
||||||
|
|
||||||
return 'View related onboarding';
|
return $catalog[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function relatedOnboardingDraft(Tenant $tenant): ?TenantOnboardingSession
|
||||||
|
{
|
||||||
|
return static::tenantActionPolicy()->relatedOnboardingDraft($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function relatedOnboardingDraftAction(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantViewHeader): ?TenantActionDescriptor
|
||||||
|
{
|
||||||
|
return static::tenantActionDescriptorForSurface($tenant, $surface, 'related_onboarding');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function relatedOnboardingDraftActionLabel(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantViewHeader): ?string
|
||||||
|
{
|
||||||
|
return static::relatedOnboardingDraftAction($tenant, $surface)?->label;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function relatedOnboardingDraftUrl(Tenant $tenant): ?string
|
public static function relatedOnboardingDraftUrl(Tenant $tenant): ?string
|
||||||
@ -1092,6 +1064,128 @@ public static function relatedOnboardingDraftUrl(Tenant $tenant): ?string
|
|||||||
return route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]);
|
return route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function tenantActionDescriptorForSurface(Tenant $tenant, TenantActionSurface $surface, string $key): ?TenantActionDescriptor
|
||||||
|
{
|
||||||
|
$descriptor = static::tenantActionCatalog($tenant, $surface)
|
||||||
|
->first(fn (TenantActionDescriptor $descriptor): bool => $descriptor->key === $key);
|
||||||
|
|
||||||
|
return $descriptor instanceof TenantActionDescriptor ? $descriptor : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function tenantActionCatalogCacheKey(Tenant $tenant, TenantActionSurface $surface): string
|
||||||
|
{
|
||||||
|
$relatedDraft = static::relatedOnboardingDraft($tenant);
|
||||||
|
|
||||||
|
return implode(':', [
|
||||||
|
(string) (auth()->id() ?? 'guest'),
|
||||||
|
$surface->value,
|
||||||
|
(string) ($tenant->getKey() ?? 'missing'),
|
||||||
|
(string) $tenant->status,
|
||||||
|
(string) ($tenant->updated_at?->getTimestamp() ?? 'no-updated-at'),
|
||||||
|
(string) ($tenant->deleted_at?->getTimestamp() ?? 'not-deleted'),
|
||||||
|
(string) ($relatedDraft?->getKey() ?? 'no-draft'),
|
||||||
|
(string) ($relatedDraft?->workflowStatus()->value ?? 'no-draft-status'),
|
||||||
|
(string) ($relatedDraft?->lifecycleState()->value ?? 'no-draft-lifecycle-state'),
|
||||||
|
(string) ($relatedDraft?->updated_at?->getTimestamp() ?? 'no-draft-updated-at'),
|
||||||
|
(string) ($relatedDraft?->completed_at?->getTimestamp() ?? 'no-draft-completed-at'),
|
||||||
|
(string) ($relatedDraft?->cancelled_at?->getTimestamp() ?? 'no-draft-cancelled-at'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function archiveTenant(Tenant $record, WorkspaceAuditLogger $auditLogger): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($record)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptor = static::lifecycleActionDescriptor($record);
|
||||||
|
|
||||||
|
if (! $descriptor instanceof TenantActionDescriptor || $descriptor->key !== 'archive') {
|
||||||
|
Notification::make()
|
||||||
|
->title('Archive unavailable')
|
||||||
|
->body('Only active tenants can be archived from tenant management surfaces.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->delete();
|
||||||
|
|
||||||
|
$auditLogger->logTenantLifecycleAction(
|
||||||
|
tenant: $record,
|
||||||
|
action: AuditActionId::TenantArchived,
|
||||||
|
actor: $user,
|
||||||
|
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title($descriptor->successNotificationTitle ?? 'Tenant archived')
|
||||||
|
->body($descriptor->successNotificationBody ?? 'The tenant remains available for inspection and audit history, but it is no longer selectable as active context.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function restoreTenant(Tenant $record, WorkspaceAuditLogger $auditLogger): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($record)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptor = static::lifecycleActionDescriptor($record);
|
||||||
|
|
||||||
|
if (! $descriptor instanceof TenantActionDescriptor || $descriptor->key !== 'restore') {
|
||||||
|
Notification::make()
|
||||||
|
->title('Restore unavailable')
|
||||||
|
->body('Only archived tenants can be restored from tenant management surfaces.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->restore();
|
||||||
|
|
||||||
|
$auditLogger->logTenantLifecycleAction(
|
||||||
|
tenant: $record,
|
||||||
|
action: AuditActionId::TenantRestored,
|
||||||
|
actor: $user,
|
||||||
|
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title($descriptor->successNotificationTitle ?? 'Tenant restored')
|
||||||
|
->body($descriptor->successNotificationBody ?? 'The tenant is available again in normal tenant management flows and can be selected as active context.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -4,14 +4,12 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Tenants\TenantActionSurface;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
class EditTenant extends EditRecord
|
class EditTenant extends EditRecord
|
||||||
@ -22,38 +20,40 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
|
Actions\Action::make('related_onboarding')
|
||||||
|
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantEditHeader) ?? 'View related onboarding')
|
||||||
|
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-eye')
|
||||||
|
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||||
|
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('restore')
|
||||||
|
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Restore')
|
||||||
|
->color('success')
|
||||||
|
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Restore tenant')
|
||||||
|
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
||||||
|
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'restore')
|
||||||
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
|
TenantResource::restoreTenant($record, $auditLogger);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->tooltip('You do not have permission to restore tenants.')
|
||||||
|
->preserveVisibility()
|
||||||
|
->destructive()
|
||||||
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Action::make('archive')
|
Action::make('archive')
|
||||||
->label('Archive')
|
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Archive')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
|
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Archive tenant')
|
||||||
|
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
||||||
|
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'archive')
|
||||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
$user = auth()->user();
|
TenantResource::archiveTenant($record, $auditLogger);
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->delete();
|
|
||||||
|
|
||||||
$auditLogger->logTenantLifecycleAction(
|
|
||||||
tenant: $record,
|
|
||||||
action: AuditActionId::TenantArchived,
|
|
||||||
actor: $user,
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'internal_tenant_id' => (int) $record->getKey(),
|
|
||||||
'tenant_guid' => (string) $record->tenant_id,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Tenant archived')
|
|
||||||
->body('The tenant remains viewable in management and audit flows, but it is no longer selectable as active context.')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
|||||||
@ -33,34 +33,14 @@ protected function getTableEmptyStateActions(): array
|
|||||||
|
|
||||||
private function makeOnboardingEntryAction(): Actions\Action
|
private function makeOnboardingEntryAction(): Actions\Action
|
||||||
{
|
{
|
||||||
|
$descriptor = TenantResource::tenantActionPolicy()->onboardingEntryDescriptor($this->accessibleResumableDraftCount());
|
||||||
|
|
||||||
return Actions\Action::make('add_tenant')
|
return Actions\Action::make('add_tenant')
|
||||||
->label($this->onboardingEntryLabel())
|
->label($descriptor->label)
|
||||||
->icon($this->onboardingEntryIcon())
|
->icon($descriptor->icon)
|
||||||
->url(route('admin.onboarding'));
|
->url(route('admin.onboarding'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function onboardingEntryLabel(): string
|
|
||||||
{
|
|
||||||
$draftCount = $this->accessibleResumableDraftCount();
|
|
||||||
|
|
||||||
return match (true) {
|
|
||||||
$draftCount === 1 => 'Continue onboarding',
|
|
||||||
$draftCount > 1 => 'Choose onboarding draft',
|
|
||||||
default => 'Add tenant',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function onboardingEntryIcon(): string
|
|
||||||
{
|
|
||||||
$draftCount = $this->accessibleResumableDraftCount();
|
|
||||||
|
|
||||||
return match (true) {
|
|
||||||
$draftCount === 1 => 'heroicon-m-arrow-path',
|
|
||||||
$draftCount > 1 => 'heroicon-m-queue-list',
|
|
||||||
default => 'heroicon-m-plus',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function accessibleResumableDraftCount(): int
|
private function accessibleResumableDraftCount(): int
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|||||||
@ -10,18 +10,17 @@
|
|||||||
use App\Filament\Widgets\Tenant\TenantVerificationReport;
|
use App\Filament\Widgets\Tenant\TenantVerificationReport;
|
||||||
use App\Jobs\RefreshTenantRbacHealthJob;
|
use App\Jobs\RefreshTenantRbacHealthJob;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantOnboardingSession;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Verification\StartVerification;
|
use App\Services\Verification\StartVerification;
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Tenants\TenantActionSurface;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
@ -66,13 +65,10 @@ protected function getHeaderActions(): array
|
|||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
Actions\Action::make('related_onboarding')
|
Actions\Action::make('related_onboarding')
|
||||||
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record) ?? 'View related onboarding')
|
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantViewHeader) ?? 'View related onboarding')
|
||||||
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraft($record)?->isResumable() === true
|
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-eye')
|
||||||
&& TenantResource::tenantOperability()->canResumeOnboarding($record)
|
|
||||||
? 'heroicon-o-arrow-path'
|
|
||||||
: 'heroicon-o-eye')
|
|
||||||
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||||
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraft($record) instanceof TenantOnboardingSession),
|
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
|
||||||
Actions\Action::make('admin_consent')
|
Actions\Action::make('admin_consent')
|
||||||
->label('Grant admin consent')
|
->label('Grant admin consent')
|
||||||
->icon('heroicon-o-clipboard-document')
|
->icon('heroicon-o-clipboard-document')
|
||||||
@ -276,37 +272,15 @@ protected function getHeaderActions(): array
|
|||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('restore')
|
Actions\Action::make('restore')
|
||||||
->label('Restore')
|
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Restore')
|
||||||
->color('success')
|
->color('success')
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => $record->trashed())
|
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalHeading ?? 'Restore tenant')
|
||||||
|
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
||||||
|
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->key === 'restore')
|
||||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
$user = auth()->user();
|
TenantResource::restoreTenant($record, $auditLogger);
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->restore();
|
|
||||||
|
|
||||||
$auditLogger->logTenantLifecycleAction(
|
|
||||||
tenant: $record,
|
|
||||||
action: AuditActionId::TenantRestored,
|
|
||||||
actor: $user,
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'internal_tenant_id' => (int) $record->getKey(),
|
|
||||||
'tenant_guid' => (string) $record->tenant_id,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Tenant restored')
|
|
||||||
->body('The tenant is available again in administrative and operating flows.')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
@ -315,37 +289,15 @@ protected function getHeaderActions(): array
|
|||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('archive')
|
Actions\Action::make('archive')
|
||||||
->label('Archive')
|
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Archive')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalHeading ?? 'Archive tenant')
|
||||||
|
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
||||||
|
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->key === 'archive')
|
||||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
$user = auth()->user();
|
TenantResource::archiveTenant($record, $auditLogger);
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->delete();
|
|
||||||
|
|
||||||
$auditLogger->logTenantLifecycleAction(
|
|
||||||
tenant: $record,
|
|
||||||
action: AuditActionId::TenantArchived,
|
|
||||||
actor: $user,
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'internal_tenant_id' => (int) $record->getKey(),
|
|
||||||
'tenant_guid' => (string) $record->tenant_id,
|
|
||||||
],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Tenant archived')
|
|
||||||
->body('The tenant remains viewable in management and audit flows, but it is no longer selectable as active context.')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Services\Verification\StartVerification;
|
use App\Services\Verification\StartVerification;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiTooltips;
|
use App\Support\Auth\UiTooltips;
|
||||||
@ -189,9 +190,15 @@ protected function getViewData(): array
|
|||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
|
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
|
||||||
|
$canOperate = app(TenantOperabilityService::class)->decisionFor($tenant)->canOperate;
|
||||||
$canStart = $isTenantMember
|
$canStart = $isTenantMember
|
||||||
|
&& $canOperate
|
||||||
&& $user->can(Capabilities::PROVIDER_RUN, $tenant);
|
&& $user->can(Capabilities::PROVIDER_RUN, $tenant);
|
||||||
|
|
||||||
|
$lifecycleNotice = $isTenantMember && ! $canOperate
|
||||||
|
? 'Verification can be started from tenant management only while the tenant is active.'
|
||||||
|
: null;
|
||||||
|
|
||||||
$runData = null;
|
$runData = null;
|
||||||
|
|
||||||
if ($run instanceof OperationRun) {
|
if ($run instanceof OperationRun) {
|
||||||
@ -220,8 +227,10 @@ protected function getViewData(): array
|
|||||||
'report' => $report,
|
'report' => $report,
|
||||||
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
||||||
'isInProgress' => $isInProgress,
|
'isInProgress' => $isInProgress,
|
||||||
|
'showStartAction' => $isTenantMember && $canOperate,
|
||||||
'canStart' => $canStart,
|
'canStart' => $canStart,
|
||||||
'startTooltip' => $isTenantMember && ! $canStart ? UiTooltips::insufficientPermission() : null,
|
'startTooltip' => $isTenantMember && $canOperate && ! $canStart ? UiTooltips::insufficientPermission() : null,
|
||||||
|
'lifecycleNotice' => $lifecycleNotice,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -113,6 +113,14 @@ private function authorizeForWorkspace(User $user, Workspace $workspace, string
|
|||||||
|
|
||||||
return Gate::forUser($user)->allows($capability, $workspace)
|
return Gate::forUser($user)->allows($capability, $workspace)
|
||||||
? Response::allow()
|
? Response::allow()
|
||||||
: Response::deny();
|
: Response::deny($this->forbiddenCapabilityMessage($capability));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function forbiddenCapabilityMessage(string $capability): string
|
||||||
|
{
|
||||||
|
return match ($capability) {
|
||||||
|
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CANCEL => 'You do not have permission to cancel this onboarding draft.',
|
||||||
|
default => 'You do not have permission to continue this onboarding draft.',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
273
app/Services/Tenants/TenantActionPolicySurface.php
Normal file
273
app/Services/Tenants/TenantActionPolicySurface.php
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Tenants;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantOnboardingSession;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Onboarding\OnboardingLifecycleService;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Tenants\TenantActionContext;
|
||||||
|
use App\Support\Tenants\TenantActionDescriptor;
|
||||||
|
use App\Support\Tenants\TenantActionFamily;
|
||||||
|
use App\Support\Tenants\TenantActionSurface;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
class TenantActionPolicySurface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TenantOperabilityService $tenantOperabilityService,
|
||||||
|
private readonly OnboardingLifecycleService $onboardingLifecycleService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function buildContext(Tenant $tenant, TenantActionSurface $surface, ?User $user = null): TenantActionContext
|
||||||
|
{
|
||||||
|
$user ??= auth()->user();
|
||||||
|
$draft = $user instanceof User ? $this->relatedOnboardingDraft($tenant, $user) : null;
|
||||||
|
$lifecycle = $this->tenantOperabilityService->lifecycleFor($tenant);
|
||||||
|
|
||||||
|
return new TenantActionContext(
|
||||||
|
tenant: $tenant,
|
||||||
|
lifecycle: $lifecycle,
|
||||||
|
surface: $surface,
|
||||||
|
relatedOnboardingDraft: $draft,
|
||||||
|
relatedOnboardingIsResumable: $draft instanceof TenantOnboardingSession
|
||||||
|
&& $this->onboardingLifecycleService->canResumeDraft($draft),
|
||||||
|
hasRelatedOnboardingDraft: $draft instanceof TenantOnboardingSession,
|
||||||
|
isArchived: $tenant->trashed() || $this->tenantOperabilityService->canRestore($tenant),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<TenantActionDescriptor>
|
||||||
|
*/
|
||||||
|
public function catalogForTenant(Tenant $tenant, TenantActionSurface $surface, ?User $user = null): array
|
||||||
|
{
|
||||||
|
$context = $this->buildContext($tenant, $surface, $user);
|
||||||
|
$actions = [
|
||||||
|
$this->viewAction(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$primaryAction = $this->primaryActionForContext($context);
|
||||||
|
|
||||||
|
if ($primaryAction instanceof TenantActionDescriptor) {
|
||||||
|
$actions[] = $primaryAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
$secondaryActions = [
|
||||||
|
$this->secondaryRelatedOnboardingActionForContext($context, $primaryAction),
|
||||||
|
$this->secondaryLifecycleActionForContext($context, $primaryAction),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($secondaryActions as $secondaryAction) {
|
||||||
|
if ($secondaryAction instanceof TenantActionDescriptor) {
|
||||||
|
$actions[] = $secondaryAction;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter($actions, static fn (mixed $action): bool => $action instanceof TenantActionDescriptor && $action->visible));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lifecycleActionForTenant(Tenant $tenant): ?TenantActionDescriptor
|
||||||
|
{
|
||||||
|
return $this->lifecycleActionForContext($this->buildContext($tenant, TenantActionSurface::ContextMenu));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function relatedOnboardingActionForTenant(Tenant $tenant, TenantActionSurface $surface, ?User $user = null): ?TenantActionDescriptor
|
||||||
|
{
|
||||||
|
return $this->relatedOnboardingActionForContext($this->buildContext($tenant, $surface, $user));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onboardingEntryDescriptor(int $resumableDraftCount): TenantActionDescriptor
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$resumableDraftCount === 1 => new TenantActionDescriptor(
|
||||||
|
key: 'resume_onboarding',
|
||||||
|
family: TenantActionFamily::OnboardingWorkflow,
|
||||||
|
label: 'Resume onboarding',
|
||||||
|
icon: 'heroicon-m-arrow-path',
|
||||||
|
group: 'primary',
|
||||||
|
),
|
||||||
|
$resumableDraftCount > 1 => new TenantActionDescriptor(
|
||||||
|
key: 'choose_onboarding_draft',
|
||||||
|
family: TenantActionFamily::OnboardingWorkflow,
|
||||||
|
label: 'Choose onboarding draft',
|
||||||
|
icon: 'heroicon-m-queue-list',
|
||||||
|
group: 'primary',
|
||||||
|
),
|
||||||
|
default => new TenantActionDescriptor(
|
||||||
|
key: 'add_tenant',
|
||||||
|
family: TenantActionFamily::OnboardingWorkflow,
|
||||||
|
label: 'Add tenant',
|
||||||
|
icon: 'heroicon-m-plus',
|
||||||
|
group: 'primary',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function relatedOnboardingDraft(Tenant $tenant, ?User $user = null): ?TenantOnboardingSession
|
||||||
|
{
|
||||||
|
$user ??= auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantOnboardingSession::query()
|
||||||
|
->where('workspace_id', (int) $tenant->workspace_id)
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->orderByDesc('updated_at')
|
||||||
|
->get()
|
||||||
|
->first(fn (TenantOnboardingSession $draft): bool => Gate::forUser($user)->allows('view', $draft));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function viewAction(): TenantActionDescriptor
|
||||||
|
{
|
||||||
|
return new TenantActionDescriptor(
|
||||||
|
key: 'view',
|
||||||
|
family: TenantActionFamily::Neutral,
|
||||||
|
label: 'View',
|
||||||
|
icon: 'heroicon-o-eye',
|
||||||
|
group: 'primary',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function primaryActionForContext(TenantActionContext $context): ?TenantActionDescriptor
|
||||||
|
{
|
||||||
|
if ($context->relatedOnboardingIsResumable && $context->lifecycle->canResumeOnboarding()) {
|
||||||
|
return $this->relatedOnboardingActionForContext($context, 'primary');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->lifecycleActionForContext($context, 'primary');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function secondaryLifecycleActionForContext(
|
||||||
|
TenantActionContext $context,
|
||||||
|
?TenantActionDescriptor $primaryAction,
|
||||||
|
): ?TenantActionDescriptor {
|
||||||
|
$action = $this->lifecycleActionForContext($context);
|
||||||
|
|
||||||
|
if (! $action instanceof TenantActionDescriptor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($primaryAction instanceof TenantActionDescriptor && $primaryAction->key === $action->key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $action;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function secondaryRelatedOnboardingActionForContext(
|
||||||
|
TenantActionContext $context,
|
||||||
|
?TenantActionDescriptor $primaryAction,
|
||||||
|
): ?TenantActionDescriptor {
|
||||||
|
$action = $this->relatedOnboardingActionForContext($context);
|
||||||
|
|
||||||
|
if (! $action instanceof TenantActionDescriptor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($primaryAction instanceof TenantActionDescriptor && $primaryAction->key === $action->key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $action;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lifecycleActionForContext(
|
||||||
|
TenantActionContext $context,
|
||||||
|
string $group = 'overflow',
|
||||||
|
): ?TenantActionDescriptor {
|
||||||
|
if (! $context->isGenericTenantManagementSurface()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->tenantOperabilityService->canRestore($context->tenant)) {
|
||||||
|
return new TenantActionDescriptor(
|
||||||
|
key: 'restore',
|
||||||
|
family: TenantActionFamily::LifecycleManagement,
|
||||||
|
label: 'Restore',
|
||||||
|
icon: 'heroicon-o-arrow-uturn-left',
|
||||||
|
destructive: true,
|
||||||
|
requiresConfirmation: true,
|
||||||
|
auditActionId: AuditActionId::TenantRestored,
|
||||||
|
successNotificationTitle: 'Tenant restored',
|
||||||
|
successNotificationBody: 'The tenant is available again in normal tenant management flows and can be selected as active context.',
|
||||||
|
modalHeading: 'Restore tenant',
|
||||||
|
modalDescription: 'Restore this archived tenant so it can be selected again in normal tenant management flows.',
|
||||||
|
group: $group,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->tenantOperabilityService->canArchive($context->tenant)) {
|
||||||
|
return new TenantActionDescriptor(
|
||||||
|
key: 'archive',
|
||||||
|
family: TenantActionFamily::LifecycleManagement,
|
||||||
|
label: 'Archive',
|
||||||
|
icon: 'heroicon-o-archive-box-x-mark',
|
||||||
|
destructive: true,
|
||||||
|
requiresConfirmation: true,
|
||||||
|
auditActionId: AuditActionId::TenantArchived,
|
||||||
|
successNotificationTitle: 'Tenant archived',
|
||||||
|
successNotificationBody: 'The tenant remains available for inspection and audit history, but it is no longer selectable as active context.',
|
||||||
|
modalHeading: 'Archive tenant',
|
||||||
|
modalDescription: 'Archive this tenant to retain it for inspection and audit history while removing it from active management flows.',
|
||||||
|
group: $group,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function relatedOnboardingActionForContext(
|
||||||
|
TenantActionContext $context,
|
||||||
|
string $group = 'overflow',
|
||||||
|
): ?TenantActionDescriptor {
|
||||||
|
$draft = $context->relatedOnboardingDraft;
|
||||||
|
|
||||||
|
if (! $draft instanceof TenantOnboardingSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($context->relatedOnboardingIsResumable && $context->lifecycle->canResumeOnboarding()) {
|
||||||
|
return new TenantActionDescriptor(
|
||||||
|
key: 'related_onboarding',
|
||||||
|
family: TenantActionFamily::OnboardingWorkflow,
|
||||||
|
label: 'Resume onboarding',
|
||||||
|
icon: 'heroicon-o-arrow-path',
|
||||||
|
group: $group,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($draft->isCancelled()) {
|
||||||
|
return new TenantActionDescriptor(
|
||||||
|
key: 'related_onboarding',
|
||||||
|
family: TenantActionFamily::OnboardingWorkflow,
|
||||||
|
label: 'View cancelled onboarding',
|
||||||
|
icon: 'heroicon-o-eye',
|
||||||
|
group: $group,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($draft->isCompleted()) {
|
||||||
|
return new TenantActionDescriptor(
|
||||||
|
key: 'related_onboarding',
|
||||||
|
family: TenantActionFamily::OnboardingWorkflow,
|
||||||
|
label: 'View completed onboarding',
|
||||||
|
icon: 'heroicon-o-eye',
|
||||||
|
group: $group,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TenantActionDescriptor(
|
||||||
|
key: 'related_onboarding',
|
||||||
|
family: TenantActionFamily::OnboardingWorkflow,
|
||||||
|
label: 'View related onboarding',
|
||||||
|
icon: 'heroicon-o-eye',
|
||||||
|
group: $group,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,6 +49,21 @@ public function canResumeOnboarding(Tenant $tenant): bool
|
|||||||
return $this->decisionFor($tenant)->canResumeOnboarding;
|
return $this->decisionFor($tenant)->canResumeOnboarding;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function canArchive(Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
return $this->decisionFor($tenant)->canArchive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canRestore(Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
return $this->decisionFor($tenant)->canRestore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function primaryManagementActionKey(Tenant $tenant, bool $preferOnboarding = false): ?string
|
||||||
|
{
|
||||||
|
return $this->decisionFor($tenant)->primaryManagementActionKey($preferOnboarding);
|
||||||
|
}
|
||||||
|
|
||||||
public function canReferenceInWorkspaceMonitoring(Tenant $tenant): bool
|
public function canReferenceInWorkspaceMonitoring(Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
return $this->decisionFor($tenant)->canReferenceInWorkspaceMonitoring;
|
return $this->decisionFor($tenant)->canReferenceInWorkspaceMonitoring;
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeMapper;
|
use App\Support\Badges\BadgeMapper;
|
||||||
use App\Support\Badges\BadgeSpec;
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
use App\Support\Tenants\TenantLifecycle;
|
||||||
|
|
||||||
final class TenantStatusBadge implements BadgeMapper
|
final class TenantStatusBadge implements BadgeMapper
|
||||||
{
|
{
|
||||||
@ -13,10 +16,10 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
$state = BadgeCatalog::normalizeTenantLifecycle($value);
|
$state = BadgeCatalog::normalizeTenantLifecycle($value);
|
||||||
|
|
||||||
return match ($state) {
|
return match ($state) {
|
||||||
'draft' => new BadgeSpec('Draft', 'gray', 'heroicon-m-document'),
|
'draft' => new BadgeSpec(TenantLifecycle::Draft->label(), 'gray', 'heroicon-m-document'),
|
||||||
'onboarding' => new BadgeSpec('Onboarding', 'warning', 'heroicon-m-arrow-path'),
|
'onboarding' => new BadgeSpec(TenantLifecycle::Onboarding->label(), 'warning', 'heroicon-m-arrow-path'),
|
||||||
'active' => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
|
'active' => new BadgeSpec(TenantLifecycle::Active->label(), 'success', 'heroicon-m-check-circle'),
|
||||||
'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-archive-box'),
|
'archived' => new BadgeSpec(TenantLifecycle::Archived->label(), 'gray', 'heroicon-m-archive-box'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,7 @@ final class UiTooltips
|
|||||||
/**
|
/**
|
||||||
* Tooltip for actions that are unavailable because the tenant is archived.
|
* Tooltip for actions that are unavailable because the tenant is archived.
|
||||||
*/
|
*/
|
||||||
public const TENANT_ARCHIVED = 'This tenant is archived.';
|
public const TENANT_ARCHIVED = 'This tenant is currently archived.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tooltip for actions that are unavailable because a tenant must always have an owner.
|
* Tooltip for actions that are unavailable because a tenant must always have an owner.
|
||||||
|
|||||||
31
app/Support/Tenants/TenantActionContext.php
Normal file
31
app/Support/Tenants/TenantActionContext.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Tenants;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantOnboardingSession;
|
||||||
|
|
||||||
|
final readonly class TenantActionContext
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public Tenant $tenant,
|
||||||
|
public TenantLifecycle $lifecycle,
|
||||||
|
public TenantActionSurface $surface,
|
||||||
|
public ?TenantOnboardingSession $relatedOnboardingDraft,
|
||||||
|
public bool $relatedOnboardingIsResumable,
|
||||||
|
public bool $hasRelatedOnboardingDraft,
|
||||||
|
public bool $isArchived,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function isOnboardingSurface(): bool
|
||||||
|
{
|
||||||
|
return $this->surface->isOnboardingSurface();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isGenericTenantManagementSurface(): bool
|
||||||
|
{
|
||||||
|
return $this->surface->isGenericTenantManagementSurface();
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Support/Tenants/TenantActionDescriptor.php
Normal file
27
app/Support/Tenants/TenantActionDescriptor.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Tenants;
|
||||||
|
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
|
||||||
|
final readonly class TenantActionDescriptor
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $key,
|
||||||
|
public TenantActionFamily $family,
|
||||||
|
public string $label,
|
||||||
|
public string $icon,
|
||||||
|
public bool $visible = true,
|
||||||
|
public bool $enabled = true,
|
||||||
|
public bool $destructive = false,
|
||||||
|
public bool $requiresConfirmation = false,
|
||||||
|
public ?AuditActionId $auditActionId = null,
|
||||||
|
public ?string $successNotificationTitle = null,
|
||||||
|
public ?string $successNotificationBody = null,
|
||||||
|
public ?string $modalHeading = null,
|
||||||
|
public ?string $modalDescription = null,
|
||||||
|
public string $group = 'overflow',
|
||||||
|
) {}
|
||||||
|
}
|
||||||
13
app/Support/Tenants/TenantActionFamily.php
Normal file
13
app/Support/Tenants/TenantActionFamily.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Tenants;
|
||||||
|
|
||||||
|
enum TenantActionFamily: string
|
||||||
|
{
|
||||||
|
case Neutral = 'neutral';
|
||||||
|
case OnboardingWorkflow = 'onboarding_workflow';
|
||||||
|
case LifecycleManagement = 'lifecycle_management';
|
||||||
|
case Readiness = 'readiness';
|
||||||
|
}
|
||||||
29
app/Support/Tenants/TenantActionSurface.php
Normal file
29
app/Support/Tenants/TenantActionSurface.php
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Tenants;
|
||||||
|
|
||||||
|
enum TenantActionSurface: string
|
||||||
|
{
|
||||||
|
case TenantIndexRow = 'tenant_index_row';
|
||||||
|
case TenantViewHeader = 'tenant_view_header';
|
||||||
|
case TenantEditHeader = 'tenant_edit_header';
|
||||||
|
case OnboardingIndexRow = 'onboarding_index_row';
|
||||||
|
case OnboardingDetailHeader = 'onboarding_detail_header';
|
||||||
|
case Widget = 'widget';
|
||||||
|
case ContextMenu = 'context_menu';
|
||||||
|
|
||||||
|
public function isOnboardingSurface(): bool
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::OnboardingIndexRow, self::OnboardingDetailHeader => true,
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isGenericTenantManagementSurface(): bool
|
||||||
|
{
|
||||||
|
return ! $this->isOnboardingSurface();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -101,7 +101,7 @@ public function canOperate(): bool
|
|||||||
|
|
||||||
public function canArchive(): bool
|
public function canArchive(): bool
|
||||||
{
|
{
|
||||||
return $this !== self::Archived;
|
return $this === self::Active;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canRestore(): bool
|
public function canRestore(): bool
|
||||||
@ -118,4 +118,14 @@ public function canReferenceInWorkspaceMonitoring(): bool
|
|||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function allowsManagementAction(string $actionKey): bool
|
||||||
|
{
|
||||||
|
return match ($actionKey) {
|
||||||
|
'resume_onboarding' => $this->canResumeOnboarding(),
|
||||||
|
'archive' => $this->canArchive(),
|
||||||
|
'restore' => $this->canRestore(),
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,4 +16,31 @@ public function __construct(
|
|||||||
public bool $canResumeOnboarding,
|
public bool $canResumeOnboarding,
|
||||||
public bool $canReferenceInWorkspaceMonitoring,
|
public bool $canReferenceInWorkspaceMonitoring,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public function allowsAction(string $actionKey): bool
|
||||||
|
{
|
||||||
|
return match ($actionKey) {
|
||||||
|
'resume_onboarding' => $this->canResumeOnboarding,
|
||||||
|
'archive' => $this->canArchive,
|
||||||
|
'restore' => $this->canRestore,
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function primaryManagementActionKey(bool $preferOnboarding = false): ?string
|
||||||
|
{
|
||||||
|
if ($preferOnboarding && $this->canResumeOnboarding) {
|
||||||
|
return 'resume_onboarding';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->canRestore) {
|
||||||
|
return 'restore';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->canArchive) {
|
||||||
|
return 'archive';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
final class ActionSurfaceDeclaration
|
final class ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
|
private const string LIST_ROW_PRIMARY_ACTION_LIMIT = 'list_row_primary_action_limit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<string, ActionSurfaceSlotRequirement>
|
* @var array<string, ActionSurfaceSlotRequirement>
|
||||||
*/
|
*/
|
||||||
@ -20,6 +22,11 @@ final class ActionSurfaceDeclaration
|
|||||||
*/
|
*/
|
||||||
private array $exemptions = [];
|
private array $exemptions = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
private array $metadata = [];
|
||||||
|
|
||||||
public ActionSurfaceDefaults $defaults;
|
public ActionSurfaceDefaults $defaults;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@ -102,6 +109,30 @@ public function exemption(ActionSurfaceSlot $slot): ?ActionSurfaceExemption
|
|||||||
return $this->exemptions[$slot->value] ?? null;
|
return $this->exemptions[$slot->value] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setMetadata(string $key, mixed $value): self
|
||||||
|
{
|
||||||
|
$this->metadata[$key] = $value;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function metadata(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return $this->metadata[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withListRowPrimaryActionLimit(int $limit): self
|
||||||
|
{
|
||||||
|
return $this->setMetadata(self::LIST_ROW_PRIMARY_ACTION_LIMIT, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listRowPrimaryActionLimit(): ?int
|
||||||
|
{
|
||||||
|
$limit = $this->metadata(self::LIST_ROW_PRIMARY_ACTION_LIMIT);
|
||||||
|
|
||||||
|
return is_int($limit) ? $limit : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, ActionSurfaceSlotRequirement>
|
* @return array<string, ActionSurfaceSlotRequirement>
|
||||||
*/
|
*/
|
||||||
@ -117,4 +148,12 @@ public function exemptions(): array
|
|||||||
{
|
{
|
||||||
return $this->exemptions;
|
return $this->exemptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function metadataValues(): array
|
||||||
|
{
|
||||||
|
return $this->metadata;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ ## Inbox
|
|||||||
|
|
||||||
- Dashboard trend visualizations (sparklines, compliance gauge, drift-over-time chart)
|
- Dashboard trend visualizations (sparklines, compliance gauge, drift-over-time chart)
|
||||||
- Dashboard "Needs Attention" should be visually louder (alert color, icon, severity weighting)
|
- Dashboard "Needs Attention" should be visually louder (alert color, icon, severity weighting)
|
||||||
|
- Dashboard enterprise polish: severity-weighted drift table, actionable alert buttons, progressive disclosure (demoted from Qualified — needs bounded scope before re-qualifying)
|
||||||
- Operations table should show duration + affected policy count
|
- Operations table should show duration + affected policy count
|
||||||
- Density control / comfortable view toggle for admin tables
|
- Density control / comfortable view toggle for admin tables
|
||||||
- Inventory landing page may be redundant — consider pure navigation section
|
- Inventory landing page may be redundant — consider pure navigation section
|
||||||
@ -27,19 +28,6 @@ ## Qualified
|
|||||||
|
|
||||||
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
|
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
|
||||||
|
|
||||||
### Governance Architecture Hardening Wave
|
|
||||||
- **Type**: hardening
|
|
||||||
- **Source**: architecture audit 2026-03-15
|
|
||||||
- **Problem**: The architecture audit surfaced four cross-cutting governance gaps that are too structural for isolated bugfixes: queued execution trust, tenant-owned query canon drift, findings workflow enforcement softness, and convention-driven Livewire trust boundaries.
|
|
||||||
- **Why it matters**: These are enterprise trust issues, not cosmetic cleanup. Left unresolved, they increase the probability of scope drift, authorization decay across async boundaries, workflow bypass, and mutable UI-state trust in a product that manages tenant-sensitive governance flows.
|
|
||||||
- **Proposed direction**: Treat the audit as a candidate wave, not as one umbrella mega-spec. Promote the four candidates individually when slots are available:
|
|
||||||
- Queued execution reauthorization and scope continuity
|
|
||||||
- Tenant-owned query canon and wrong-tenant guards
|
|
||||||
- Findings workflow enforcement and audit backstop
|
|
||||||
- Livewire context locking and trusted-state reduction
|
|
||||||
- **Dependencies**: Audit constitution and candidate detail document in [../audits/tenantpilot-architecture-audit-constitution.md](../audits/tenantpilot-architecture-audit-constitution.md) and [../audits/2026-03-15-audit-spec-candidates.md](../audits/2026-03-15-audit-spec-candidates.md)
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Queued Execution Reauthorization and Scope Continuity
|
### Queued Execution Reauthorization and Scope Continuity
|
||||||
- **Type**: hardening
|
- **Type**: hardening
|
||||||
- **Source**: architecture audit 2026-03-15
|
- **Source**: architecture audit 2026-03-15
|
||||||
@ -58,15 +46,6 @@ ### Tenant-Owned Query Canon and Wrong-Tenant Guards
|
|||||||
- **Dependencies**: Canonical tenant context work in Specs 135 and 136
|
- **Dependencies**: Canonical tenant context work in Specs 135 and 136
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
### Findings Workflow Enforcement and Audit Backstop
|
|
||||||
- **Type**: hardening
|
|
||||||
- **Source**: architecture audit 2026-03-15
|
|
||||||
- **Problem**: Findings lifecycle semantics are strong in the spec, but enforcement still depends too much on service-path discipline. Direct or bypassing state mutation remains too plausible.
|
|
||||||
- **Why it matters**: This is workflow-truth debt in a governance domain. If findings state can drift outside the canonical workflow path, auditability and operator trust degrade together.
|
|
||||||
- **Proposed direction**: Formalize transition enforcement and add an audit backstop so meaningful lifecycle changes cannot silently bypass the intended workflow.
|
|
||||||
- **Dependencies**: Findings workflow SLA (Spec 111), audit log foundation (Spec 134)
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Livewire Context Locking and Trusted-State Reduction
|
### Livewire Context Locking and Trusted-State Reduction
|
||||||
- **Type**: hardening
|
- **Type**: hardening
|
||||||
- **Source**: architecture audit 2026-03-15
|
- **Source**: architecture audit 2026-03-15
|
||||||
@ -79,19 +58,19 @@ ### Livewire Context Locking and Trusted-State Reduction
|
|||||||
### Exception / Risk-Acceptance Workflow for Findings
|
### Exception / Risk-Acceptance Workflow for Findings
|
||||||
- **Type**: feature
|
- **Type**: feature
|
||||||
- **Source**: HANDOVER gap analysis, Spec 111 follow-up
|
- **Source**: HANDOVER gap analysis, Spec 111 follow-up
|
||||||
- **Problem**: Finding has `risk_accepted` status but no formal exception entity. No workflow to accept risk, track justification, or expire acceptance.
|
- **Problem**: Finding has a `risk_accepted` status value but no formal exception lifecycle. Today, accepting risk is a status transition — there is no dedicated entity to record who accepted the risk, why, under what conditions, or when the acceptance expires. No approval workflow, no expiry/renewal semantics, no structured justification. Auditors cannot answer "who accepted this risk, what was the justification, and is it still valid?"
|
||||||
- **Why it matters**: Enterprise compliance requires documented risk acceptance. Auditors ask "who accepted this and when?"
|
- **Why it matters**: Enterprise compliance frameworks (ISO 27001, SOC 2, CIS) require documented, time-bounded risk acceptance with clear ownership. A bare status flag does not meet this bar. Without a formal exception lifecycle, risk acceptance becomes invisible to audit trails and impossible to govern at scale.
|
||||||
- **Proposed direction**: Exception entity linked to Finding, approval flow, expiry tracking, audit trail
|
- **Proposed direction**: First-class `RiskException` (or `FindingException`) entity linked to Finding, with: justification text, owner (actor), `accepted_at`, `expires_at`, renewal/reminder semantics, optional linkage to verification checks or related findings. Approval flow with capability-gated acceptance. Audit trail for creation, renewal, expiry, and revocation. Findings in `risk_accepted` state without a valid exception should surface as governance warnings.
|
||||||
- **Dependencies**: Findings workflow (Spec 111) complete
|
- **Dependencies**: Findings workflow (Spec 111) complete, audit log foundation (Spec 134)
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
### Evidence Pack Entity
|
### Evidence Domain Foundation
|
||||||
- **Type**: feature
|
- **Type**: feature
|
||||||
- **Source**: HANDOVER gap, R2 theme completion
|
- **Source**: HANDOVER gap, R2 theme completion
|
||||||
- **Problem**: Review pack export (Spec 109) exists, permission posture (104/105) exists, but no formal "evidence pack" that bundles these for external audit/compliance submission.
|
- **Problem**: Review pack export (Spec 109) and permission posture reports (104/105) exist as separate output artifacts. There is no first-class evidence domain model that curates, bundles, and tracks these artifacts as a coherent compliance deliverable for external audit submission.
|
||||||
- **Why it matters**: Enterprise customers need a single deliverable for auditors — not separate exports.
|
- **Why it matters**: Enterprise customers need a single, versioned, auditor-ready package — not a collection of separate exports assembled manually. The gap is not export packaging (Spec 109 handles that); it is the absence of an evidence domain layer that owns curation, completeness tracking, and audit-trail linkage.
|
||||||
- **Proposed direction**: Evidence pack = curated bundle of review pack + posture report + findings summary + baseline governance state
|
- **Proposed direction**: Evidence domain model with curated artifact references (review packs, posture reports, findings summaries, baseline governance snapshots). Completeness metadata. Immutable snapshots with generation timestamp and actor. Not a re-implementation of export — a higher-order assembly layer.
|
||||||
- **Dependencies**: Review pack export (109), permission posture (104)
|
- **Dependencies**: Review pack export (109), permission posture (104/105)
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
### Policy Lifecycle / Ghost Policies (Spec 900 refresh)
|
### Policy Lifecycle / Ghost Policies (Spec 900 refresh)
|
||||||
@ -121,33 +100,24 @@ ### Cross-Tenant Compare & Promotion
|
|||||||
- **Dependencies**: Inventory sync, backup/restore mature
|
- **Dependencies**: Inventory sync, backup/restore mature
|
||||||
- **Priority**: medium (high value, high effort)
|
- **Priority**: medium (high value, high effort)
|
||||||
|
|
||||||
### System Console Multi-Workspace Operator
|
### System Console Scope Hardening
|
||||||
|
- **Type**: hardening
|
||||||
|
- **Source**: Spec 113/114 follow-up
|
||||||
|
- **Problem**: The system console (`/system`) needs a clear cross-workspace entitlement model. Current platform capabilities (Spec 114) define per-surface access, but cross-workspace query authorization and scope isolation for platform operators are not yet hardened as a standalone contract.
|
||||||
|
- **Why it matters**: Platform operators acting across workspaces need tight scope boundaries to prevent accidental cross-workspace data exposure in troubleshooting and monitoring flows.
|
||||||
|
- **Proposed direction**: Formalize cross-workspace query authorization model, scope isolation rules for platform operator sessions, and regression coverage for wrong-workspace access in system console surfaces.
|
||||||
|
- **Dependencies**: System console (114) stable, canonical tenant context (Specs 135/136)
|
||||||
|
- **Priority**: low
|
||||||
|
|
||||||
|
### System Console Multi-Workspace Operator UX
|
||||||
- **Type**: feature
|
- **Type**: feature
|
||||||
- **Source**: Spec 113 deferred
|
- **Source**: Spec 113 deferred
|
||||||
- **Problem**: System console (`/system`) currently can't select/filter across workspaces for platform operators.
|
- **Problem**: System console (`/system`) currently can't select/filter across workspaces for platform operators. Triage and monitoring require workspace-by-workspace navigation.
|
||||||
- **Why it matters**: Platform ops need cross-workspace visibility for troubleshooting and monitoring.
|
- **Why it matters**: Platform ops need cross-workspace visibility for troubleshooting and monitoring at scale.
|
||||||
- **Proposed direction**: New UX + entitlement model for system-level operators
|
- **Proposed direction**: Workspace selector/filter in system console views, cross-workspace run aggregation, unified triage entry point.
|
||||||
- **Dependencies**: System console (114) stable
|
- **Dependencies**: System console (114) stable, System Console Scope Hardening
|
||||||
- **Priority**: low
|
- **Priority**: low
|
||||||
|
|
||||||
### Workspace Chooser v2
|
|
||||||
- **Type**: polish
|
|
||||||
- **Source**: Spec 107 deferred backlog
|
|
||||||
- **Problem**: Current chooser is functional but basic. Missing search, sort, favorites, environment badges, last activity display.
|
|
||||||
- **Why it matters**: MSPs with 10+ workspaces need fast navigation.
|
|
||||||
- **Proposed direction**: Search + sort + pins, environment badge (Prod/Test/Staging), last activity per workspace, dropdown switcher in header
|
|
||||||
- **Dependencies**: Workspace chooser v1 (107) stable
|
|
||||||
- **Priority**: low
|
|
||||||
|
|
||||||
### Dashboard Polish (Enterprise-grade)
|
|
||||||
- **Type**: polish
|
|
||||||
- **Source**: Product review 2026-03-08
|
|
||||||
- **Problem**: Current dashboard shows raw numbers without context. No trend indicators, no severity weighting, governance card too small.
|
|
||||||
- **Why it matters**: First impression for evaluators. Enterprise admins compare with Datadog/Vanta/Drata/Intune Portal.
|
|
||||||
- **Proposed direction**: Trend sparklines, compliance gauge, severity-weighted drift table, actionable alert buttons, progressive disclosure
|
|
||||||
- **Dependencies**: Baseline governance (101), alerts (099), drift engine (119) stable
|
|
||||||
- **Priority**: medium
|
|
||||||
|
|
||||||
### Operations Naming Harmonization Across Run Types, Catalog, UI, and Audit
|
### Operations Naming Harmonization Across Run Types, Catalog, UI, and Audit
|
||||||
- **Type**: hardening
|
- **Type**: hardening
|
||||||
- **Source**: coding discovery, operations UX consistency review
|
- **Source**: coding discovery, operations UX consistency review
|
||||||
@ -167,6 +137,68 @@ ### Operations Naming Harmonization Across Run Types, Catalog, UI, and Audit
|
|||||||
- Concrete desired outcome without overdesigning the solution
|
- Concrete desired outcome without overdesigning the solution
|
||||||
- Easy to promote into a full spec once operations-domain work is prioritized
|
- Easy to promote into a full spec once operations-domain work is prioritized
|
||||||
|
|
||||||
|
### Provider Connection Resolution Normalization
|
||||||
|
- **Type**: hardening
|
||||||
|
- **Source**: architecture audit – provider connection resolution analysis
|
||||||
|
- **Problem**: The codebase has a dual-resolution model for provider connections. Gen 2 jobs (`ProviderInventorySyncJob`, `ProviderConnectionHealthCheckJob`, `ProviderComplianceSnapshotJob`) receive an explicit `providerConnectionId` and pass it through the `ProviderOperationStartGate`. Gen 1 jobs (`ExecuteRestoreRunJob`, `EntraGroupSyncJob`, `SyncRoleDefinitionsJob`, policy sync jobs, etc.) do NOT — their called services resolve the default connection at runtime via `MicrosoftGraphOptionsResolver::resolveForTenant()` or internal `resolveProviderConnection()` methods. This creates non-deterministic execution: a job dispatched against one connection may silently execute against a different one if the default changes between dispatch and execution. ~20 services use the Gen 1 implicit resolution pattern.
|
||||||
|
- **Why it matters**: Non-deterministic credential binding is a correctness and audit gap. Enterprise customers need to know exactly which connection identity was used for every Graph API call. The implicit pattern also prevents connection-scoped rate limiting, error attribution, and consent-scope validation. This is the foundational refactor that unblocks all other provider connection improvements.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- Refactor all Gen 1 services to accept an explicit `ProviderConnection` (or `providerConnectionId`) parameter instead of resolving default internally
|
||||||
|
- Update all Gen 1 jobs to accept `providerConnectionId` at dispatch time (resolved at the UI/controller layer via `ProviderOperationStartGate` or equivalent)
|
||||||
|
- Deprecate `MicrosoftGraphOptionsResolver` — callers should use `ProviderGateway::graphOptions($connection)` directly
|
||||||
|
- Ensure `provider_connection_id` is recorded in every `OperationRun` context and audit event
|
||||||
|
- Standardize error handling: all resolution failures produce `ProviderConnectionResolution::blocked()` with structured `ProviderReasonCodes`, not mixed exceptions (`ProviderConfigurationRequiredException`, `RuntimeException`, `InvalidArgumentException`)
|
||||||
|
- **Known affected services** (Gen 1 / implicit resolution): `RestoreService` (line 2913 internal `resolveProviderConnection()`), `PolicySyncService` (lines 58, 450), `PolicySnapshotService` (line 752), `RbacHealthService` (line 192), `InventorySyncService` (line 730 internal `resolveProviderConnection()`), `EntraGroupSyncService`, `RoleDefinitionsSyncService`, `EntraAdminRolesReportService`, `AssignmentBackupService`, `AssignmentRestoreService`, `ScopeTagResolver`, `TenantPermissionService`, `VersionService`, `ConfigurationPolicyTemplateResolver`, `FoundationSnapshotService`, `FoundationMappingService`, `RestoreRiskChecker`, `PolicyCaptureOrchestrator`, `AssignmentFilterResolver`, `RbacOnboardingService`, `TenantConfigService`
|
||||||
|
- **Known affected jobs** (Gen 1 / no explicit connectionId): `ExecuteRestoreRunJob`, `EntraGroupSyncJob`, `SyncRoleDefinitionsJob`, `SyncEntraAdminRolesJob`, plus any job that calls a Gen 1 service
|
||||||
|
- **Gen 2 reference implementations** (correct pattern): `ProviderInventorySyncJob`, `ProviderConnectionHealthCheckJob`, `ProviderComplianceSnapshotJob` — all receive `providerConnectionId`, pass through `ProviderOperationStartGate`, lock row, create `OperationRun` with connection in context
|
||||||
|
- **Key architecture components**:
|
||||||
|
- `ProviderConnectionResolver` — correct, keep as-is. `resolveDefault()` returns `ProviderConnectionResolution` value object
|
||||||
|
- `ProviderOperationStartGate` — canonical dispatch-time gate, correct Gen 2 pattern. Handles 3 operation types: `provider.connection.check`, `inventory_sync`, `compliance.snapshot`
|
||||||
|
- `MicrosoftGraphOptionsResolver` — legacy bridge (32 lines), target for deprecation. Calls `resolveDefault()` internally, hides connection identity
|
||||||
|
- `ProviderGateway` — lower-level primitive, builds graph options from explicit connection. Correct, keep as-is
|
||||||
|
- `ProviderIdentityResolver` — resolves identity (platform vs dedicated) from connection. Correct, keep as-is
|
||||||
|
- Partial unique index on `provider_connections`: `(tenant_id, provider) WHERE is_default = true`
|
||||||
|
- **Out of scope**: UX label changes, UI banners, legacy credential field removal (those are separate candidates below)
|
||||||
|
- **Dependencies**: None — this is the foundational refactor
|
||||||
|
- **Related specs**: Spec 081 (Tenant credential migration CI guardrails), Spec 088 (provider connection model), Spec 089 (provider gateway), Spec 137 (data-layer provider prep)
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Provider Connection UX Clarity
|
||||||
|
- **Type**: polish
|
||||||
|
- **Source**: architecture audit – provider connection resolution analysis
|
||||||
|
- **Problem**: The operator-facing language and information architecture around provider connections creates confusion about why a "default" connection is required, what happens when it's missing, and when actions are tenant-wide vs connection-scoped. Specific issues: (1) "Set as Default" is misleading — it implies preference, but the connection is actually the canonical operational identity; (2) missing-default errors surface as blocked `OperationRun` records or exceptions, but there is no proactive banner/hint on the tenant or connection pages; (3) action labels don't distinguish tenant-wide operations (verify, sync) from connection-scoped operations (health check, test); (4) the singleton auto-promotion (first connection becomes default automatically) is invisible — operators don't understand why their first connection was special.
|
||||||
|
- **Why it matters**: Reduces support friction and operator confusion. Enterprise operators managing multiple tenants need clear, predictable language about connection lifecycle. The current UX makes the correct architecture feel like a bug ("why do I need a default?").
|
||||||
|
- **Proposed direction**:
|
||||||
|
- Rename "Set as Default" → "Promote to Primary" (or "Set as Primary Connection") across all surfaces
|
||||||
|
- Add a missing-primary-connection banner on tenant detail / connection list when no default exists — with a direct "Promote" action
|
||||||
|
- Distinguish action labels: tenant-wide actions ("Sync Tenant", "Verify Tenant") vs connection-scoped actions ("Check Connection Health", "Test Connection")
|
||||||
|
- Improve blocked-notification copy: instead of generic "provider connection required", show "No primary connection configured for [Provider]. Promote a connection to continue."
|
||||||
|
- Show a transient success notification when auto-promotion happens on first connection creation ("This connection was automatically set as primary because it's the first for this provider")
|
||||||
|
- Consider an info tooltip or help text explaining the primary connection concept on the connection resource pages
|
||||||
|
- **Key surfaces to update**: `ProviderConnectionResource` (row actions, header actions, table empty state), `TenantResource` (verify action, connection tab), onboarding wizard consent step, `ProviderNextStepsRegistry` remediation links, notification templates for blocked operations
|
||||||
|
- **Auto-default creation locations** (4 places, need UX feedback): `CreateProviderConnection` action, `TenantOnboardingController`, `AdminConsentCallbackController`, `ManagedTenantOnboardingWizard`
|
||||||
|
- **Out of scope**: Backend resolution refactoring (that's the normalization candidate above), legacy field removal
|
||||||
|
- **Dependencies**: Soft dependency on "Provider Connection Resolution Normalization" — UX improvements are more coherent when the backend consistently uses explicit connections, but many label/banner changes can proceed independently
|
||||||
|
- **Related specs**: Spec 061 (provider connection UX), Spec 088 (provider connection model)
|
||||||
|
- **Priority**: medium
|
||||||
|
|
||||||
|
### Provider Connection Legacy Cleanup
|
||||||
|
- **Type**: hardening
|
||||||
|
- **Source**: architecture audit – provider connection resolution analysis
|
||||||
|
- **Problem**: After normalization is complete, several legacy artifacts remain: (1) `MicrosoftGraphOptionsResolver` — a 32-line convenience bridge that exists only because ~20 services haven't been updated to use explicit connections; (2) service-internal `resolveProviderConnection()` methods in `RestoreService` (line 2913), `InventorySyncService` (line 730), and similar — these are local resolution logic that should not exist once services receive explicit connections; (3) `Tenant` model legacy credential accessors (`app_client_id`, `app_client_secret` fields) — `graphOptions()` already throws `BadMethodCallException`, but the fields and accessors remain; (4) `migration_review_required` flag on `ProviderConnection` — used during the credential migration from tenant-level to connection-level, should be retired once all tenants are migrated.
|
||||||
|
- **Why it matters**: Dead code increases cognitive load and creates false affordances. New developers may use `MicrosoftGraphOptionsResolver` or internal resolution methods thinking they're the correct pattern. Legacy credential fields on `Tenant` suggest credentials still live there. Cleaning up after normalization makes the correct architecture self-documenting.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- Remove `MicrosoftGraphOptionsResolver` class entirely (after normalization ensures zero callers)
|
||||||
|
- Remove all service-internal `resolveProviderConnection()` / `resolveDefault()` methods
|
||||||
|
- Remove legacy credential fields from `Tenant` model (migration to drop columns, update factory, update tests)
|
||||||
|
- Evaluate `migration_review_required` — if all tenants have migrated, remove the flag and related UI (banner, filter)
|
||||||
|
- Update CI guardrails: `NoLegacyTenantGraphOptionsTest` and `NoTenantCredentialRuntimeReadsSpec081Test` can be simplified or removed once the code they guard against is gone
|
||||||
|
- Verify no seeders, factories, or test helpers reference legacy patterns
|
||||||
|
- **Out of scope**: Any new features — this is pure cleanup
|
||||||
|
- **Dependencies**: Hard dependency on "Provider Connection Resolution Normalization" — cleanup cannot proceed until all callers are migrated
|
||||||
|
- **Related specs**: Spec 081 (credential migration guardrails), Spec 088 (provider connection model), Spec 137 (data-layer provider prep)
|
||||||
|
- **Priority**: medium (deferred until normalization is complete)
|
||||||
|
|
||||||
### Support Intake with Context (MVP)
|
### Support Intake with Context (MVP)
|
||||||
- **Type**: feature
|
- **Type**: feature
|
||||||
- **Source**: Product design, operator feedback
|
- **Source**: Product design, operator feedback
|
||||||
@ -178,6 +210,29 @@ ### Support Intake with Context (MVP)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Covered / Absorbed
|
||||||
|
|
||||||
|
> Candidates that were previously qualified but are now substantially covered by existing specs, or were umbrella labels whose children have been promoted individually.
|
||||||
|
|
||||||
|
### Governance Architecture Hardening Wave (umbrella — dissolved)
|
||||||
|
- **Original source**: architecture audit 2026-03-15
|
||||||
|
- **Status**: Dissolved into individual candidates. The four children are now tracked separately in Qualified: Queued Execution Reauthorization, Tenant-Owned Query Canon, Livewire Context Locking. The fourth child (Findings Workflow Enforcement) is absorbed below.
|
||||||
|
- **Reference**: [../audits/tenantpilot-architecture-audit-constitution.md](../audits/tenantpilot-architecture-audit-constitution.md), [../audits/2026-03-15-audit-spec-candidates.md](../audits/2026-03-15-audit-spec-candidates.md)
|
||||||
|
|
||||||
|
### Findings Workflow Enforcement and Audit Backstop
|
||||||
|
- **Original source**: architecture audit 2026-03-15, candidate C
|
||||||
|
- **Status**: Largely absorbed by Spec 111 (findings workflow v2) which defines transition enforcement, timestamp tracking, reason validation, and audit logging. The remaining architectural enforcement gap (model-level bypass prevention) is a hardening follow-up to Spec 111, not a standalone spec-sized problem. Re-qualify only if enforcement softness surfaces as a concrete regression or audit finding.
|
||||||
|
|
||||||
|
### Workspace Chooser v2
|
||||||
|
- **Original source**: Spec 107 deferred backlog
|
||||||
|
- **Status**: Workspace chooser v1 is covered by Spec 107 + semantic fix in Spec 121. The v2 polish items (search, sort, favorites, pins, environment badges) remain tracked as an Inbox entry. Not qualified as a standalone spec candidate at current priority.
|
||||||
|
|
||||||
|
### Dashboard Polish (Enterprise-grade)
|
||||||
|
- **Original source**: Product review 2026-03-08
|
||||||
|
- **Status**: Core tenant dashboard is covered by Spec 058 (drift-first KPIs, needs attention, recent lists). Workspace-level landing is in progress via Spec 129. The remaining polish items (sparklines, compliance gauge, progressive disclosure) are tracked in Inbox. This was demoted because the candidate lacked a bounded spec scope — it read as a wish list rather than a specifiable problem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Planned
|
## Planned
|
||||||
|
|
||||||
> Ready for spec creation. Waiting for slot in active work.
|
> Ready for spec creation. Waiting for slot in active work.
|
||||||
|
|||||||
@ -5,9 +5,12 @@
|
|||||||
<div>
|
<div>
|
||||||
@if ($tenant?->trashed())
|
@if ($tenant?->trashed())
|
||||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-100">
|
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-100">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="text-sm font-semibold">Archived</div>
|
<div class="text-sm font-semibold">Tenant archived</div>
|
||||||
<div class="text-sm">{{ \App\Support\Rbac\UiTooltips::TENANT_ARCHIVED }}</div>
|
<div class="text-sm">{{ \App\Support\Rbac\UiTooltips::TENANT_ARCHIVED }}</div>
|
||||||
|
<div class="text-sm text-amber-800 dark:text-amber-200">
|
||||||
|
This tenant remains available for inspection and audit history, but it is not selectable as active context until you restore it.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -12,10 +12,14 @@
|
|||||||
$report = is_array($report) ? $report : null;
|
$report = is_array($report) ? $report : null;
|
||||||
|
|
||||||
$isInProgress = (bool) ($isInProgress ?? false);
|
$isInProgress = (bool) ($isInProgress ?? false);
|
||||||
|
$showStartAction = (bool) ($showStartAction ?? false);
|
||||||
$canStart = (bool) ($canStart ?? false);
|
$canStart = (bool) ($canStart ?? false);
|
||||||
|
|
||||||
$startTooltip = $startTooltip ?? null;
|
$startTooltip = $startTooltip ?? null;
|
||||||
$startTooltip = is_string($startTooltip) && trim($startTooltip) !== '' ? trim($startTooltip) : null;
|
$startTooltip = is_string($startTooltip) && trim($startTooltip) !== '' ? trim($startTooltip) : null;
|
||||||
|
|
||||||
|
$lifecycleNotice = $lifecycleNotice ?? null;
|
||||||
|
$lifecycleNotice = is_string($lifecycleNotice) && trim($lifecycleNotice) !== '' ? trim($lifecycleNotice) : null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-filament::section
|
<x-filament::section
|
||||||
@ -29,30 +33,36 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@if ($canStart)
|
@if ($showStartAction)
|
||||||
<x-filament::button
|
@if ($canStart)
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
wire:click="startVerification"
|
|
||||||
>
|
|
||||||
Start verification
|
|
||||||
</x-filament::button>
|
|
||||||
@else
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<x-filament::button
|
<x-filament::button
|
||||||
color="gray"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled
|
wire:click="startVerification"
|
||||||
:title="$startTooltip"
|
|
||||||
>
|
>
|
||||||
Start verification
|
Start verification
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
|
@else
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<x-filament::button
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
disabled
|
||||||
|
:title="$startTooltip"
|
||||||
|
>
|
||||||
|
Start verification
|
||||||
|
</x-filament::button>
|
||||||
|
|
||||||
@if ($startTooltip)
|
@if ($startTooltip)
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ $startTooltip }}
|
{{ $startTooltip }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@elseif ($lifecycleNotice)
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $lifecycleNotice }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@ -73,30 +83,36 @@
|
|||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($canStart)
|
@if ($showStartAction)
|
||||||
<x-filament::button
|
@if ($canStart)
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
wire:click="startVerification"
|
|
||||||
>
|
|
||||||
Start verification
|
|
||||||
</x-filament::button>
|
|
||||||
@else
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<x-filament::button
|
<x-filament::button
|
||||||
color="gray"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled
|
wire:click="startVerification"
|
||||||
:title="$startTooltip"
|
|
||||||
>
|
>
|
||||||
Start verification
|
Start verification
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
|
@else
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<x-filament::button
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
disabled
|
||||||
|
:title="$startTooltip"
|
||||||
|
>
|
||||||
|
Start verification
|
||||||
|
</x-filament::button>
|
||||||
|
|
||||||
@if ($startTooltip)
|
@if ($startTooltip)
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ $startTooltip }}
|
{{ $startTooltip }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@elseif ($lifecycleNotice)
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $lifecycleNotice }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@ -119,30 +135,36 @@
|
|||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($canStart)
|
@if ($showStartAction)
|
||||||
<x-filament::button
|
@if ($canStart)
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
wire:click="startVerification"
|
|
||||||
>
|
|
||||||
Start verification
|
|
||||||
</x-filament::button>
|
|
||||||
@else
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<x-filament::button
|
<x-filament::button
|
||||||
color="gray"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled
|
wire:click="startVerification"
|
||||||
:title="$startTooltip"
|
|
||||||
>
|
>
|
||||||
Start verification
|
Start verification
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
|
@else
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<x-filament::button
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
disabled
|
||||||
|
:title="$startTooltip"
|
||||||
|
>
|
||||||
|
Start verification
|
||||||
|
</x-filament::button>
|
||||||
|
|
||||||
@if ($startTooltip)
|
@if ($startTooltip)
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ $startTooltip }}
|
{{ $startTooltip }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@elseif ($lifecycleNotice)
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $lifecycleNotice }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Tenant Action Taxonomy and Lifecycle-Safe Visibility
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-15
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validation pass complete on 2026-03-15.
|
||||||
|
- No clarification markers remained after first draft.
|
||||||
|
- The spec stays aligned with Spec 143 lifecycle semantics and Spec 144 canonical-view semantics while focusing only on tenant action taxonomy and lifecycle-safe visibility.
|
||||||
@ -0,0 +1,303 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Tenant Action Taxonomy Internal Admin Contract
|
||||||
|
version: 0.1.0
|
||||||
|
summary: Internal planning contract for lifecycle-safe tenant action resolution and execution
|
||||||
|
description: |
|
||||||
|
This contract is an internal design artifact for Spec 145. It models the server-side
|
||||||
|
semantics that Filament and Livewire action surfaces must follow. It is not a public API
|
||||||
|
commitment and may be implemented through Livewire actions, controller actions, or service calls.
|
||||||
|
In this spec slice it is design-only and documents the intended resolver and mutation semantics;
|
||||||
|
it does not require adding new public or controller-backed HTTP endpoints.
|
||||||
|
servers:
|
||||||
|
- url: /internal/admin
|
||||||
|
tags:
|
||||||
|
- name: Tenant Action Catalog
|
||||||
|
- name: Tenant Lifecycle Actions
|
||||||
|
- name: Onboarding Workflow Actions
|
||||||
|
paths:
|
||||||
|
/tenants/{tenant}/actions:
|
||||||
|
get:
|
||||||
|
tags: [Tenant Action Catalog]
|
||||||
|
summary: Resolve lifecycle-safe action catalog for a tenant and surface
|
||||||
|
operationId: resolveTenantActions
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
- name: surface
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TenantActionSurface'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant action catalog resolved
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TenantActionCatalogResponse'
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks capability for one or more returned action intents
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: Tenant is outside workspace or tenant entitlement scope
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/tenants/{tenant}/archive:
|
||||||
|
post:
|
||||||
|
tags: [Tenant Lifecycle Actions]
|
||||||
|
summary: Archive an active tenant
|
||||||
|
operationId: archiveTenant
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [confirm]
|
||||||
|
properties:
|
||||||
|
confirm:
|
||||||
|
type: boolean
|
||||||
|
const: true
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant archived
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TenantLifecycleMutationResponse'
|
||||||
|
'403':
|
||||||
|
description: Actor is entitled to inspect but lacks lifecycle-mutation capability
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: Tenant is outside entitlement scope
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'409':
|
||||||
|
description: Tenant is not in a lifecycle state that can be archived
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/tenants/{tenant}/restore:
|
||||||
|
post:
|
||||||
|
tags: [Tenant Lifecycle Actions]
|
||||||
|
summary: Restore an archived tenant
|
||||||
|
operationId: restoreTenant
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [confirm]
|
||||||
|
properties:
|
||||||
|
confirm:
|
||||||
|
type: boolean
|
||||||
|
const: true
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant restored
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TenantLifecycleMutationResponse'
|
||||||
|
'403':
|
||||||
|
description: Actor is entitled to inspect but lacks lifecycle-mutation capability
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: Tenant is outside entitlement scope
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'409':
|
||||||
|
description: Tenant is not archived and cannot be restored
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/onboarding/{onboardingDraft}/actions:
|
||||||
|
get:
|
||||||
|
tags: [Onboarding Workflow Actions]
|
||||||
|
summary: Resolve workflow-specific actions for an onboarding draft
|
||||||
|
operationId: resolveOnboardingActions
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/OnboardingDraftId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Onboarding actions resolved
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [draftId, actions]
|
||||||
|
properties:
|
||||||
|
draftId:
|
||||||
|
type: integer
|
||||||
|
actions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/TenantActionDescriptor'
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks onboarding workflow capability
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: Draft is outside workspace or tenant entitlement scope
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
components:
|
||||||
|
parameters:
|
||||||
|
TenantId:
|
||||||
|
name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: External tenant route key or canonical tenant identifier
|
||||||
|
OnboardingDraftId:
|
||||||
|
name: onboardingDraft
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
schemas:
|
||||||
|
TenantActionSurface:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- tenant_index_row
|
||||||
|
- tenant_view_header
|
||||||
|
- tenant_edit_header
|
||||||
|
- onboarding_index_row
|
||||||
|
- onboarding_detail_header
|
||||||
|
- widget
|
||||||
|
- context_menu
|
||||||
|
TenantActionCatalogResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- tenantId
|
||||||
|
- lifecycle
|
||||||
|
- surface
|
||||||
|
- actions
|
||||||
|
properties:
|
||||||
|
tenantId:
|
||||||
|
type: string
|
||||||
|
lifecycle:
|
||||||
|
type: string
|
||||||
|
enum: [draft, onboarding, active, archived]
|
||||||
|
surface:
|
||||||
|
$ref: '#/components/schemas/TenantActionSurface'
|
||||||
|
actions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/TenantActionDescriptor'
|
||||||
|
TenantActionDescriptor:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- family
|
||||||
|
- label
|
||||||
|
- visible
|
||||||
|
- enabled
|
||||||
|
- destructive
|
||||||
|
- requiresConfirmation
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- view
|
||||||
|
- resume_onboarding
|
||||||
|
- complete_onboarding
|
||||||
|
- archive
|
||||||
|
- restore
|
||||||
|
- view_operations
|
||||||
|
- verify
|
||||||
|
- grant_admin_consent
|
||||||
|
- view_related_onboarding
|
||||||
|
family:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- neutral
|
||||||
|
- onboarding_workflow
|
||||||
|
- lifecycle_management
|
||||||
|
- readiness
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
visible:
|
||||||
|
type: boolean
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
destructive:
|
||||||
|
type: boolean
|
||||||
|
requiresConfirmation:
|
||||||
|
type: boolean
|
||||||
|
capability:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
auditActionId:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
reasonCode:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
group:
|
||||||
|
type: string
|
||||||
|
enum: [primary, secondary, overflow]
|
||||||
|
nullable: true
|
||||||
|
TenantLifecycleMutationResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- tenantId
|
||||||
|
- beforeLifecycle
|
||||||
|
- afterLifecycle
|
||||||
|
- auditActionId
|
||||||
|
properties:
|
||||||
|
tenantId:
|
||||||
|
type: string
|
||||||
|
beforeLifecycle:
|
||||||
|
type: string
|
||||||
|
enum: [draft, onboarding, active, archived]
|
||||||
|
afterLifecycle:
|
||||||
|
type: string
|
||||||
|
enum: [draft, onboarding, active, archived]
|
||||||
|
auditActionId:
|
||||||
|
type: string
|
||||||
|
notificationTitle:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
ErrorResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- code
|
||||||
|
- message
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- not_found
|
||||||
|
- forbidden
|
||||||
|
- invalid_lifecycle
|
||||||
|
- invalid_workflow_state
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
@ -0,0 +1,150 @@
|
|||||||
|
# Data Model: Tenant Action Taxonomy and Lifecycle-Safe Visibility
|
||||||
|
|
||||||
|
## 1. Tenant
|
||||||
|
|
||||||
|
**Type**: Existing persisted model (`App\Models\Tenant`)
|
||||||
|
|
||||||
|
**Core fields**:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `external_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `name`
|
||||||
|
- `status` (`draft`, `onboarding`, `active`, `archived`)
|
||||||
|
- `deleted_at`
|
||||||
|
- `is_current`
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
- belongs to `Workspace`
|
||||||
|
- has many `TenantOnboardingSession`
|
||||||
|
- has many provider/operational records already used elsewhere in the admin plane
|
||||||
|
|
||||||
|
**Validation / invariants**:
|
||||||
|
- `workspace_id` is required
|
||||||
|
- `status` must resolve to a canonical `TenantLifecycle`
|
||||||
|
- soft-delete implies archived semantics on lifecycle-sensitive surfaces
|
||||||
|
- only active tenants are selectable as remembered tenant context
|
||||||
|
|
||||||
|
**Lifecycle transitions relevant to this feature**:
|
||||||
|
- `draft -> onboarding`: onboarding workflow progresses after tenant identification
|
||||||
|
- `onboarding -> active`: onboarding completes through workflow activation semantics
|
||||||
|
- `onboarding -> draft`: last resumable onboarding draft is cancelled and linked tenant is normalized back to draft
|
||||||
|
- `active -> archived`: archive action soft-deletes tenant and sets archived semantics
|
||||||
|
- `archived -> active`: restore action reactivates archived tenant
|
||||||
|
|
||||||
|
## 2. TenantOnboardingSession
|
||||||
|
|
||||||
|
**Type**: Existing persisted model (`App\Models\TenantOnboardingSession`)
|
||||||
|
|
||||||
|
**Core fields**:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id` nullable until linked
|
||||||
|
- `entra_tenant_id`
|
||||||
|
- `current_step`
|
||||||
|
- `lifecycle_state`
|
||||||
|
- `current_checkpoint`
|
||||||
|
- `last_completed_checkpoint`
|
||||||
|
- `state` JSON
|
||||||
|
- `completed_at`
|
||||||
|
- `cancelled_at`
|
||||||
|
- `version`
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
- belongs to `Workspace`
|
||||||
|
- belongs to `Tenant` optionally
|
||||||
|
- belongs to `startedByUser`
|
||||||
|
- belongs to `updatedByUser`
|
||||||
|
|
||||||
|
**Validation / invariants**:
|
||||||
|
- workspace entitlement always required
|
||||||
|
- tenant entitlement required once a linked tenant exists
|
||||||
|
- resumability is workflow-derived, not inferred only from tenant lifecycle
|
||||||
|
- activation readiness is determined by `OnboardingLifecycleService`
|
||||||
|
|
||||||
|
**Lifecycle semantics relevant to this feature**:
|
||||||
|
- drives `Resume onboarding`
|
||||||
|
- drives `Complete onboarding` availability
|
||||||
|
- must remain distinct from archive/restore semantics
|
||||||
|
|
||||||
|
## 3. TenantActionContext
|
||||||
|
|
||||||
|
**Type**: New derived domain object or array DTO proposed by this plan
|
||||||
|
|
||||||
|
**Purpose**: Encapsulate the inputs needed to decide whether an action is visible, enabled, grouped, or executable on a specific surface.
|
||||||
|
|
||||||
|
**Fields**:
|
||||||
|
- `tenant_id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_lifecycle`
|
||||||
|
- `surface` (`tenant_index_row`, `tenant_view_header`, `tenant_edit_header`, `onboarding_index_row`, `onboarding_detail_header`, `widget`, `context_menu`)
|
||||||
|
- `page_category`
|
||||||
|
- `has_related_onboarding_session`
|
||||||
|
- `related_onboarding_is_resumable`
|
||||||
|
- `is_member`
|
||||||
|
- `has_capability`
|
||||||
|
- `is_archived`
|
||||||
|
|
||||||
|
**Derivation sources**:
|
||||||
|
- `TenantOperabilityService`
|
||||||
|
- `OnboardingLifecycleService`
|
||||||
|
- `TenantOnboardingSessionPolicy`
|
||||||
|
- `UiEnforcement` / capability resolvers
|
||||||
|
|
||||||
|
## 4. TenantActionDescriptor
|
||||||
|
|
||||||
|
**Type**: New derived domain object or array DTO proposed by this plan
|
||||||
|
|
||||||
|
**Purpose**: Normalized description of one operator-facing action returned by the tenant-action policy surface.
|
||||||
|
|
||||||
|
**Fields**:
|
||||||
|
- `key` (`view`, `resume_onboarding`, `archive`, `restore`, `view_operations`, `verify`, `grant_admin_consent`)
|
||||||
|
- `family` (`neutral`, `onboarding_workflow`, `lifecycle_management`, `readiness`)
|
||||||
|
- `label`
|
||||||
|
- `visible`
|
||||||
|
- `enabled`
|
||||||
|
- `destructive`
|
||||||
|
- `requires_confirmation`
|
||||||
|
- `capability`
|
||||||
|
- `audit_action_id` nullable
|
||||||
|
- `reason_code` nullable when hidden or disabled
|
||||||
|
- `priority` or `group` to distinguish primary versus overflow actions
|
||||||
|
- `url` or `handler` metadata depending on surface
|
||||||
|
|
||||||
|
**Invariants**:
|
||||||
|
- label must match domain semantics
|
||||||
|
- archive and restore are mutually exclusive for one tenant on one surface
|
||||||
|
- onboarding-only actions and active-only actions are mutually exclusive except in explicit workflow context
|
||||||
|
- `requires_confirmation` must be true for destructive-like lifecycle mutations
|
||||||
|
|
||||||
|
## 5. TenantActionPolicySurface
|
||||||
|
|
||||||
|
**Type**: New service/resolver proposed by this plan
|
||||||
|
|
||||||
|
**Purpose**: Produce `TenantActionDescriptor` collections from `TenantActionContext` and central lifecycle/workflow/RBAC rules.
|
||||||
|
|
||||||
|
**Responsibilities**:
|
||||||
|
- resolve primary and overflow actions per surface
|
||||||
|
- enforce lifecycle-safe visibility before RBAC helper decoration
|
||||||
|
- keep onboarding workflow actions distinct from archive/restore
|
||||||
|
- provide reusable predicates for `canArchiveTenant`, `canRestoreTenant`, `canResumeOnboardingTenant`, `canShowActivationAction`, and `canShowReadinessActions`
|
||||||
|
|
||||||
|
**Non-responsibilities**:
|
||||||
|
- executing mutations
|
||||||
|
- replacing server-side authorization
|
||||||
|
- persisting lifecycle state
|
||||||
|
|
||||||
|
## 6. Audit Lifecycle Events
|
||||||
|
|
||||||
|
**Type**: Existing enum-backed audit vocabulary
|
||||||
|
|
||||||
|
**Relevant values**:
|
||||||
|
- `tenant.archived`
|
||||||
|
- `tenant.restored`
|
||||||
|
- `tenant.returned_to_draft`
|
||||||
|
- `managed_tenant_onboarding.resume`
|
||||||
|
- `managed_tenant_onboarding.cancelled`
|
||||||
|
- `managed_tenant_onboarding.activation`
|
||||||
|
|
||||||
|
**Requirement**:
|
||||||
|
- UI action taxonomy must stay aligned with these events so operator intent can be reconstructed from audit history.
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
# Implementation Plan: Tenant Action Taxonomy and Lifecycle-Safe Visibility
|
||||||
|
|
||||||
|
**Branch**: `145-tenant-action-taxonomy-lifecycle-safe-visibility` | **Date**: 2026-03-15 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/145-tenant-action-taxonomy-lifecycle-safe-visibility/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Spec 145 hardens tenant action semantics by separating lifecycle truth from surface-specific action assembly. The implementation will keep lifecycle as the source of truth in the existing `TenantLifecycle`, `TenantOperabilityService`, and onboarding lifecycle services, while introducing a central tenant-action policy/resolver layer that decides which actions are visible, enabled, labeled, and auditable for each surface.
|
||||||
|
|
||||||
|
The change scope is primarily Filament admin behavior, not data ownership or provider integration. The plan therefore focuses on consolidating duplicated action logic across `TenantResource`, `ViewTenant`, `EditTenant`, onboarding pages, and tenant-linked widgets; preserving existing audit action IDs and badge mappings; and adding focused Pest coverage for lifecycle-specific action visibility, label honesty, and 404 versus 403 authorization semantics.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15 with Laravel 12, Filament v5, Livewire v4.0+
|
||||||
|
**Primary Dependencies**: Filament Actions/Tables/Infolists, Laravel Gates/Policies, `UiEnforcement`, `WorkspaceUiEnforcement`, `ActionSurfaceDeclaration`, `BadgeCatalog`, `TenantOperabilityService`, `OnboardingLifecycleService`
|
||||||
|
**Storage**: PostgreSQL for tenants, onboarding sessions, audit logs, operation runs, and workspace membership data
|
||||||
|
**Testing**: Pest 4 feature tests, Livewire component tests, and unit tests run through Laravel Sail
|
||||||
|
**Target Platform**: Laravel Sail containerized admin web application on macOS development and Linux container deployment
|
||||||
|
**Project Type**: Laravel monolith web application
|
||||||
|
**Performance Goals**: Keep action availability and labeling local, synchronous, and DB-backed at render time; preserve DB-only admin rendering with no new external calls for action visibility decisions
|
||||||
|
**Constraints**: No new ownership boundaries; no raw capability strings; preserve deny-as-not-found for non-members and 403 for in-scope capability denial; destructive-like actions keep `->requiresConfirmation()`; onboarding completion remains workflow-contextual and must not become a generic tenant-table mutation
|
||||||
|
**Scale/Scope**: One core tenant resource plus supporting pages, one onboarding wizard, shared RBAC/UI enforcement helpers, existing badge and audit registries, and focused regression suites under `tests/Feature/Rbac`, `tests/Feature/Onboarding`, `tests/Feature/TenantRBAC`, and `tests/Unit/Tenants`
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
**Pre-Phase 0 Gate: PASS**
|
||||||
|
|
||||||
|
- Inventory-first: no change to inventory, backups, or snapshot ownership; feature is semantics/UI-policy only.
|
||||||
|
- Read/write separation: archive and restore remain explicit tenant lifecycle mutations with confirmation, audit logging, and test coverage.
|
||||||
|
- Graph contract path: no new Microsoft Graph calls are introduced; existing verification and provider actions remain out of scope for contract changes.
|
||||||
|
- Deterministic capabilities: capability checks continue through canonical registries and existing resolver services.
|
||||||
|
- RBAC-UX planes: feature stays within the admin `/admin` plane and preserves 404 versus 403 semantics.
|
||||||
|
- Workspace and tenant isolation: action legitimacy remains based on workspace membership, tenant entitlement, capability, lifecycle, and page context rather than remembered tenant context.
|
||||||
|
- Destructive confirmations: `Archive`, `Restore`, and any future force-delete exposure remain confirmation-gated.
|
||||||
|
- Global search safety: no new global-search surface is introduced, but any touched tenant actions must remain non-member-safe.
|
||||||
|
- Run observability / Ops-UX: no new `OperationRun` workflow is introduced; existing run-producing actions stay under their current contracts.
|
||||||
|
- Badge semantics: tenant lifecycle badges already route through `BadgeCatalog` and `TenantStatusBadge`; design will reuse that path.
|
||||||
|
- UI naming: action labels will remain `Verb + Object` and align with existing audit vocabulary.
|
||||||
|
- Filament Action Surface Contract: in-scope list/detail/onboarding surfaces already declare action surfaces or are governed by the same contract; plan will consolidate action inventories without violating row-action limits.
|
||||||
|
- UX-001: no layout redesign; only action grouping, visibility, and naming semantics are being hardened.
|
||||||
|
|
||||||
|
**Post-Phase 1 Re-check: PASS**
|
||||||
|
|
||||||
|
- Design keeps lifecycle state in existing domain models and introduces only derived policy/value-object artifacts.
|
||||||
|
- Design does not require a new table, queue, external dependency, or provider contract.
|
||||||
|
- Design preserves Filament v5 / Livewire v4 action semantics and keeps panel-provider registration unchanged in `bootstrap/providers.php`.
|
||||||
|
- Design keeps onboarding completion inside onboarding workflow context and avoids collapsing onboarding and archive/restore into a single mutation path.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/145-tenant-action-taxonomy-lifecycle-safe-visibility/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── tenant-action-taxonomy.openapi.yaml
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Resources/
|
||||||
|
│ │ ├── TenantResource.php
|
||||||
|
│ │ └── TenantResource/Pages/
|
||||||
|
│ │ ├── EditTenant.php
|
||||||
|
│ │ ├── ListTenants.php
|
||||||
|
│ │ └── ViewTenant.php
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ └── Workspaces/ManagedTenantOnboardingWizard.php
|
||||||
|
│ └── Widgets/Tenant/
|
||||||
|
├── Models/
|
||||||
|
│ ├── Tenant.php
|
||||||
|
│ └── TenantOnboardingSession.php
|
||||||
|
├── Policies/
|
||||||
|
│ └── TenantOnboardingSessionPolicy.php
|
||||||
|
├── Services/
|
||||||
|
│ ├── Onboarding/
|
||||||
|
│ └── Tenants/TenantOperabilityService.php
|
||||||
|
├── Support/
|
||||||
|
│ ├── Audit/AuditActionId.php
|
||||||
|
│ ├── Badges/
|
||||||
|
│ ├── Rbac/
|
||||||
|
│ ├── Tenants/
|
||||||
|
│ └── Ui/ActionSurface/
|
||||||
|
└── Providers/
|
||||||
|
└── AuthServiceProvider.php
|
||||||
|
|
||||||
|
routes/
|
||||||
|
└── web.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Onboarding/
|
||||||
|
│ ├── Rbac/
|
||||||
|
│ ├── TenantRBAC/
|
||||||
|
│ └── 144/
|
||||||
|
└── Unit/
|
||||||
|
└── Tenants/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Use the existing Laravel monolith structure. Central lifecycle truth remains in `app/Support/Tenants` and `app/Services/Tenants`; surface-specific action assembly is refactored in-place across Filament resources/pages and onboarding workflow pages; regression coverage extends existing Pest suites instead of creating a parallel test namespace.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations or exemptions are required for this plan.
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
# Quickstart: Implementing Spec 145
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
1. Start Sail if it is not already running.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Confirm you are on the feature branch.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git branch --show-current
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected branch: `145-tenant-action-taxonomy-lifecycle-safe-visibility`
|
||||||
|
|
||||||
|
## Implementation Sequence
|
||||||
|
|
||||||
|
1. Add the central tenant-action policy surface.
|
||||||
|
Target areas:
|
||||||
|
- `app/Services/Tenants/`
|
||||||
|
- `app/Support/Tenants/`
|
||||||
|
|
||||||
|
2. Refactor tenant-management surfaces to consume the central action policy.
|
||||||
|
Target areas:
|
||||||
|
- `app/Filament/Resources/TenantResource.php`
|
||||||
|
- `app/Filament/Resources/TenantResource/Pages/ViewTenant.php`
|
||||||
|
- `app/Filament/Resources/TenantResource/Pages/EditTenant.php`
|
||||||
|
- `app/Filament/Resources/TenantResource/Pages/ListTenants.php`
|
||||||
|
|
||||||
|
3. Keep onboarding workflow actions distinct and reuse onboarding lifecycle rules instead of duplicating tenant-surface logic.
|
||||||
|
Target area:
|
||||||
|
- `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||||
|
|
||||||
|
4. Reuse existing centralized semantics instead of adding local mappings.
|
||||||
|
Relevant existing files:
|
||||||
|
- `app/Services/Tenants/TenantOperabilityService.php`
|
||||||
|
- `app/Support/Tenants/TenantLifecycle.php`
|
||||||
|
- `app/Support/Badges/Domains/TenantStatusBadge.php`
|
||||||
|
- `app/Support/Audit/AuditActionId.php`
|
||||||
|
- `app/Support/Rbac/UiEnforcement.php`
|
||||||
|
|
||||||
|
5. Keep User Story 3 implementation boundaries explicit.
|
||||||
|
- `T023` owns runtime reuse of the resolved action catalog across list, detail, and onboarding surfaces.
|
||||||
|
- `T024` owns only action-surface declaration cleanup and overflow-contract alignment.
|
||||||
|
- Do not move catalog-resolution logic into action-surface declarations.
|
||||||
|
|
||||||
|
6. Preserve Filament v5 and Livewire v4 compliance.
|
||||||
|
- No v3/v4 Filament APIs.
|
||||||
|
- No provider registration changes are needed; Laravel 12 panel providers remain in `bootstrap/providers.php`.
|
||||||
|
- Destructive actions remain confirmation-gated.
|
||||||
|
|
||||||
|
## Focused Test Pass
|
||||||
|
|
||||||
|
Run the minimum targeted suite first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Unit/Tenants/TenantOperabilityServiceTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Unit/Tenants/TenantActionPolicySurfaceTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantResourceAuthorizationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantLifecycleActionNamingTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
These focused tests are part of the required regression baseline for this spec slice. Extend them as needed, but do not treat the central action-policy, confirmation-regression, or cross-surface consistency coverage as optional.
|
||||||
|
|
||||||
|
## Formatting
|
||||||
|
|
||||||
|
Run Pint after edits:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Verification Checklist
|
||||||
|
|
||||||
|
1. `draft` tenant on `/admin/tenants`: shows `View` and onboarding-oriented action, not `Archive`.
|
||||||
|
2. `onboarding` tenant on `/admin/tenants/{tenant}`: shows `Resume onboarding` and readiness/support actions, not active-only lifecycle actions.
|
||||||
|
3. `active` tenant on index and detail: shows `Archive`, not onboarding-only lifecycle actions.
|
||||||
|
4. `archived` tenant on index and detail: shows `Restore`, not `Archive` or `Resume onboarding`.
|
||||||
|
5. Onboarding wizard route: onboarding completion remains workflow-contextual and does not become a generic tenant action.
|
||||||
|
6. Non-member access still resolves as 404; in-scope member without capability is denied as 403 or disabled-in-UI per existing helper semantics.
|
||||||
|
|
||||||
|
## Done Criteria
|
||||||
|
|
||||||
|
The implementation is ready for completion review when:
|
||||||
|
|
||||||
|
- lifecycle-action assembly is centralized,
|
||||||
|
- index/detail/onboarding surfaces no longer drift semantically,
|
||||||
|
- audit and badge semantics remain centralized,
|
||||||
|
- targeted Pest coverage passes,
|
||||||
|
- and the Action Surface Contract still validates for touched Filament surfaces.
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
# Phase 0 Research: Tenant Action Taxonomy and Lifecycle-Safe Visibility
|
||||||
|
|
||||||
|
## Decision: Add a dedicated tenant-action policy surface on top of existing lifecycle services
|
||||||
|
|
||||||
|
**Rationale**: `TenantLifecycle` and `TenantOperabilityService` already provide canonical lifecycle and coarse operability booleans, but they do not model surface-specific action families, labels, confirmation semantics, or workflow-specific distinctions. `TenantResource`, `ViewTenant`, and `EditTenant` currently duplicate lifecycle action assembly. A dedicated tenant-action policy or resolver can consume the existing lifecycle decision and produce surface-aware action descriptors without moving workflow or persistence logic into the UI layer.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Extend `TenantOperabilityDecision` with many more booleans: rejected because it would mix domain lifecycle with page-specific action inventory and quickly become a rigid flag bag.
|
||||||
|
- Keep per-surface closures and only rename labels: rejected because the drift problem is already duplication, not just wording.
|
||||||
|
|
||||||
|
## Decision: Keep onboarding completion inside onboarding workflow services and pages
|
||||||
|
|
||||||
|
**Rationale**: `ManagedTenantOnboardingWizard` and `OnboardingLifecycleService` already model resumability, lifecycle checkpoints, blocking reasons, and activation readiness. Treating activation as just another tenant table action would collapse workflow state into generic lifecycle mutation and violate the spec's “Restore is not Activation” rule.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Expose activation directly from `TenantResource` for onboarding tenants: rejected because onboarding readiness is workflow-dependent and not a generic tenant-management mutation.
|
||||||
|
- Reframe restore as activation when a tenant was previously onboarding: rejected because archived recovery and onboarding completion are different audit and operator intents.
|
||||||
|
|
||||||
|
## Decision: Reuse existing audit action IDs and expand taxonomy through policy, not new lifecycle event names
|
||||||
|
|
||||||
|
**Rationale**: The repo already has `AuditActionId::TenantArchived`, `TenantRestored`, `TenantReturnedToDraft`, and onboarding-specific lifecycle audit IDs. The immediate need is semantic consistency between labels, visibility, and these existing audit IDs. That can be achieved without introducing new audit enum values in this spec slice.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Introduce a parallel action-event enum for UI taxonomy: rejected because it would split action naming from the canonical audit vocabulary without immediate benefit.
|
||||||
|
- Leave audit naming for a later hardening pass and ignore it in the plan: rejected because the spec explicitly requires audit-friendly semantics now.
|
||||||
|
|
||||||
|
## Decision: Preserve centralized badge rendering through `BadgeCatalog` and `TenantStatusBadge`
|
||||||
|
|
||||||
|
**Rationale**: Tenant lifecycle badge rendering is already centralized and used by `TenantResource` and other admin surfaces. Spec 145 changes action semantics more than badge semantics, so the correct plan is to reuse `BadgeCatalog` and avoid page-local lifecycle label mappings.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add local status-to-action wording helpers in resource classes: rejected because it would drift from BADGE-001 and duplicate lifecycle knowledge.
|
||||||
|
- Defer all tenant-status rendering considerations to Spec 146 and ignore badges entirely: rejected because touched surfaces still need centralized status semantics during this rollout.
|
||||||
|
|
||||||
|
## Decision: Use existing RBAC enforcement helpers as the execution gate, with the new action policy driving visibility and grouping
|
||||||
|
|
||||||
|
**Rationale**: `UiEnforcement` and `WorkspaceUiEnforcement` already enforce the member-visible-disabled versus non-member-hidden patterns and canonical capability-registry usage. The missing layer is semantic validity before those enforcement helpers are applied. The action policy should therefore decide whether an action is lifecycle-valid for a surface; `UiEnforcement` should continue to decide capability-based disabled state and execution guards.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Move all lifecycle and RBAC logic into `UiEnforcement`: rejected because lifecycle correctness is not purely an RBAC concern.
|
||||||
|
- Let the policy layer also execute authorization side effects: rejected because policies and enforcement helpers already own server-side enforcement.
|
||||||
|
|
||||||
|
## Decision: Extend existing Pest suites rather than creating a new “145” test namespace
|
||||||
|
|
||||||
|
**Rationale**: The workspace already has targeted suites for tenant resource authorization, edit-tenant archive enforcement, onboarding draft lifecycle, tenant switcher scope, archived tenant route access, and tenant operability decisions. Extending those suites keeps regression coverage close to the existing behavior seams and makes future failures easier to localize.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Create a single new spec-numbered feature test file: rejected because the behavior spans unit, Livewire, routing, and onboarding concerns already covered by established suites.
|
||||||
|
- Rely on manual verification because the change is “mostly labels”: rejected because the spec is explicitly semantic hardening, not a cosmetic rename.
|
||||||
|
|
||||||
|
## Decision: No new database schema or provider contract is required
|
||||||
|
|
||||||
|
**Rationale**: The necessary domain inputs already exist on `Tenant`, `TenantOnboardingSession`, audit enums, badge mappers, and operability services. This feature should add derived policy and surface logic only.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add a persistent tenant-action-state table: rejected because action availability is derived from lifecycle, workflow state, entitlement, and page context.
|
||||||
|
- Add Graph/provider metadata to decide archive or restore availability: rejected because these lifecycle actions are local administrative semantics, not provider operations.
|
||||||
@ -0,0 +1,225 @@
|
|||||||
|
# Feature Specification: Tenant Action Taxonomy and Lifecycle-Safe Visibility
|
||||||
|
|
||||||
|
**Feature Branch**: `145-tenant-action-taxonomy-lifecycle-safe-visibility`
|
||||||
|
**Created**: 2026-03-15
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Tenant actions currently blur lifecycle semantics, especially where labels, visibility, and actual persistence behavior do not match. The product needs an explicit and enterprise-grade action taxonomy so tenant actions are predictable, lifecycle-safe, and consistent across list views, detail pages, onboarding flows, and future governance surfaces."
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace + tenant + onboarding-linked tenant actions
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/admin/tenants`
|
||||||
|
- `/admin/tenants/{tenant}`
|
||||||
|
- `/admin/onboarding`
|
||||||
|
- `/admin/onboarding/{onboardingDraft}`
|
||||||
|
- Any tenant-related action surface in tables, infolists, page headers, widgets, and contextual action groups within the admin plane
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Tenants remain workspace-owned records.
|
||||||
|
- Tenant lifecycle semantics remain defined by Spec 143.
|
||||||
|
- Onboarding drafts and sessions remain separate workspace-scoped workflow records that may link to a tenant.
|
||||||
|
- This feature governs action semantics, labels, availability, and lifecycle-safe visibility only.
|
||||||
|
- This feature does not change workspace ownership, tenant ownership, or the hierarchy between tenant and onboarding records.
|
||||||
|
- **RBAC**:
|
||||||
|
- Authorization planes involved: admin `/admin` tenant-management and onboarding surfaces inside the admin plane.
|
||||||
|
- Workspace non-members or users lacking entitlement to the tenant in scope receive deny-as-not-found semantics.
|
||||||
|
- Workspace members who can see the tenant surface but lack the required lifecycle-action capability receive forbidden semantics.
|
||||||
|
- Action legitimacy must be determined from workspace membership, tenant entitlement, capability policy checks, tenant lifecycle, workflow state where relevant, and page context.
|
||||||
|
- Selected header tenant context must never be the primary determinant of whether a tenant lifecycle action is valid.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - See The Right Next Action (Priority: P1)
|
||||||
|
|
||||||
|
As a workspace operator, I need each tenant surface to show the next valid action for that tenant's lifecycle instead of a misleading or impossible action, so that I can move work forward without interpreting hidden persistence rules.
|
||||||
|
|
||||||
|
**Why this priority**: This is the highest-risk operator failure. Misleading lifecycle actions directly cause incorrect administration and erode trust in the product.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by preparing tenants in `draft`, `onboarding`, `active`, and `archived` states and verifying that each in-scope surface shows only lifecycle-valid actions with honest labels.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a tenant is in `draft` or `onboarding`, **When** the operator opens the tenants index or tenant detail page, **Then** the primary lifecycle-oriented action is `Resume onboarding` or another workflow-accurate onboarding action and `Archive` is not shown.
|
||||||
|
2. **Given** a tenant is `active`, **When** the operator opens a tenant management surface, **Then** the surface may show `Archive` and related non-lifecycle actions, but it does not show onboarding-only actions.
|
||||||
|
3. **Given** a tenant is `archived`, **When** the operator opens a tenant management surface, **Then** the surface may show `Restore` where authorized and does not show `Archive` or `Resume onboarding`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Trust Action Labels (Priority: P2)
|
||||||
|
|
||||||
|
As a workspace operator, I need lifecycle actions to be named after the actual domain change they perform, so that I can understand what will happen before I click and later reconstruct the change from UI and audit history.
|
||||||
|
|
||||||
|
**Why this priority**: Honest naming is required for governance, auditability, and a stable operator mental model, but it depends on the lifecycle-safe availability rules established in User Story 1.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by reviewing in-scope lifecycle-changing actions and confirming that archive-like behavior is labeled `Archive`, archive recovery is labeled `Restore`, and onboarding continuation is labeled `Resume onboarding` or equivalent workflow-accurate wording.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a tenant action moves a tenant into retained non-operational archived state, **When** the operator sees the action label, **Then** the action is labeled `Archive` and not `Deactivate`.
|
||||||
|
2. **Given** a tenant action reverses archival of an already established tenant record, **When** the operator sees the action label, **Then** the action is labeled `Restore` and not presented as generic activation or onboarding completion.
|
||||||
|
3. **Given** a tenant action continues onboarding workflow, **When** the operator sees the action label, **Then** the action is labeled `Resume onboarding` or equivalent workflow-accurate wording rather than a generic tenant-management verb.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Get Consistent Cross-Surface Behavior (Priority: P3)
|
||||||
|
|
||||||
|
As a workspace operator, I need tenant action availability to stay consistent across table rows, page headers, infolists, widgets, and contextual groups, so that the same tenant does not appear to support contradictory lifecycle operations depending on where I look.
|
||||||
|
|
||||||
|
**Why this priority**: Cross-surface consistency reduces future drift and prevents authorization or lifecycle bugs from hiding behind page-specific visibility logic.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by comparing the action inventory for the same tenant lifecycle across the tenants index, tenant detail page, onboarding surfaces, and tenant-linked contextual surfaces and confirming that any differences are page-intent-specific rather than contradictory.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the same `active` tenant is shown on the index and detail page, **When** the operator compares visible lifecycle actions, **Then** both surfaces allow `Archive` where authorized and neither surface exposes onboarding-only lifecycle actions.
|
||||||
|
2. **Given** the same `archived` tenant appears in multiple in-scope surfaces, **When** the operator compares visible lifecycle actions, **Then** `Restore` is the only recovery action shown and any unavailable lifecycle actions remain hidden rather than failing after click.
|
||||||
|
3. **Given** the operator lacks the capability to perform a lifecycle mutation but can still inspect the tenant, **When** the operator visits different in-scope surfaces, **Then** the invalid lifecycle action remains unavailable everywhere and the tenant is still viewable where entitlement allows it.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when a tenant has onboarding workflow records but has already become `active`? The product must suppress onboarding-only actions and preserve normal active-tenant actions.
|
||||||
|
- What happens when an archived tenant remains viewable in a history or operations context? Read-only inspection may remain available where policy allows, but active-only or onboarding-only lifecycle actions must remain hidden.
|
||||||
|
- What happens when a user can view a tenant but lacks capability for archive or restore? The tenant remains inspectable, but lifecycle-changing actions stay hidden or resolve to forbidden if reached directly.
|
||||||
|
- What happens when list and detail pages intentionally differ in depth of action inventory? Differences are allowed only when they reflect page context, not contradictory lifecycle semantics.
|
||||||
|
- What happens when onboarding completion is workflow-ready but the operator is outside onboarding context? The product may link back into onboarding workflow, but it must not surface onboarding completion as a casual substitute for restore or archive reversal.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature changes operator-facing lifecycle action semantics and authorization-driven visibility but does not introduce new Microsoft Graph calls, new long-running work, or a new `OperationRun` type. Lifecycle-changing actions such as `Archive` and `Restore` remain auditable mutations. Any implementation slice that changes those actions must preserve confirmation, audit coverage, tenant isolation, and explicit operator feedback.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** This feature does not create or reuse an `OperationRun` for new workflows. It does establish vocabulary and lifecycle-safe linking rules that future operation or audit surfaces must reuse when they present tenant-related actions or cross-links.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** This feature changes authorization behavior in the admin `/admin` plane and tenant-context behavior inside that plane. Cross-plane behavior is not in scope. Non-members or actors lacking tenant entitlement must receive deny-as-not-found behavior. Members who can inspect the tenant but lack the capability for lifecycle mutation must receive forbidden behavior for direct mutation attempts. Server-side enforcement must come from Gates, Policies, or a central tenant-action policy surface using the canonical capability registry rather than raw capability strings or role-name checks. Global search behavior for in-scope tenant records must remain non-member-safe and lifecycle-safe. Lifecycle-changing actions such as `Archive` and `Restore` must require confirmation. Regression coverage must include at least one positive authorization case and one negative authorization case for lifecycle actions.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. This feature does not alter authentication handshake behavior.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** This feature does not define the full tenant-status presentation system, but any badge or status indicator touched in scope must continue to use centralized lifecycle semantics rather than page-local mappings. Full badge hardening remains a follow-up concern for Spec 146.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** The target object is the tenant record and its linked onboarding workflow where applicable. Primary operator verbs are `View`, `Resume onboarding`, `Archive`, `Restore`, `View operations`, and onboarding/readiness support verbs such as `Start verification` where already present. Source disambiguation is used only when necessary to distinguish onboarding workflow from normal tenant management. The same lifecycle vocabulary must be preserved across buttons, confirmation modals, notifications, audit prose, and any future run titles or history records. Implementation-first terms such as `soft delete` or generic `deactivate` must not be primary operator-facing labels when the actual domain semantics are archival.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** This feature modifies Filament tenant-management and onboarding surfaces. The Action Surface Contract is satisfied if the implementation centralizes lifecycle-safe visibility, keeps no more than two visible row actions before overflow where practical, requires confirmation on destructive-like lifecycle mutations, and applies the same action taxonomy across the listed surfaces.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature is not a layout redesign. Existing table, detail, and onboarding layouts remain intact. UX-001 compliance is preserved by keeping list pages as list pages, detail pages as inspection surfaces, and onboarding pages as workflow surfaces while correcting action grouping, empty-state CTAs, and lifecycle-safe action inventory where touched.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-145-001**: The system MUST define a central tenant action taxonomy that distinguishes neutral inspection actions, onboarding workflow actions, lifecycle-management actions, and readiness or verification actions.
|
||||||
|
- **FR-145-002**: The system MUST label each tenant action according to the actual persisted domain behavior it triggers or the actual workflow it continues.
|
||||||
|
- **FR-145-003**: The system MUST NOT use `Deactivate` as the operator-facing label for an action whose real semantics are archive, retirement, or soft-deletion into retained non-operational state.
|
||||||
|
- **FR-145-004**: The system MUST keep onboarding workflow actions distinct from normal tenant-management lifecycle actions in naming, grouping, and availability rules.
|
||||||
|
- **FR-145-005**: `Resume onboarding` MUST be available only for tenants in `draft` or `onboarding` when workflow state and authorization make continuation meaningful.
|
||||||
|
- **FR-145-006**: `Archive` MUST be available only for `active` tenants that satisfy the required capability and page-context rules.
|
||||||
|
- **FR-145-007**: `Restore` MUST be available only for `archived` tenants that satisfy the required capability and page-context rules.
|
||||||
|
- **FR-145-008**: The system MUST treat onboarding completion or activation as conceptually distinct from archive recovery, and MUST NOT present `Restore` as a generic synonym for activation.
|
||||||
|
- **FR-145-009**: Draft and onboarding tenants MUST NOT expose active-only lifecycle actions such as `Archive` on in-scope generic tenant-management surfaces.
|
||||||
|
- **FR-145-010**: Active tenants MUST NOT expose onboarding-only actions such as `Resume onboarding` on in-scope generic tenant-management surfaces unless a page is explicitly onboarding-contextual.
|
||||||
|
- **FR-145-011**: Archived tenants MUST NOT expose onboarding-only or active-only lifecycle actions on in-scope surfaces.
|
||||||
|
- **FR-145-012**: Neutral inspection actions such as `View` and `View operations` MAY remain available across lifecycle states where the actor is entitled and the page context is valid, but they MUST NOT imply operability.
|
||||||
|
- **FR-145-013**: Readiness or verification actions MUST remain semantically distinct from lifecycle transitions and MUST NOT be used as implicit activation semantics.
|
||||||
|
- **FR-145-014**: Action visibility in scope MUST be determined by lifecycle validity, capability policy, workflow readiness where relevant, and page context rather than by raw shortcuts such as `!trashed()` or scattered one-off status checks.
|
||||||
|
- **FR-145-015**: The same lifecycle-action decision MUST be reusable across tables, page header actions, infolists, widgets, and contextual dropdowns so equivalent surfaces do not drift semantically.
|
||||||
|
- **FR-145-016**: When list and detail surfaces intentionally differ, the difference MUST reflect page depth or workflow context and MUST NOT create contradictory lifecycle meaning for the same tenant.
|
||||||
|
- **FR-145-017**: Lifecycle-changing actions in scope MUST remain server-side authorization protected even when they are hidden in the UI.
|
||||||
|
- **FR-145-018**: Tenant lifecycle-action legitimacy MUST NOT depend on the currently selected header tenant context.
|
||||||
|
- **FR-145-019**: Failure paths for invalid lifecycle actions MUST prefer non-visibility over click-then-refusal, and any direct refusal that still occurs MUST identify whether the cause is wrong lifecycle, wrong workflow state, missing capability, or missing entitlement.
|
||||||
|
- **FR-145-020**: Lifecycle-changing action naming and availability MUST remain audit-friendly so archive, restore, and onboarding completion intent can be distinguished later without inference.
|
||||||
|
- **FR-145-021**: In-scope implementations MUST move toward a central tenant-action policy surface or reusable lifecycle-aware predicates rather than expanding ad hoc visibility logic in resource-local code.
|
||||||
|
- **FR-145-022**: The product MUST adopt the conceptual lifecycle-by-action matrix below for in-scope actions, subject to capability and page-context rules.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
||||||
|
|
||||||
|
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||||
|
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Managed tenants index | `/admin/tenants` | Existing create/onboarding entry actions only where already valid | Row click or explicit `View` action | `View` plus one lifecycle-primary action: `Resume onboarding`, `Archive`, or `Restore` depending on lifecycle; any additional actions move into overflow | None in this scope unless already present and lifecycle-safe | Existing tenant creation or onboarding CTA remains singular per UX-001 | Not applicable | Existing save/cancel semantics unchanged | Yes for `Archive` and `Restore` | `Archive` and `Restore` are destructive-like lifecycle changes and require confirmation plus server-side capability enforcement. |
|
||||||
|
| Tenant detail page | `/admin/tenants/{tenant}` | None added in this spec | Route-record inspection | Not applicable | None | Not applicable | `Resume onboarding`, `Archive`, `Restore`, `View operations`, and lifecycle-safe readiness actions when valid | Existing save/cancel semantics unchanged where edit exists | Yes for lifecycle mutations | Detail pages may show richer action inventory than list rows, but lifecycle semantics must remain identical. |
|
||||||
|
| Onboarding index | `/admin/onboarding` | Existing onboarding-entry action only where already valid | Row click or explicit `View onboarding details` | `Resume onboarding` and `View` when linked tenant or draft is inspectable | None in this scope | Existing onboarding CTA remains singular per UX-001 | Not applicable | Existing save/cancel semantics unchanged | Workflow mutations remain audited under onboarding rules | Onboarding pages are workflow surfaces and must not expose `Archive` or `Restore` as peer actions. |
|
||||||
|
| Onboarding detail page | `/admin/onboarding/{onboardingDraft}` | None added in this spec | Route-record inspection | Not applicable | None | Not applicable | `Resume onboarding`, `Complete onboarding` where workflow-ready, `View tenant`, readiness actions, and related support actions where already valid | Existing save/cancel semantics unchanged | Yes for workflow mutations and any lifecycle completion event | `Complete onboarding` belongs to workflow context, not generic tenant-management surfaces. |
|
||||||
|
| Tenant-linked widgets and contextual groups | In-scope admin widgets, infolists, and context menus | None added in this spec | Existing linked tenant affordance | Match the same lifecycle-valid primary action as the parent surface or show no lifecycle mutation | None in this scope | Not applicable | Not applicable | Not applicable | Yes when a lifecycle mutation is exposed | Exemption: widget-specific secondary actions may vary by surface, but lifecycle action meaning and availability must not contradict the core matrix. |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Tenant**: A durable workspace-owned record with lifecycle state and lifecycle-governed management semantics.
|
||||||
|
- **Tenant Lifecycle State**: The canonical state set inherited from Spec 143: `draft`, `onboarding`, `active`, and `archived`.
|
||||||
|
- **Onboarding Workflow Record**: A workspace-scoped draft or session that governs onboarding progression and may link to a tenant.
|
||||||
|
- **Tenant Action Taxonomy**: The product-level classification of tenant actions into neutral inspection, onboarding workflow, lifecycle management, and readiness or verification actions.
|
||||||
|
- **Tenant Action Policy Surface**: The central decision layer that resolves whether a given action is semantically valid, visible, and executable for a tenant in a given lifecycle and page context.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-145-001**: In focused regression coverage across in-scope tenant surfaces, 100% of archive-like actions are labeled `Archive` and 0 are labeled `Deactivate`.
|
||||||
|
- **SC-145-002**: In focused regression coverage across in-scope tenant surfaces, 100% of `Restore` actions appear only for `archived` tenants and 0 appear for `draft`, `onboarding`, or `active` tenants.
|
||||||
|
- **SC-145-003**: In focused regression coverage across in-scope tenant surfaces, 100% of `Resume onboarding` actions appear only for `draft` or `onboarding` tenants and 0 appear for `active` or `archived` tenants outside explicit onboarding workflow context.
|
||||||
|
- **SC-145-004**: In focused regression coverage, the same tenant lifecycle does not expose contradictory lifecycle-changing actions across the tenants index and tenant detail page for any covered tenant state.
|
||||||
|
- **SC-145-005**: In focused authorization coverage, 100% of non-member or non-entitled lifecycle-action access attempts resolve as deny-as-not-found, and 100% of in-scope capability denials resolve as forbidden.
|
||||||
|
- **SC-145-006**: In focused UX validation, 100% of covered lifecycle-changing actions use operator-facing names that match the underlying lifecycle outcome and can be distinguished in later audit review without inference.
|
||||||
|
|
||||||
|
## Lifecycle-By-Action Matrix
|
||||||
|
|
||||||
|
| Lifecycle | View | Resume onboarding | Complete onboarding / Activate | Archive | Restore | View operations | Verification / readiness actions |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Draft | Yes | Yes | Workflow-dependent | No | No | Usually yes when records exist | Yes when meaningful |
|
||||||
|
| Onboarding | Yes | Yes | Workflow-dependent | No | No | Yes | Yes |
|
||||||
|
| Active | Yes | No | No | Yes | No | Yes | Yes |
|
||||||
|
| Archived | Yes where policy allows | No | No | No | Yes | Yes where policy allows | Usually no unless explicitly read-only support exists |
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Tenant-related actions currently blur lifecycle semantics by mixing archive behavior, onboarding continuation, neutral viewing, and readiness support under labels and visibility rules that do not consistently match actual product behavior. This creates operator confusion, weakens audit clarity, and makes future governance features harder to implement safely.
|
||||||
|
|
||||||
|
This feature defines a formal tenant action taxonomy and lifecycle-safe visibility model so that action meaning, action availability, and persisted domain behavior align across list views, detail pages, onboarding flows, and future governance surfaces.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Define a clear central taxonomy for tenant actions.
|
||||||
|
- Align action labels with actual domain behavior.
|
||||||
|
- Make lifecycle-changing actions available only when semantically valid for the current tenant lifecycle.
|
||||||
|
- Keep onboarding workflow actions distinct from normal active-tenant management actions.
|
||||||
|
- Reduce duplicated visibility logic across list views, detail pages, widgets, and contextual groups.
|
||||||
|
- Create an enterprise-grade action model that future audit, governance, and read-only surfaces can inherit.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- This feature does not redesign the onboarding workflow end to end.
|
||||||
|
- This feature does not introduce a generalized lifecycle state-machine engine.
|
||||||
|
- This feature does not create new tenant lifecycle states beyond those accepted in Spec 143.
|
||||||
|
- This feature does not define the full visual design system for buttons, badges, or menus.
|
||||||
|
- This feature does not introduce permanent deletion as a required surface, though it reserves taxonomy space for a separate destructive tier.
|
||||||
|
- This feature does not change workspace or tenant ownership boundaries.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Spec 143 remains the source of truth for canonical tenant lifecycle semantics.
|
||||||
|
- Spec 144 remains the source of truth for canonical operation-viewer legitimacy when tenant-linked records cross lifecycle or header-context boundaries.
|
||||||
|
- In-scope tenant actions already exist across multiple surfaces and need semantic hardening rather than a parallel action system.
|
||||||
|
- Existing capability registry and authorization primitives can support central lifecycle-aware predicates without redefining the product's ownership model.
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
1. Immediate semantic correction: replace misleading lifecycle labels, remove obviously invalid lifecycle actions from the wrong states, and ensure onboarding tenants expose workflow-appropriate next actions.
|
||||||
|
2. Predicate centralization: move scattered lifecycle-action visibility checks into central lifecycle-aware rules reused by list, detail, onboarding, and contextual surfaces.
|
||||||
|
3. Lifecycle hardening: keep archive, restore, and onboarding completion technically and conceptually distinct, and align downstream audit or event naming with the corrected taxonomy.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Renaming labels without fixing visibility logic would leave the underlying semantics inconsistent.
|
||||||
|
- Over-centralizing without page-context nuance could suppress legitimate detail-page actions that do not belong on list pages.
|
||||||
|
- Hiding onboarding actions without clear workflow continuation paths would make onboarding tenants feel blocked or lost.
|
||||||
|
- Failing to keep restore distinct from onboarding completion would preserve ambiguity in both UX and audit interpretation.
|
||||||
|
|
||||||
|
## Follow-Up Dependencies
|
||||||
|
|
||||||
|
- Spec 146 — Central Tenant Status Presentation
|
||||||
|
- Spec 147 — Tenant Selector and Remembered Context Enforcement
|
||||||
|
- Spec 148 — Central Tenant Operability Policy
|
||||||
|
- Future audit hardening work to align lifecycle event naming and operator-visible history with the corrected action taxonomy
|
||||||
|
|
||||||
|
## Final Direction
|
||||||
|
|
||||||
|
Tenant actions must behave like explicit domain operations rather than loosely named UI affordances. Onboarding actions remain onboarding actions, archive means archive, restore means restore, active-tenant management remains distinct from onboarding workflow, and lifecycle-changing actions appear only when they are semantically valid for the current tenant.
|
||||||
@ -0,0 +1,196 @@
|
|||||||
|
# Tasks: Tenant Action Taxonomy and Lifecycle-Safe Visibility
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/145-tenant-action-taxonomy-lifecycle-safe-visibility/`
|
||||||
|
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
|
||||||
|
|
||||||
|
**Tests**: Runtime behavior changes in this repo require Pest coverage. This feature changes runtime behavior across tenant-management and onboarding surfaces, so tests are required for every user story.
|
||||||
|
**Operations**: This feature does not introduce new long-running or remote work. Existing run-producing actions remain under their current Ops-UX contracts and are not expanded here.
|
||||||
|
**RBAC**: This feature changes authorization-driven action visibility. Tasks below include central policy enforcement, canonical capability-registry usage, explicit 404 versus 403 semantics, and positive/negative authorization tests.
|
||||||
|
**Global Search**: This feature does not redesign tenant global search, but because the spec carries RBAC-UX safety requirements, tasks below include an explicit regression check that touched tenant action semantics do not introduce non-member leakage or tenant-context unsafe search behavior.
|
||||||
|
**UI Naming**: This feature changes operator-facing action labels, modal titles, notifications, and audit-aligned helper copy. Tasks below standardize `View`, `Resume onboarding`, `Archive`, and `Restore` vocabulary across surfaces.
|
||||||
|
**Filament UI Action Surfaces**: This feature modifies Filament resources and pages. Tasks below keep list/detail/onboarding action surfaces aligned with the spec matrix, maintain max two visible row actions before overflow, preserve inspection affordances, and keep destructive-like actions confirmation-gated.
|
||||||
|
**Filament UI UX-001**: This feature is not a layout redesign. Tasks below keep existing layouts intact while hardening action grouping and lifecycle-safe affordances.
|
||||||
|
**Badges**: Tenant lifecycle badge semantics remain centralized; tasks below ensure touched surfaces continue to derive lifecycle wording from shared badge/lifecycle helpers rather than ad hoc mappings.
|
||||||
|
**Contract Artifact**: `/specs/145-tenant-action-taxonomy-lifecycle-safe-visibility/contracts/tenant-action-taxonomy.openapi.yaml` is a design contract for internal action semantics and resolver shape, not a commitment to add public controller endpoints in this spec slice.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so each story can be implemented and tested independently.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Prepare shared fixtures and test entry points used by the rest of the implementation.
|
||||||
|
|
||||||
|
- [X] T001 Create shared tenant lifecycle/action test helpers in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Pest.php`
|
||||||
|
- [X] T002 [P] Create the new regression test entry points in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Tenants/TenantActionPolicySurfaceTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantLifecycleActionNamingTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Build the central tenant-action semantics layer that all user stories depend on.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T003 [P] Add failing foundational unit coverage for lifecycle-safe action predicates in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Tenants/TenantActionPolicySurfaceTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Tenants/TenantOperabilityServiceTest.php`
|
||||||
|
- [X] T004 Create tenant action value objects and enums in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantActionContext.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantActionDescriptor.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantActionFamily.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantActionSurface.php`
|
||||||
|
- [X] T005 Implement the central resolver in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Tenants/TenantActionPolicySurface.php` using `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Tenants/TenantOperabilityService.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Onboarding/OnboardingLifecycleService.php`
|
||||||
|
- [X] T006 Update reusable lifecycle predicates in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantOperabilityDecision.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantLifecycle.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Tenants/TenantOperabilityService.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Foundation ready. User story implementation can now proceed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - See The Right Next Action (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Show only lifecycle-valid next actions for each tenant state across tenant-management and onboarding surfaces.
|
||||||
|
|
||||||
|
**Independent Test**: Prepare `draft`, `onboarding`, `active`, and `archived` tenants and verify each in-scope surface shows only the correct next action, with non-members denied as 404 and in-scope capability denials preserved as 403 or disabled via current UI-enforcement patterns.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T007 [P] [US1] Extend lifecycle visibility and authorization coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantResourceAuthorizationTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php`
|
||||||
|
- [X] T008 [P] [US1] Extend onboarding-specific action availability coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php`
|
||||||
|
- [X] T009 [P] [US1] Add explicit lifecycle-invalid and workflow-invalid failure-honesty coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php`
|
||||||
|
- [X] T010 [P] [US1] Add selected-header-tenant independence coverage for tenant action legitimacy in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T011 [US1] Refactor tenant row actions to consume the central action policy in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource.php`
|
||||||
|
- [X] T012 [US1] Refactor tenant header lifecycle actions to consume the central action policy in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource/Pages/ViewTenant.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource/Pages/EditTenant.php`
|
||||||
|
- [X] T013 [US1] Refactor onboarding entry and workflow actions so onboarding stays workflow-contextual in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource/Pages/ListTenants.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||||
|
- [X] T014 [US1] Align server-side lifecycle-action guards, failure-honesty messages, and 404 versus 403 behavior in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource/Pages/ViewTenant.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Policies/TenantOnboardingSessionPolicy.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 is complete when every tenant lifecycle shows the correct next action without surfacing invalid archive/restore/onboarding transitions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Trust Action Labels (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Ensure tenant lifecycle action labels, modal titles, notifications, and audit-aligned copy describe actual domain behavior.
|
||||||
|
|
||||||
|
**Independent Test**: Verify that archive-like behavior is always labeled `Archive`, archived recovery is always `Restore`, and onboarding continuation remains `Resume onboarding`, with no `Deactivate` terminology across touched admin surfaces.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T015 [P] [US2] Extend operator-facing naming and confirmation-regression coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantLifecycleActionNamingTest.php`
|
||||||
|
- [X] T016 [P] [US2] Extend lifecycle wording coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/144/CanonicalOperationViewerContextMismatchTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T017 [US2] Centralize action labels and taxonomy metadata in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Tenants/TenantActionPolicySurface.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Tenants/TenantActionDescriptor.php`
|
||||||
|
- [X] T018 [US2] Replace lifecycle action labels, modal titles, and notification copy in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource/Pages/ViewTenant.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource/Pages/EditTenant.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||||
|
- [X] T019 [US2] Align audit-facing prose and lifecycle-supporting wording without changing stable audit action IDs in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/TenantStatusBadge.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Widgets/Tenant/TenantArchivedBanner.php`, and the touched Filament lifecycle-action surfaces
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 2 is complete when all touched operator-facing lifecycle copy uses honest taxonomy and avoids ambiguous or implementation-first wording.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Get Consistent Cross-Surface Behavior (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Keep lifecycle action availability consistent across list rows, detail headers, onboarding surfaces, and contextual tenant affordances.
|
||||||
|
|
||||||
|
**Independent Test**: Compare the same tenant lifecycle across index, detail, onboarding, and contextual surfaces and confirm there are no contradictory lifecycle actions or mismatched hidden/disabled behaviors.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T020 [P] [US3] Extend cross-surface consistency coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php`
|
||||||
|
- [X] T021 [P] [US3] Extend selector and UI-enforcement consistency coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php`
|
||||||
|
- [X] T022 [P] [US3] Add tenant global-search safety regression coverage or explicit non-impact verification in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T023 [US3] Reuse a single resolved action catalog for runtime action selection across `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource/Pages/ViewTenant.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource/Pages/EditTenant.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||||
|
- [X] T024 [US3] Update action-surface declarations and contract-oriented overflow behavior only, without introducing resolver logic, in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Ui/ActionSurface/ActionSurfaceDeclaration.php`
|
||||||
|
- [X] T025 [US3] Normalize contextual lifecycle affordances in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantResource/Pages/ListTenants.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Widgets/Tenant/TenantArchivedBanner.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Widgets/Tenant/TenantVerificationReport.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 3 is complete when equivalent tenant lifecycles no longer expose contradictory actions across touched surfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Finalize shared validation and clean up cross-story regressions.
|
||||||
|
|
||||||
|
- [X] T026 [P] Add final shared regression assertions in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Tenants/TenantActionPolicySurfaceTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`
|
||||||
|
- [X] T027 Run and stabilize the full focused validation suite documented in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/145-tenant-action-taxonomy-lifecycle-safe-visibility/quickstart.md`, including `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Tenants/TenantOperabilityServiceTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Tenants/TenantActionPolicySurfaceTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantResourceAuthorizationTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantLifecycleActionNamingTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php`
|
||||||
|
- [X] T028 Run formatting for touched PHP files using `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/145-tenant-action-taxonomy-lifecycle-safe-visibility/quickstart.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Phase 1: Setup** has no dependencies and can start immediately.
|
||||||
|
- **Phase 2: Foundational** depends on Phase 1 and blocks all story work.
|
||||||
|
- **Phase 3: User Story 1** depends on Phase 2 and delivers the MVP.
|
||||||
|
- **Phase 4: User Story 2** depends on Phase 2 and benefits from User Story 1’s central policy surface.
|
||||||
|
- **Phase 5: User Story 3** depends on Phase 2 and should follow after the main policy surface is integrated into list/detail/onboarding surfaces.
|
||||||
|
- **Phase 6: Polish** depends on the chosen story phases being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)**: Starts immediately after foundational work and establishes lifecycle-safe availability.
|
||||||
|
- **US2 (P2)**: Depends on the foundational policy surface and reuses US1 integration points for naming consistency.
|
||||||
|
- **US3 (P3)**: Depends on the foundational policy surface and validates consistency after US1 and US2 integrations are in place.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Write or extend tests first and confirm they fail before implementing the corresponding behavior.
|
||||||
|
- Central support/value objects come before surface refactors.
|
||||||
|
- Surface refactors come before copy cleanup and final contract alignment.
|
||||||
|
- Story-level regression coverage must pass before moving to the next priority.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- `T002` and `T003` can be done in parallel after the initial test-helper setup.
|
||||||
|
- `T007`, `T008`, `T009`, and `T010` can run in parallel within US1.
|
||||||
|
- `T015` and `T016` can run in parallel within US2.
|
||||||
|
- `T020`, `T021`, and `T022` can run in parallel within US3.
|
||||||
|
- `T026` can run in parallel with parts of `T027` once implementation is complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Execute lifecycle visibility and onboarding workflow test updates in parallel:
|
||||||
|
Task: "Extend lifecycle visibility and authorization coverage in tests/Feature/Rbac/TenantResourceAuthorizationTest.php and tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php"
|
||||||
|
Task: "Extend onboarding-specific action availability coverage in tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php and tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Execute naming and lifecycle-copy regression updates in parallel:
|
||||||
|
Task: "Extend operator-facing naming coverage in tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php and tests/Feature/Rbac/TenantLifecycleActionNamingTest.php"
|
||||||
|
Task: "Extend lifecycle wording coverage in tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php and tests/Feature/144/CanonicalOperationViewerContextMismatchTest.php"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Execute cross-surface and selector consistency coverage in parallel:
|
||||||
|
Task: "Extend cross-surface consistency coverage in tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php and tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php"
|
||||||
|
Task: "Extend selector and UI-enforcement consistency coverage in tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php and tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First
|
||||||
|
|
||||||
|
1. Complete Setup and Foundational phases.
|
||||||
|
2. Deliver User Story 1 by centralizing lifecycle-safe action availability across tenant list, detail, and onboarding surfaces.
|
||||||
|
3. Validate the focused regression suite before moving on.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Add taxonomy-honest labels and copy through User Story 2 without changing ownership boundaries or onboarding activation semantics.
|
||||||
|
2. Finish User Story 3 by eliminating cross-surface drift and documenting contract-compliant action layouts.
|
||||||
|
|
||||||
|
### Completion
|
||||||
|
|
||||||
|
1. Run the focused validation suite from `quickstart.md`.
|
||||||
|
2. Run Pint on touched files.
|
||||||
|
3. Confirm the implementation matches the spec’s UI Action Matrix and lifecycle-by-action matrix.
|
||||||
@ -223,6 +223,10 @@
|
|||||||
ensureDefaultMicrosoftProviderConnection: false,
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$tenant->forceFill([
|
||||||
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
|
])->save();
|
||||||
|
|
||||||
$workspace = $tenant->workspace()->firstOrFail();
|
$workspace = $tenant->workspace()->firstOrFail();
|
||||||
|
|
||||||
$configured = array_merge(
|
$configured = array_merge(
|
||||||
@ -272,7 +276,7 @@
|
|||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'type' => 'provider.connection.check',
|
'type' => 'provider.connection.check',
|
||||||
'status' => OperationRunStatus::Completed->value,
|
'status' => OperationRunStatus::Completed->value,
|
||||||
'outcome' => OperationRunOutcome::Blocked->value,
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
'context' => [
|
'context' => [
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
'target_scope' => [
|
'target_scope' => [
|
||||||
@ -286,8 +290,8 @@
|
|||||||
'status' => 'fail',
|
'status' => 'fail',
|
||||||
'severity' => 'critical',
|
'severity' => 'critical',
|
||||||
'blocking' => true,
|
'blocking' => true,
|
||||||
'reason_code' => 'provider_permission_missing',
|
'reason_code' => 'permission_denied',
|
||||||
'message' => "Missing required application permission: {$missingKey}",
|
'message' => 'Missing required Graph permissions.',
|
||||||
'evidence' => [],
|
'evidence' => [],
|
||||||
'next_steps' => [],
|
'next_steps' => [],
|
||||||
],
|
],
|
||||||
@ -316,12 +320,18 @@
|
|||||||
|
|
||||||
$page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
|
$page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
|
||||||
|
|
||||||
|
$page->script(<<<'JS'
|
||||||
|
document.querySelectorAll('.fi-sc-wizard-header-step-btn')[2]?.click();
|
||||||
|
JS);
|
||||||
|
|
||||||
$page
|
$page
|
||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
||||||
->waitForText('View required permissions')
|
->wait(1)
|
||||||
->click('View required permissions')
|
->assertScript("document.querySelector('[data-testid=\"verification-assist-trigger\"]') !== null", true)
|
||||||
->waitForText('Open full page');
|
->click('[data-testid="verification-assist-trigger"]')
|
||||||
|
->assertScript("document.querySelector('[data-testid=\"verification-assist-root\"]') !== null", true)
|
||||||
|
->assertAttribute('[data-testid="verification-assist-full-page"]', 'target', '_blank');
|
||||||
|
|
||||||
$page->script(<<<'JS'
|
$page->script(<<<'JS'
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
@ -336,12 +346,11 @@
|
|||||||
|
|
||||||
$page
|
$page
|
||||||
->waitForText('Copied')
|
->waitForText('Copied')
|
||||||
->assertAttribute('[data-testid="verification-assist-full-page"]', 'target', '_blank')
|
|
||||||
->assertAttribute('[data-testid="verification-assist-full-page"]', 'rel', 'noopener noreferrer')
|
->assertAttribute('[data-testid="verification-assist-full-page"]', 'rel', 'noopener noreferrer')
|
||||||
->click('Open full page')
|
->click('[data-testid="verification-assist-full-page"]')
|
||||||
->wait(1)
|
->wait(1)
|
||||||
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
||||||
->assertSee('Open full page')
|
->assertScript("document.querySelector('[data-testid=\"verification-assist-root\"]') !== null", true)
|
||||||
->click('Close')
|
->click('Close')
|
||||||
->click('Provider connection')
|
->click('Provider connection')
|
||||||
->assertSee('Select an existing connection or create a new one.');
|
->assertSee('Select an existing connection or create a new one.');
|
||||||
@ -353,6 +362,10 @@
|
|||||||
ensureDefaultMicrosoftProviderConnection: false,
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$tenant->forceFill([
|
||||||
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
|
])->save();
|
||||||
|
|
||||||
$workspace = $tenant->workspace()->firstOrFail();
|
$workspace = $tenant->workspace()->firstOrFail();
|
||||||
|
|
||||||
$configured = array_merge(
|
$configured = array_merge(
|
||||||
@ -400,7 +413,7 @@
|
|||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'type' => 'provider.connection.check',
|
'type' => 'provider.connection.check',
|
||||||
'status' => OperationRunStatus::Completed->value,
|
'status' => OperationRunStatus::Completed->value,
|
||||||
'outcome' => OperationRunOutcome::Blocked->value,
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
'context' => [
|
'context' => [
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
'target_scope' => [
|
'target_scope' => [
|
||||||
@ -414,7 +427,7 @@
|
|||||||
'status' => 'fail',
|
'status' => 'fail',
|
||||||
'severity' => 'critical',
|
'severity' => 'critical',
|
||||||
'blocking' => true,
|
'blocking' => true,
|
||||||
'reason_code' => 'provider_permission_missing',
|
'reason_code' => 'permission_denied',
|
||||||
'message' => 'Provider connection requires admin consent before use.',
|
'message' => 'Provider connection requires admin consent before use.',
|
||||||
'evidence' => [],
|
'evidence' => [],
|
||||||
'next_steps' => [
|
'next_steps' => [
|
||||||
@ -451,13 +464,20 @@
|
|||||||
]);
|
]);
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]))
|
$page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
|
||||||
|
|
||||||
|
$page->script(<<<'JS'
|
||||||
|
document.querySelectorAll('.fi-sc-wizard-header-step-btn')[2]?.click();
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$page
|
||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
||||||
->waitForText('Grant admin consent')
|
->wait(1)
|
||||||
->click('Grant admin consent')
|
->assertScript("document.querySelector('[data-testid=\"verification-next-step-grant-admin-consent\"]') !== null", true)
|
||||||
->waitForText('Required permissions assist')
|
->click('[data-testid="verification-next-step-grant-admin-consent"]')
|
||||||
|
->assertScript("document.querySelector('[data-testid=\"verification-assist-root\"]') !== null", true)
|
||||||
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
|
||||||
->assertSee('Open full page')
|
->assertAttribute('[data-testid="verification-assist-full-page"]', 'target', '_blank')
|
||||||
->assertSee('Review platform connection');
|
->assertSee('Use the existing Start verification action in this step after reviewing changes.');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -44,7 +44,7 @@ public function test_renders_workspace_operations_list_with_tenantless_runs_when
|
|||||||
->assertSee('Tenant run');
|
->assertSee('Tenant run');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_renders_workspace_operations_list_safely_with_tenant_context_and_tenantless_records_present(): void
|
public function test_renders_workspace_operations_list_workspace_wide_even_with_tenant_context_and_tenantless_records_present(): void
|
||||||
{
|
{
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
@ -73,6 +73,6 @@ public function test_renders_workspace_operations_list_safely_with_tenant_contex
|
|||||||
->get(route('admin.operations.index'))
|
->get(route('admin.operations.index'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Tenant run')
|
->assertSee('Tenant run')
|
||||||
->assertDontSee('Tenantless run');
|
->assertSee('Tenantless run');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -101,6 +101,7 @@ public function test_keeps_onboarding_tenant_runs_viewable_with_lifecycle_aware_
|
|||||||
->assertSee('Run tenant: Onboarding Tenant.')
|
->assertSee('Run tenant: Onboarding Tenant.')
|
||||||
->assertSee('This tenant is currently onboarding')
|
->assertSee('This tenant is currently onboarding')
|
||||||
->assertSee('Back to Operations')
|
->assertSee('Back to Operations')
|
||||||
|
->assertDontSee('This tenant is currently active')
|
||||||
->assertDontSee('← Back to Onboarding Tenant');
|
->assertDontSee('← Back to Onboarding Tenant');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,6 +139,7 @@ public function test_keeps_archived_tenant_runs_viewable_with_lifecycle_aware_co
|
|||||||
->assertSee('Run tenant: Archived Tenant.')
|
->assertSee('Run tenant: Archived Tenant.')
|
||||||
->assertSee('This tenant is currently archived')
|
->assertSee('This tenant is currently archived')
|
||||||
->assertSee('Back to Operations')
|
->assertSee('Back to Operations')
|
||||||
|
->assertDontSee('deactivated')
|
||||||
->assertDontSee('← Back to Archived Tenant');
|
->assertDontSee('← Back to Archived Tenant');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,6 +167,7 @@ public function test_keeps_selector_excluded_draft_tenant_runs_viewable_with_lif
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Run tenant is not available in the current tenant selector')
|
->assertSee('Run tenant is not available in the current tenant selector')
|
||||||
->assertSee('Run tenant: Draft Tenant.')
|
->assertSee('Run tenant: Draft Tenant.')
|
||||||
->assertSee('This tenant is currently draft');
|
->assertSee('This tenant is currently draft')
|
||||||
|
->assertDontSee('Resume onboarding');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,8 @@
|
|||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertSee('Archived')
|
->assertSee('Tenant archived')
|
||||||
->assertSee(UiTooltips::TENANT_ARCHIVED);
|
->assertSee(UiTooltips::TENANT_ARCHIVED)
|
||||||
|
->assertSee('This tenant remains available for inspection and audit history, but it is not selectable as active context until you restore it.')
|
||||||
|
->assertDontSee('deactivated');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -286,7 +286,7 @@ function getPlacementEmptyStateAction(Testable $component, string $name): ?Actio
|
|||||||
expect($headerCreate?->getLabel())->toBe('Add tenant');
|
expect($headerCreate?->getLabel())->toBe('Add tenant');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('labels the empty-state tenant action as continue onboarding when one draft exists', function (): void {
|
it('labels the empty-state tenant action as resume onboarding when one draft exists', function (): void {
|
||||||
$workspace = Workspace::factory()->create([
|
$workspace = Workspace::factory()->create([
|
||||||
'archived_at' => now(),
|
'archived_at' => now(),
|
||||||
]);
|
]);
|
||||||
@ -321,10 +321,10 @@ function getPlacementEmptyStateAction(Testable $component, string $name): ?Actio
|
|||||||
|
|
||||||
$emptyStateCreate = getPlacementEmptyStateAction($component, 'add_tenant');
|
$emptyStateCreate = getPlacementEmptyStateAction($component, 'add_tenant');
|
||||||
expect($emptyStateCreate)->not->toBeNull();
|
expect($emptyStateCreate)->not->toBeNull();
|
||||||
expect($emptyStateCreate?->getLabel())->toBe('Continue onboarding');
|
expect($emptyStateCreate?->getLabel())->toBe('Resume onboarding');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('labels the tenant header action as continue onboarding when one draft exists', function (): void {
|
it('labels the tenant header action as resume onboarding when one draft exists', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
createOnboardingDraft([
|
createOnboardingDraft([
|
||||||
@ -345,7 +345,7 @@ function getPlacementEmptyStateAction(Testable $component, string $name): ?Actio
|
|||||||
$headerCreate = getHeaderAction($component, 'add_tenant');
|
$headerCreate = getHeaderAction($component, 'add_tenant');
|
||||||
expect($headerCreate)->not->toBeNull();
|
expect($headerCreate)->not->toBeNull();
|
||||||
expect($headerCreate?->isVisible())->toBeTrue();
|
expect($headerCreate?->isVisible())->toBeTrue();
|
||||||
expect($headerCreate?->getLabel())->toBe('Continue onboarding');
|
expect($headerCreate?->getLabel())->toBe('Resume onboarding');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('labels the tenant header action as choose onboarding draft when multiple drafts exist', function (): void {
|
it('labels the tenant header action as choose onboarding draft when multiple drafts exist', function (): void {
|
||||||
|
|||||||
@ -88,7 +88,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$tenant->forceFill(['workspace_id' => (int) $workspace->getKey()])->save();
|
$tenant->forceFill(['workspace_id' => (int) $workspace->getKey()])->save();
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->create([
|
$connection = ProviderConnection::factory()->consentGranted()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
|
|||||||
@ -23,11 +23,11 @@ function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant
|
|||||||
'metadata' => [],
|
'metadata' => [],
|
||||||
], $attributes));
|
], $attributes));
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->create([
|
$connection = ProviderConnection::factory()->consentGranted()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'is_default' => true,
|
'is_default' => true,
|
||||||
'status' => 'ok',
|
'status' => 'connected',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ProviderCredential::factory()->create([
|
ProviderCredential::factory()->create([
|
||||||
|
|||||||
@ -53,7 +53,7 @@
|
|||||||
->assertStatus(404);
|
->assertStatus(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('readonly users cannot deactivate tenants (archive)', function () {
|
test('readonly users cannot archive tenants', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|||||||
@ -178,13 +178,13 @@
|
|||||||
$response->assertSee('Open in Entra');
|
$response->assertSee('Open in Entra');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tenant can be deactivated from the tenant detail action menu', function () {
|
test('tenant can be archived from the tenant detail action menu', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::create([
|
||||||
'tenant_id' => 'tenant-ui-deactivate',
|
'tenant_id' => 'tenant-ui-archive',
|
||||||
'name' => 'UI Tenant Deactivate',
|
'name' => 'UI Tenant Archive',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant($tenant, $user, role: 'owner');
|
[$user, $tenant] = createUserWithTenant($tenant, $user, role: 'owner');
|
||||||
|
|||||||
@ -27,7 +27,7 @@
|
|||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->create([
|
$connection = ProviderConnection::factory()->consentGranted()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
@ -150,7 +150,7 @@
|
|||||||
[$user, $tenant] = createUserWithTenant(role: 'operator', ensureDefaultMicrosoftProviderConnection: false);
|
[$user, $tenant] = createUserWithTenant(role: 'operator', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->create([
|
$connection = ProviderConnection::factory()->consentGranted()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
@ -191,6 +191,20 @@
|
|||||||
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
|
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps archived tenant verification contextual and read-only on the widget surface', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$tenant->delete();
|
||||||
|
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(TenantVerificationReport::class, ['record' => $tenant])
|
||||||
|
->assertSee('No verification run has been started yet.')
|
||||||
|
->assertSee('Verification can be started from tenant management only while the tenant is active.')
|
||||||
|
->assertDontSee('Start verification');
|
||||||
|
});
|
||||||
|
|
||||||
it('starts tenant verification from the tenant list row action via the unified run path', function (): void {
|
it('starts tenant verification from the tenant list row action via the unified run path', function (): void {
|
||||||
Queue::fake();
|
Queue::fake();
|
||||||
|
|
||||||
@ -199,7 +213,7 @@
|
|||||||
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->create([
|
$connection = ProviderConnection::factory()->consentGranted()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
describe('Tenant View header action UI enforcement', function () {
|
describe('Tenant View header action UI enforcement', function () {
|
||||||
it('shows edit and deactivate actions as visible but disabled for readonly members', function () {
|
it('shows edit and archive actions as visible but disabled for readonly members', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
@ -35,7 +35,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows edit and deactivate actions as enabled for owner members', function () {
|
it('shows edit and archive actions as enabled for owner members', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
@ -50,7 +50,7 @@
|
|||||||
->assertActionEnabled('archive');
|
->assertActionEnabled('archive');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not execute the deactivate action for readonly members (silently blocked by Filament)', function () {
|
it('does not execute the archive action for readonly members (silently blocked by Filament)', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|||||||
@ -24,11 +24,11 @@ function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant
|
|||||||
'metadata' => [],
|
'metadata' => [],
|
||||||
], $attributes));
|
], $attributes));
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->create([
|
$connection = ProviderConnection::factory()->consentGranted()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'is_default' => true,
|
'is_default' => true,
|
||||||
'status' => 'ok',
|
'status' => 'connected',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ProviderCredential::factory()->create([
|
ProviderCredential::factory()->create([
|
||||||
|
|||||||
@ -24,11 +24,11 @@ function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant
|
|||||||
'metadata' => [],
|
'metadata' => [],
|
||||||
], $attributes));
|
], $attributes));
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->create([
|
$connection = ProviderConnection::factory()->consentGranted()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'is_default' => true,
|
'is_default' => true,
|
||||||
'status' => 'ok',
|
'status' => 'connected',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ProviderCredential::factory()->create([
|
ProviderCredential::factory()->create([
|
||||||
|
|||||||
@ -90,13 +90,13 @@ function executeInventorySyncNow(Tenant $tenant, array $selection): array
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $defaultConnection instanceof ProviderConnection) {
|
if (! $defaultConnection instanceof ProviderConnection) {
|
||||||
$defaultConnection = ProviderConnection::factory()->create([
|
$defaultConnection = ProviderConnection::factory()->consentGranted()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'entra_tenant_id' => $tenant->tenant_id,
|
'entra_tenant_id' => $tenant->tenant_id,
|
||||||
'is_default' => true,
|
'is_default' => true,
|
||||||
'status' => 'ok',
|
'status' => 'connected',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ProviderCredential::factory()->create([
|
ProviderCredential::factory()->create([
|
||||||
|
|||||||
@ -68,7 +68,7 @@
|
|||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defaults the operations list to the active tenant when tenant context is set', function (): void {
|
it('keeps the operations list workspace-scoped when tenant context is set', function (): void {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||||
|
|
||||||
@ -101,7 +101,7 @@
|
|||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||||
->get('/admin/operations')
|
->get('/admin/operations')
|
||||||
->assertSee('Policy sync')
|
->assertSee('Policy sync')
|
||||||
->assertDontSee('Inventory sync');
|
->assertSee('Inventory sync');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows readonly users to view operations list and detail', function (): void {
|
it('allows readonly users to view operations list and detail', function (): void {
|
||||||
|
|||||||
@ -4,11 +4,16 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Verification\VerificationReportWriter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
@ -190,6 +195,168 @@
|
|||||||
->assertRedirect(route('admin.onboarding.draft', ['onboardingDraft' => $activeDraft->getKey()]));
|
->assertRedirect(route('admin.onboarding.draft', ['onboardingDraft' => $activeDraft->getKey()]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses workflow-contextual onboarding wording on landing and draft routes', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$firstDraft = createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => '44444444-1111-1111-1111-111111111111',
|
||||||
|
'tenant_name' => 'First Draft',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => '55555555-1111-1111-1111-111111111111',
|
||||||
|
'tenant_name' => 'Second Draft',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('admin.onboarding'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Resume onboarding')
|
||||||
|
->assertDontSee('Resume onboarding draft');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('admin.onboarding.draft', ['onboardingDraft' => $firstDraft->getKey()]))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Choose onboarding draft')
|
||||||
|
->assertDontSee('All onboarding drafts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses complete onboarding wording for activation-ready drafts', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => 'aaaaaaaa-1111-1111-1111-111111111111',
|
||||||
|
'name' => 'Activation Ready Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'is_default' => true,
|
||||||
|
'status' => 'connected',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'entra_tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||||
|
[
|
||||||
|
'key' => 'consent',
|
||||||
|
'title' => 'Required application permissions',
|
||||||
|
'status' => 'pass',
|
||||||
|
'severity' => 'low',
|
||||||
|
'blocking' => false,
|
||||||
|
'reason_code' => 'ok',
|
||||||
|
'message' => 'Consent is ready.',
|
||||||
|
'evidence' => [],
|
||||||
|
'next_steps' => [],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'current_step' => 'bootstrap',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'verification_operation_run_id' => (int) $run->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->followingRedirects()
|
||||||
|
->get(route('admin.onboarding'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Complete onboarding')
|
||||||
|
->assertDontSee('Activate tenant')
|
||||||
|
->assertDontSee('Restore')
|
||||||
|
->assertDontSee('Archive')
|
||||||
|
->assertSee('After completion');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns instead of completing onboarding when the workflow is not ready', function (): void {
|
||||||
|
$tenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'name' => 'Verification Pending Tenant',
|
||||||
|
]);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $tenant->workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'current_step' => 'connection',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ManagedTenantOnboardingWizard::class, [
|
||||||
|
'onboardingDraft' => (int) $draft->getKey(),
|
||||||
|
])
|
||||||
|
->call('completeOnboarding')
|
||||||
|
->assertNotified('Verification required');
|
||||||
|
|
||||||
|
$tenant->refresh();
|
||||||
|
$draft->refresh();
|
||||||
|
|
||||||
|
expect($tenant->status)->toBe(Tenant::STATUS_ONBOARDING)
|
||||||
|
->and($draft->completed_at)->toBeNull()
|
||||||
|
->and($draft->current_step)->toBe('connection');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns a linked onboarding tenant to draft when its last onboarding draft is cancelled', function (): void {
|
it('returns a linked onboarding tenant to draft when its last onboarding draft is cancelled', function (): void {
|
||||||
$tenant = Tenant::factory()->onboarding()->create([
|
$tenant = Tenant::factory()->onboarding()->create([
|
||||||
'name' => 'Cancelled Linked Tenant',
|
'name' => 'Cancelled Linked Tenant',
|
||||||
|
|||||||
@ -58,7 +58,7 @@
|
|||||||
->assertSee('Last updated by')
|
->assertSee('Last updated by')
|
||||||
->assertSee('Primary Owner')
|
->assertSee('Primary Owner')
|
||||||
->assertSee('Second Operator')
|
->assertSee('Second Operator')
|
||||||
->assertSee('Resume onboarding draft')
|
->assertSee('Resume onboarding')
|
||||||
->assertSee('View summary');
|
->assertSee('View summary');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -127,7 +127,7 @@
|
|||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertDontSee('All onboarding drafts');
|
->assertDontSee('Choose onboarding draft');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the all drafts header action when multiple resumable drafts exist', function (): void {
|
it('shows the all drafts header action when multiple resumable drafts exist', function (): void {
|
||||||
@ -167,7 +167,7 @@
|
|||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('All onboarding drafts');
|
->assertSee('Choose onboarding draft');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects to the canonical draft route immediately after step one identifies a tenant', function (): void {
|
it('redirects to the canonical draft route immediately after step one identifies a tenant', function (): void {
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
it('returns 403 when updating a selected connection without manage capability', function (): void {
|
it('returns 403 when a workspace operator attempts to open the onboarding draft wizard', function (): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
@ -34,6 +34,10 @@
|
|||||||
'status' => Tenant::STATUS_ONBOARDING,
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'operator'],
|
||||||
|
]);
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -51,7 +55,7 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
TenantOnboardingSession::create([
|
$session = TenantOnboardingSession::create([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
@ -63,14 +67,8 @@
|
|||||||
'updated_by_user_id' => (int) $user->getKey(),
|
'updated_by_user_id' => (int) $user->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ManagedTenantOnboardingWizard::class)
|
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()])
|
||||||
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
|
||||||
'display_name' => 'Updated name',
|
|
||||||
'client_id' => 'new-client-id',
|
|
||||||
])
|
|
||||||
->assertStatus(403);
|
->assertStatus(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -103,7 +101,7 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
TenantOnboardingSession::create([
|
$session = TenantOnboardingSession::create([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
@ -118,7 +116,7 @@
|
|||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ManagedTenantOnboardingWizard::class)
|
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()])
|
||||||
->assertStatus(404);
|
->assertStatus(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -140,6 +138,10 @@
|
|||||||
'status' => Tenant::STATUS_ONBOARDING,
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -183,7 +185,7 @@
|
|||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ManagedTenantOnboardingWizard::class)
|
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()])
|
||||||
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
||||||
'display_name' => 'Updated name',
|
'display_name' => 'Updated name',
|
||||||
'client_id' => 'old-client-id',
|
'client_id' => 'old-client-id',
|
||||||
@ -236,6 +238,10 @@
|
|||||||
'status' => Tenant::STATUS_ONBOARDING,
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -253,7 +259,7 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
TenantOnboardingSession::create([
|
$session = TenantOnboardingSession::create([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
@ -268,7 +274,7 @@
|
|||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ManagedTenantOnboardingWizard::class)
|
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()])
|
||||||
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
||||||
'display_name' => 'Updated name',
|
'display_name' => 'Updated name',
|
||||||
'client_id' => 'new-client-id',
|
'client_id' => 'new-client-id',
|
||||||
@ -302,6 +308,10 @@
|
|||||||
'status' => Tenant::STATUS_ONBOARDING,
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -322,7 +332,7 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
TenantOnboardingSession::create([
|
$session = TenantOnboardingSession::create([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
@ -337,7 +347,7 @@
|
|||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ManagedTenantOnboardingWizard::class)
|
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()])
|
||||||
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
||||||
'display_name' => 'Updated name',
|
'display_name' => 'Updated name',
|
||||||
'client_id' => 'new-client-id',
|
'client_id' => 'new-client-id',
|
||||||
@ -382,6 +392,10 @@
|
|||||||
'status' => Tenant::STATUS_ONBOARDING,
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->platform()->create([
|
$connection = ProviderConnection::factory()->platform()->create([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -391,7 +405,7 @@
|
|||||||
'is_default' => true,
|
'is_default' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
TenantOnboardingSession::create([
|
$session = TenantOnboardingSession::create([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
@ -406,7 +420,7 @@
|
|||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ManagedTenantOnboardingWizard::class)
|
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()])
|
||||||
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
||||||
'display_name' => 'Dedicated connection',
|
'display_name' => 'Dedicated connection',
|
||||||
'connection_type' => ProviderConnectionType::Dedicated->value,
|
'connection_type' => ProviderConnectionType::Dedicated->value,
|
||||||
@ -440,6 +454,10 @@
|
|||||||
'status' => Tenant::STATUS_ONBOARDING,
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'manager'],
|
||||||
|
]);
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->platform()->create([
|
$connection = ProviderConnection::factory()->platform()->create([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -449,7 +467,7 @@
|
|||||||
'is_default' => true,
|
'is_default' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
TenantOnboardingSession::create([
|
$session = TenantOnboardingSession::create([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
@ -464,7 +482,7 @@
|
|||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ManagedTenantOnboardingWizard::class)
|
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()])
|
||||||
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
||||||
'display_name' => 'Dedicated connection',
|
'display_name' => 'Dedicated connection',
|
||||||
'connection_type' => ProviderConnectionType::Dedicated->value,
|
'connection_type' => ProviderConnectionType::Dedicated->value,
|
||||||
@ -492,6 +510,10 @@
|
|||||||
'status' => Tenant::STATUS_ONBOARDING,
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
$otherTenant = Tenant::factory()->create([
|
$otherTenant = Tenant::factory()->create([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'tenant_id' => '55555555-5555-5555-5555-555555555555',
|
'tenant_id' => '55555555-5555-5555-5555-555555555555',
|
||||||
@ -515,7 +537,7 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
TenantOnboardingSession::create([
|
$session = TenantOnboardingSession::create([
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
@ -530,7 +552,7 @@
|
|||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ManagedTenantOnboardingWizard::class)
|
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()])
|
||||||
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
->call('updateSelectedProviderConnectionInline', (int) $connection->getKey(), [
|
||||||
'display_name' => 'Updated name',
|
'display_name' => 'Updated name',
|
||||||
'client_id' => 'new-client-id',
|
'client_id' => 'new-client-id',
|
||||||
|
|||||||
@ -203,15 +203,16 @@ function createVerificationAssistDraft(
|
|||||||
it('shows the assist trigger for blocked and needs-attention states and hides it when verification is ready', function (string $state, bool $shouldSeeTrigger): void {
|
it('shows the assist trigger for blocked and needs-attention states and hides it when verification is ready', function (string $state, bool $shouldSeeTrigger): void {
|
||||||
[$user, , $draft] = createVerificationAssistDraft($state);
|
[$user, , $draft] = createVerificationAssistDraft($state);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$component = Livewire::actingAs($user)
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $user->last_workspace_id])
|
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()]);
|
||||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]))
|
|
||||||
->assertSuccessful()
|
if ($shouldSeeTrigger) {
|
||||||
->when(
|
$component->assertActionVisible('wizardVerificationRequiredPermissionsAssist');
|
||||||
$shouldSeeTrigger,
|
|
||||||
fn ($response) => $response->assertSee('View required permissions')->assertSee('Required permissions assist'),
|
return;
|
||||||
fn ($response) => $response->assertDontSee('View required permissions'),
|
}
|
||||||
);
|
|
||||||
|
$component->assertActionHidden('wizardVerificationRequiredPermissionsAssist');
|
||||||
})->with([
|
})->with([
|
||||||
'blocked' => ['blocked', true],
|
'blocked' => ['blocked', true],
|
||||||
'needs attention' => ['needs_attention', true],
|
'needs attention' => ['needs_attention', true],
|
||||||
@ -225,12 +226,12 @@ function createVerificationAssistDraft(
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()])
|
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()])
|
||||||
->assertWizardCurrentStep(3)
|
|
||||||
->mountAction('wizardVerificationRequiredPermissionsAssist')
|
->mountAction('wizardVerificationRequiredPermissionsAssist')
|
||||||
->assertMountedActionModalSee('Required permissions assist')
|
->assertMountedActionModalSee('Required permissions assist')
|
||||||
->assertMountedActionModalSee('Open full page')
|
->assertMountedActionModalSee('Open full page')
|
||||||
->unmountAction()
|
->unmountAction();
|
||||||
->assertWizardCurrentStep(3);
|
|
||||||
|
expect($draft->fresh()?->current_step)->toBe('verify');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders summary metadata and missing application permissions in the assist slideover', function (): void {
|
it('renders summary metadata and missing application permissions in the assist slideover', function (): void {
|
||||||
|
|||||||
@ -61,7 +61,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$inactive = Tenant::factory()->create([
|
$inactive = Tenant::factory()->create([
|
||||||
'status' => 'inactive',
|
'status' => Tenant::STATUS_ARCHIVED,
|
||||||
'deleted_at' => null,
|
'deleted_at' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,7 @@ function tenantWithDefaultMicrosoftConnectionForPolicySyncReport(array $attribut
|
|||||||
'app_client_secret' => null,
|
'app_client_secret' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->create([
|
$connection = ProviderConnection::factory()->consentGranted()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'is_default' => true,
|
'is_default' => true,
|
||||||
|
|||||||
@ -20,7 +20,7 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
|
|||||||
'app_client_secret' => null,
|
'app_client_secret' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->create([
|
$connection = ProviderConnection::factory()->consentGranted()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'is_default' => true,
|
'is_default' => true,
|
||||||
|
|||||||
@ -66,4 +66,19 @@
|
|||||||
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionHidden('archive');
|
->assertActionHidden('archive');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses restore naming and confirmation copy for archived tenants', function () {
|
||||||
|
$tenant = Tenant::factory()->archived()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('restore')
|
||||||
|
->assertActionEnabled('restore')
|
||||||
|
->assertActionExists('restore', fn (Action $action): bool => $action->getLabel() === 'Restore' && $action->isConfirmationRequired())
|
||||||
|
->assertActionHidden('archive');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -235,4 +235,81 @@
|
|||||||
->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]))
|
->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]))
|
||||||
->assertForbidden();
|
->assertForbidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 403 when a manager tries to complete onboarding without owner capability', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'manager',
|
||||||
|
workspaceRole: 'manager',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'is_default' => true,
|
||||||
|
'status' => 'connected',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'entra_tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||||
|
[
|
||||||
|
'key' => 'consent',
|
||||||
|
'title' => 'Required application permissions',
|
||||||
|
'status' => 'pass',
|
||||||
|
'severity' => 'low',
|
||||||
|
'blocking' => false,
|
||||||
|
'reason_code' => 'ok',
|
||||||
|
'message' => 'Consent is ready.',
|
||||||
|
'evidence' => [],
|
||||||
|
'next_steps' => [],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'current_step' => 'bootstrap',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'verification_operation_run_id' => (int) $run->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ManagedTenantOnboardingWizard::class, [
|
||||||
|
'onboardingDraft' => (int) $draft->getKey(),
|
||||||
|
])
|
||||||
|
->call('completeOnboarding')
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
253
tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php
Normal file
253
tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function tenantActionSurfaceSearchTitles($results): array
|
||||||
|
{
|
||||||
|
return collect($results)->map(fn ($result): string => (string) $result->title)->values()->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps onboarding lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
||||||
|
$tenant = Tenant::factory()->onboarding()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $tenant->workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenants::class)
|
||||||
|
->assertTableActionVisible('related_onboarding', $tenant)
|
||||||
|
->assertTableActionHidden('archive', $tenant)
|
||||||
|
->assertTableActionHidden('restore', $tenant);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('related_onboarding')
|
||||||
|
->assertActionHidden('archive')
|
||||||
|
->assertActionHidden('restore');
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('related_onboarding')
|
||||||
|
->assertActionHidden('archive')
|
||||||
|
->assertActionHidden('restore');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps active lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenants::class)
|
||||||
|
->assertTableActionVisible('archive', $tenant)
|
||||||
|
->assertTableActionHidden('restore', $tenant)
|
||||||
|
->assertTableActionHidden('related_onboarding', $tenant);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('archive')
|
||||||
|
->assertActionHidden('restore')
|
||||||
|
->assertActionHidden('related_onboarding');
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('archive')
|
||||||
|
->assertActionHidden('restore')
|
||||||
|
->assertActionHidden('related_onboarding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps draft lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
||||||
|
$tenant = Tenant::factory()->draft()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $tenant->workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenants::class)
|
||||||
|
->assertTableActionVisible('related_onboarding', $tenant)
|
||||||
|
->assertTableActionHidden('archive', $tenant)
|
||||||
|
->assertTableActionHidden('restore', $tenant);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('related_onboarding')
|
||||||
|
->assertActionHidden('archive')
|
||||||
|
->assertActionHidden('restore');
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('related_onboarding')
|
||||||
|
->assertActionHidden('archive')
|
||||||
|
->assertActionHidden('restore');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps archived lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
||||||
|
$tenant = Tenant::factory()->archived()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenants::class)
|
||||||
|
->assertTableActionVisible('restore', $tenant)
|
||||||
|
->assertTableActionHidden('archive', $tenant);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('restore')
|
||||||
|
->assertActionHidden('archive');
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('restore')
|
||||||
|
->assertActionHidden('archive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps tenant global search aligned with active-only selector semantics across lifecycles', function (): void {
|
||||||
|
$active = Tenant::factory()->active()->create(['name' => 'Surface Search Active']);
|
||||||
|
[$user, $active] = createUserWithTenant(tenant: $active, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$draft = Tenant::factory()->draft()->create([
|
||||||
|
'workspace_id' => (int) $active->workspace_id,
|
||||||
|
'name' => 'Surface Search Draft',
|
||||||
|
]);
|
||||||
|
$onboarding = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $active->workspace_id,
|
||||||
|
'name' => 'Surface Search Onboarding',
|
||||||
|
]);
|
||||||
|
$archived = Tenant::factory()->archived()->create([
|
||||||
|
'workspace_id' => (int) $active->workspace_id,
|
||||||
|
'name' => 'Surface Search Archived',
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(tenant: $draft, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
createUserWithTenant(tenant: $onboarding, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
createUserWithTenant(tenant: $archived, user: $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel('admin');
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
Filament::bootCurrentPanel();
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $active->workspace_id);
|
||||||
|
|
||||||
|
expect(tenantActionSurfaceSearchTitles(TenantResource::getGlobalSearchResults('Surface Search')))
|
||||||
|
->toBe(['Surface Search Active']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps list-row lifecycle actions independent from the selected header tenant context', function (): void {
|
||||||
|
$selectedTenant = Tenant::factory()->active()->create(['name' => 'Selected Header Tenant']);
|
||||||
|
[$user, $selectedTenant] = createUserWithTenant(tenant: $selectedTenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $selectedTenant->workspace_id,
|
||||||
|
'name' => 'Independent Onboarding Tenant',
|
||||||
|
]);
|
||||||
|
$archivedTenant = Tenant::factory()->archived()->create([
|
||||||
|
'workspace_id' => (int) $selectedTenant->workspace_id,
|
||||||
|
'name' => 'Independent Archived Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(tenant: $onboardingTenant, user: $user, role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
createUserWithTenant(tenant: $archivedTenant, user: $user, role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $onboardingTenant->workspace,
|
||||||
|
'tenant' => $onboardingTenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $onboardingTenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $onboardingTenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $selectedTenant->workspace_id);
|
||||||
|
Filament::setTenant($selectedTenant, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenants::class)
|
||||||
|
->assertTableActionVisible('archive', $selectedTenant)
|
||||||
|
->assertTableActionVisible('related_onboarding', $onboardingTenant)
|
||||||
|
->assertTableActionHidden('archive', $onboardingTenant)
|
||||||
|
->assertTableActionVisible('restore', $archivedTenant)
|
||||||
|
->assertTableActionHidden('archive', $archivedTenant);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('documents and preserves the tenant row overflow contract for lifecycle actions', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $tenant->workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'status' => 'completed',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$declaration = TenantResource::actionSurfaceDeclaration();
|
||||||
|
|
||||||
|
expect($declaration->listRowPrimaryActionLimit())->toBe(2)
|
||||||
|
->and((string) ($declaration->slot(ActionSurfaceSlot::ListRowMoreMenu)?->details ?? ''))->toContain('two');
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenants::class)
|
||||||
|
->assertTableActionVisible('view', $tenant)
|
||||||
|
->assertTableActionVisible('archive', $tenant)
|
||||||
|
->assertTableActionHidden('related_onboarding', $tenant)
|
||||||
|
->assertTableActionVisible('related_onboarding_overflow', $tenant)
|
||||||
|
->assertTableActionHidden('restore', $tenant);
|
||||||
|
});
|
||||||
96
tests/Feature/Rbac/TenantLifecycleActionNamingTest.php
Normal file
96
tests/Feature/Rbac/TenantLifecycleActionNamingTest.php
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('labels the onboarding entry action as resume onboarding when exactly one resumable draft exists', function (): void {
|
||||||
|
$tenant = Tenant::factory()->onboarding()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $tenant->workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenants::class)
|
||||||
|
->assertActionExists('add_tenant', fn (Action $action): bool => $action->getLabel() === 'Resume onboarding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses archive consistently across list, view, and edit surfaces', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenants::class)
|
||||||
|
->assertTableActionExists('archive', fn (Action $action): bool => $action->getLabel() === 'Archive' && $action->isConfirmationRequired(), $tenant);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionExists('archive', fn (Action $action): bool => $action->getLabel() === 'Archive' && $action->isConfirmationRequired());
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionExists('archive', fn (Action $action): bool => $action->getLabel() === 'Archive' && $action->isConfirmationRequired());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses restore consistently across list, view, and edit surfaces', function (): void {
|
||||||
|
$tenant = Tenant::factory()->archived()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenants::class)
|
||||||
|
->assertTableActionExists('restore', fn (Action $action): bool => $action->getLabel() === 'Restore' && $action->isConfirmationRequired(), $tenant);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionExists('restore', fn (Action $action): bool => $action->getLabel() === 'Restore' && $action->isConfirmationRequired());
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionExists('restore', fn (Action $action): bool => $action->getLabel() === 'Restore' && $action->isConfirmationRequired());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fall back to deactivate terminology on lifecycle actions', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenants::class)
|
||||||
|
->assertTableActionExists('archive', fn (Action $action): bool => $action->getLabel() !== 'Deactivate', $tenant);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionExists('archive', fn (Action $action): bool => $action->getLabel() !== 'Deactivate');
|
||||||
|
});
|
||||||
189
tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php
Normal file
189
tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('shows resume onboarding instead of archive for draft and onboarding tenants on list and detail surfaces', function (\Closure $tenantFactory): void {
|
||||||
|
$tenant = $tenantFactory();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $tenant->workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenants::class)
|
||||||
|
->assertTableActionVisible('view', $tenant)
|
||||||
|
->assertTableActionVisible('related_onboarding', $tenant)
|
||||||
|
->assertTableActionExists('related_onboarding', fn (Action $action): bool => $action->getLabel() === 'Resume onboarding', $tenant)
|
||||||
|
->assertTableActionHidden('archive', $tenant)
|
||||||
|
->assertTableActionHidden('restore', $tenant);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('related_onboarding')
|
||||||
|
->assertActionExists('related_onboarding', fn (Action $action): bool => $action->getLabel() === 'Resume onboarding')
|
||||||
|
->assertActionHidden('archive')
|
||||||
|
->assertActionHidden('restore');
|
||||||
|
})->with([
|
||||||
|
'draft' => [fn (): Tenant => Tenant::factory()->draft()->create()],
|
||||||
|
'onboarding' => [fn (): Tenant => Tenant::factory()->onboarding()->create()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('shows archive only for active tenants on list and detail surfaces', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenants::class)
|
||||||
|
->assertTableActionVisible('archive', $tenant)
|
||||||
|
->assertTableActionHidden('restore', $tenant)
|
||||||
|
->assertTableActionHidden('related_onboarding', $tenant);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('archive')
|
||||||
|
->assertActionHidden('restore');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows restore only for archived tenants on list and detail surfaces', function (): void {
|
||||||
|
$tenant = Tenant::factory()->archived()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenants::class)
|
||||||
|
->assertTableActionVisible('restore', $tenant)
|
||||||
|
->assertTableActionHidden('archive', $tenant)
|
||||||
|
->assertTableActionHidden('related_onboarding', $tenant);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('restore')
|
||||||
|
->assertActionHidden('archive')
|
||||||
|
->assertActionHidden('related_onboarding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps lifecycle actions visible but disabled for in-scope members without mutation capability', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager');
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenants::class)
|
||||||
|
->assertTableActionVisible('archive', $tenant)
|
||||||
|
->assertTableActionDisabled('archive', $tenant);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 on tenant detail routes for non-members regardless of lifecycle state', function (\Closure $tenantFactory): void {
|
||||||
|
$tenant = $tenantFactory();
|
||||||
|
[$user] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(TenantResource::getUrl('view', ['record' => $tenant]))
|
||||||
|
->assertNotFound();
|
||||||
|
})->with([
|
||||||
|
'draft' => [fn (): Tenant => Tenant::factory()->draft()->create()],
|
||||||
|
'onboarding' => [fn (): Tenant => Tenant::factory()->onboarding()->create()],
|
||||||
|
'active' => [fn (): Tenant => Tenant::factory()->active()->create()],
|
||||||
|
'archived' => [fn (): Tenant => Tenant::factory()->archived()->create()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('keeps tenant detail lifecycle actions bound to the viewed record instead of the selected header tenant', function (): void {
|
||||||
|
$selectedTenant = Tenant::factory()->active()->create();
|
||||||
|
[$user, $selectedTenant] = createUserWithTenant(tenant: $selectedTenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$archivedTenant = Tenant::factory()->archived()->create([
|
||||||
|
'workspace_id' => (int) $selectedTenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $archivedTenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
Filament::setTenant($selectedTenant, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $archivedTenant->getRouteKey()])
|
||||||
|
->assertActionVisible('restore')
|
||||||
|
->assertActionHidden('archive')
|
||||||
|
->assertActionHidden('related_onboarding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses lifecycle-invalid archive and restore mutations without changing tenant state', function (): void {
|
||||||
|
$activeTenant = Tenant::factory()->active()->create();
|
||||||
|
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner');
|
||||||
|
|
||||||
|
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $activeTenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $onboardingTenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$auditLogger = app(WorkspaceAuditLogger::class);
|
||||||
|
|
||||||
|
TenantResource::restoreTenant($activeTenant, $auditLogger);
|
||||||
|
TenantResource::archiveTenant($onboardingTenant, $auditLogger);
|
||||||
|
|
||||||
|
$activeTenant->refresh();
|
||||||
|
$onboardingTenant->refresh();
|
||||||
|
|
||||||
|
expect($activeTenant->trashed())->toBeFalse()
|
||||||
|
->and($onboardingTenant->trashed())->toBeFalse()
|
||||||
|
->and($onboardingTenant->status)->toBe(Tenant::STATUS_ONBOARDING)
|
||||||
|
->and(AuditLog::query()
|
||||||
|
->whereIn('action', [
|
||||||
|
AuditActionId::TenantArchived->value,
|
||||||
|
AuditActionId::TenantRestored->value,
|
||||||
|
])
|
||||||
|
->whereIn('resource_id', [
|
||||||
|
(string) $activeTenant->getKey(),
|
||||||
|
(string) $onboardingTenant->getKey(),
|
||||||
|
])
|
||||||
|
->exists())->toBeFalse();
|
||||||
|
});
|
||||||
@ -56,6 +56,21 @@
|
|||||||
expect(TenantResource::canEdit($otherTenant))->toBeFalse();
|
expect(TenantResource::canEdit($otherTenant))->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not grant lifecycle mutation abilities for inaccessible tenants regardless of lifecycle state', function (\Closure $tenantFactory): void {
|
||||||
|
[$user] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
$otherTenant = $tenantFactory();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
expect(TenantResource::canEdit($otherTenant))->toBeFalse()
|
||||||
|
->and(TenantResource::canDelete($otherTenant))->toBeFalse();
|
||||||
|
})->with([
|
||||||
|
'draft' => [fn (): Tenant => Tenant::factory()->draft()->create()],
|
||||||
|
'onboarding' => [fn (): Tenant => Tenant::factory()->onboarding()->create()],
|
||||||
|
'active' => [fn (): Tenant => Tenant::factory()->active()->create()],
|
||||||
|
'archived' => [fn (): Tenant => Tenant::factory()->archived()->create()],
|
||||||
|
]);
|
||||||
|
|
||||||
it('keeps onboarding and archived tenants manageable when the actor is entitled', function () {
|
it('keeps onboarding and archived tenants manageable when the actor is entitled', function () {
|
||||||
$onboardingTenant = Tenant::factory()->onboarding()->create();
|
$onboardingTenant = Tenant::factory()->onboarding()->create();
|
||||||
[$user, $onboardingTenant] = createUserWithTenant(
|
[$user, $onboardingTenant] = createUserWithTenant(
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ListTenants as ListTenantsPage;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -32,6 +33,28 @@
|
|||||||
Queue::fake();
|
Queue::fake();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides non-member tenants from the tenant management list before lifecycle actions can be discovered', function (): void {
|
||||||
|
$visibleTenant = Tenant::factory()->active()->create();
|
||||||
|
$hiddenTenant = Tenant::factory()->archived()->create([
|
||||||
|
'workspace_id' => (int) $visibleTenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$user, $visibleTenant] = createUserWithTenant(tenant: $visibleTenant, role: 'owner');
|
||||||
|
|
||||||
|
Filament::setTenant($visibleTenant, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListTenantsPage::class)
|
||||||
|
->assertCanSeeTableRecords([$visibleTenant])
|
||||||
|
->assertCanNotSeeTableRecords([$hiddenTenant]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(PolicyResource::getUrl('index', tenant: $hiddenTenant))
|
||||||
|
->assertNotFound();
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
|
|
||||||
it('hides sync action for users who are not members of the tenant', function () {
|
it('hides sync action for users who are not members of the tenant', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$otherTenant = Tenant::factory()->create();
|
$otherTenant = Tenant::factory()->create();
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
@ -38,3 +42,34 @@
|
|||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Archived');
|
->assertSee('Archived');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows only restore as the lifecycle mutation on archived tenant detail for owners', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$tenant->delete();
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('restore')
|
||||||
|
->assertActionEnabled('restore')
|
||||||
|
->assertActionExists('restore', fn (Action $action): bool => $action->getLabel() === 'Restore')
|
||||||
|
->assertActionHidden('archive')
|
||||||
|
->assertActionHidden('related_onboarding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps archived tenant detail inspectable for readonly members while blocking lifecycle mutation', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$tenant->delete();
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('restore')
|
||||||
|
->assertActionDisabled('restore')
|
||||||
|
->assertActionHidden('archive')
|
||||||
|
->assertActionHidden('related_onboarding');
|
||||||
|
});
|
||||||
|
|||||||
@ -2,10 +2,15 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\ChooseTenant;
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\PanelRegistry;
|
use Filament\PanelRegistry;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
@ -58,3 +63,63 @@
|
|||||||
|
|
||||||
expect($user->getTenants($panel))->toHaveCount(0);
|
expect($user->getTenants($panel))->toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects selecting non-active memberships as tenant context', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$active = Tenant::factory()->active()->create(['name' => 'Allowed']);
|
||||||
|
$onboarding = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $active->workspace_id,
|
||||||
|
'name' => 'Onboarding',
|
||||||
|
]);
|
||||||
|
$archived = Tenant::factory()->archived()->create([
|
||||||
|
'workspace_id' => (int) $active->workspace_id,
|
||||||
|
'name' => 'Archived',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$active->getKey() => ['role' => 'readonly'],
|
||||||
|
$onboarding->getKey() => ['role' => 'readonly'],
|
||||||
|
$archived->getKey() => ['role' => 'readonly'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ChooseTenant::class)
|
||||||
|
->call('selectTenant', $onboarding->getKey())
|
||||||
|
->assertStatus(404);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ChooseTenant::class)
|
||||||
|
->call('selectTenant', $archived->getKey())
|
||||||
|
->assertStatus(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no tenant global-search results when the user lacks any active tenant membership', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$draft = Tenant::factory()->draft()->create(['name' => 'Search Draft']);
|
||||||
|
$onboarding = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $draft->workspace_id,
|
||||||
|
'name' => 'Search Onboarding',
|
||||||
|
]);
|
||||||
|
$archived = Tenant::factory()->archived()->create([
|
||||||
|
'workspace_id' => (int) $draft->workspace_id,
|
||||||
|
'name' => 'Search Archived',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$draft->getKey() => ['role' => 'readonly'],
|
||||||
|
$onboarding->getKey() => ['role' => 'readonly'],
|
||||||
|
$archived->getKey() => ['role' => 'readonly'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel('admin');
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
Filament::bootCurrentPanel();
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $draft->workspace_id);
|
||||||
|
|
||||||
|
expect(TenantResource::getGlobalSearchResults('Search'))->toHaveCount(0);
|
||||||
|
});
|
||||||
|
|||||||
@ -91,7 +91,7 @@
|
|||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->create([
|
$connection = ProviderConnection::factory()->consentGranted()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'entra_tenant_id' => fake()->uuid(),
|
'entra_tenant_id' => fake()->uuid(),
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
beforeEach(function (): void {
|
beforeEach(function (): void {
|
||||||
$this->resolver = new WorkspaceRedirectResolver;
|
$this->resolver = app(WorkspaceRedirectResolver::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects to managed tenants index when workspace has zero tenants', function (): void {
|
it('redirects to managed tenants index when workspace has zero tenants', function (): void {
|
||||||
|
|||||||
@ -8,11 +8,14 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Tenants\TenantActionPolicySurface;
|
||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
use App\Support\Providers\ProviderCredentialKind;
|
use App\Support\Providers\ProviderCredentialKind;
|
||||||
use App\Support\Providers\ProviderCredentialSource;
|
use App\Support\Providers\ProviderCredentialSource;
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
|
use App\Support\Tenants\TenantActionDescriptor;
|
||||||
|
use App\Support\Tenants\TenantActionSurface;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Pest\PendingCalls\TestCall;
|
use Pest\PendingCalls\TestCall;
|
||||||
@ -487,6 +490,26 @@ function createOnboardingDraft(array $attributes = []): TenantOnboardingSession
|
|||||||
return $factory->create($attributes);
|
return $factory->create($attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<TenantActionDescriptor>
|
||||||
|
*/
|
||||||
|
function tenantActionCatalog(Tenant $tenant, TenantActionSurface $surface, ?User $user = null): array
|
||||||
|
{
|
||||||
|
return app(TenantActionPolicySurface::class)->catalogForTenant($tenant, $surface, $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<TenantActionDescriptor> $actions
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
function tenantActionKeys(array $actions): array
|
||||||
|
{
|
||||||
|
return array_values(array_map(
|
||||||
|
static fn (TenantActionDescriptor $action): string => $action->key,
|
||||||
|
$actions,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
function ensureDefaultPlatformProviderConnection(Tenant $tenant, string $provider = 'microsoft'): ProviderConnection
|
function ensureDefaultPlatformProviderConnection(Tenant $tenant, string $provider = 'microsoft'): ProviderConnection
|
||||||
{
|
{
|
||||||
return ensureDefaultProviderConnection($tenant, $provider, ProviderConnectionType::Platform->value);
|
return ensureDefaultProviderConnection($tenant, $provider, ProviderConnectionType::Platform->value);
|
||||||
|
|||||||
@ -14,13 +14,13 @@
|
|||||||
expect($archived->label)->toBe('Archived');
|
expect($archived->label)->toBe('Archived');
|
||||||
expect($archived->color)->toBe('gray');
|
expect($archived->color)->toBe('gray');
|
||||||
|
|
||||||
$suspended = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'suspended');
|
$unknown = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'suspended');
|
||||||
expect($suspended->label)->toBe('Suspended');
|
expect($unknown->label)->toBe('Unknown');
|
||||||
expect($suspended->color)->toBe('warning');
|
expect($unknown->color)->toBe('gray');
|
||||||
|
|
||||||
$error = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'error');
|
$error = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'error');
|
||||||
expect($error->label)->toBe('Error');
|
expect($error->label)->toBe('Unknown');
|
||||||
expect($error->color)->toBe('danger');
|
expect($error->color)->toBe('gray');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps tenant app status values to canonical badge semantics', function (): void {
|
it('maps tenant app status values to canonical badge semantics', function (): void {
|
||||||
|
|||||||
81
tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php
Normal file
81
tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Policies\TenantOnboardingSessionPolicy;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Auth\Access\Response;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('returns not found for actors outside the active workspace when viewing a draft', function (): void {
|
||||||
|
$tenant = Tenant::factory()->onboarding()->create();
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$otherWorkspaceUser = User::factory()->create();
|
||||||
|
$otherWorkspace = \App\Models\Workspace::factory()->create();
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $owner,
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $otherWorkspace->getKey(),
|
||||||
|
'user_id' => (int) $otherWorkspaceUser->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $tenant->workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $owner,
|
||||||
|
'updated_by' => $owner,
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $otherWorkspace->getKey());
|
||||||
|
|
||||||
|
$result = app(TenantOnboardingSessionPolicy::class)->view($otherWorkspaceUser, $draft);
|
||||||
|
|
||||||
|
expect($result)->toBeInstanceOf(Response::class)
|
||||||
|
->and($result->allowed())->toBeFalse()
|
||||||
|
->and($result->status())->toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an honest forbidden message for entitled actors missing onboarding capability', function (): void {
|
||||||
|
$tenant = Tenant::factory()->onboarding()->create();
|
||||||
|
$readonlyUser = User::factory()->create();
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $readonlyUser,
|
||||||
|
role: 'readonly',
|
||||||
|
workspaceRole: 'readonly',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $tenant->workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $readonlyUser,
|
||||||
|
'updated_by' => $readonlyUser,
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$viewResult = app(TenantOnboardingSessionPolicy::class)->view($readonlyUser, $draft);
|
||||||
|
$cancelResult = app(TenantOnboardingSessionPolicy::class)->cancel($readonlyUser, $draft);
|
||||||
|
|
||||||
|
expect($viewResult)->toBeInstanceOf(Response::class)
|
||||||
|
->and($viewResult->allowed())->toBeFalse()
|
||||||
|
->and($viewResult->message())->toBe('You do not have permission to continue this onboarding draft.')
|
||||||
|
->and($cancelResult)->toBeInstanceOf(Response::class)
|
||||||
|
->and($cancelResult->allowed())->toBeFalse()
|
||||||
|
->and($cancelResult->message())->toBe('You do not have permission to cancel this onboarding draft.');
|
||||||
|
});
|
||||||
169
tests/Unit/Tenants/TenantActionPolicySurfaceTest.php
Normal file
169
tests/Unit/Tenants/TenantActionPolicySurfaceTest.php
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Tenants\TenantActionPolicySurface;
|
||||||
|
use App\Support\Tenants\TenantActionSurface;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('does not expose archive as a lifecycle action for draft and onboarding tenants', function (\Closure $tenantFactory): void {
|
||||||
|
$tenant = $tenantFactory();
|
||||||
|
|
||||||
|
expect(app(TenantActionPolicySurface::class)->lifecycleActionForTenant($tenant))
|
||||||
|
->toBeNull();
|
||||||
|
})->with([
|
||||||
|
'draft' => [fn (): Tenant => Tenant::factory()->draft()->create()],
|
||||||
|
'onboarding' => [fn (): Tenant => Tenant::factory()->onboarding()->create()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('returns archive only for active tenants and restore only for archived tenants', function (
|
||||||
|
\Closure $tenantFactory,
|
||||||
|
string $expectedKey,
|
||||||
|
string $expectedLabel,
|
||||||
|
): void {
|
||||||
|
$tenant = $tenantFactory();
|
||||||
|
$descriptor = app(TenantActionPolicySurface::class)->lifecycleActionForTenant($tenant);
|
||||||
|
|
||||||
|
expect($descriptor)
|
||||||
|
->not->toBeNull()
|
||||||
|
->and($descriptor?->key)->toBe($expectedKey)
|
||||||
|
->and($descriptor?->label)->toBe($expectedLabel)
|
||||||
|
->and($descriptor?->requiresConfirmation)->toBeTrue();
|
||||||
|
})->with([
|
||||||
|
'active' => [fn (): Tenant => Tenant::factory()->active()->create(), 'archive', 'Archive'],
|
||||||
|
'archived' => [fn (): Tenant => Tenant::factory()->archived()->create(), 'restore', 'Restore'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('returns resume onboarding as the primary action for draft and onboarding tenants with resumable drafts', function (\Closure $tenantFactory): void {
|
||||||
|
$tenant = $tenantFactory();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $tenant->workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$catalog = tenantActionCatalog($tenant, TenantActionSurface::TenantIndexRow, $user);
|
||||||
|
|
||||||
|
expect(tenantActionKeys($catalog))
|
||||||
|
->toBe(['view', 'related_onboarding'])
|
||||||
|
->and($catalog[1]->label)->toBe('Resume onboarding');
|
||||||
|
})->with([
|
||||||
|
'draft' => [fn (): Tenant => Tenant::factory()->draft()->create()],
|
||||||
|
'onboarding' => [fn (): Tenant => Tenant::factory()->onboarding()->create()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('keeps completed onboarding as a view-only overflow action for active tenants', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $tenant->workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'status' => 'completed',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$catalog = tenantActionCatalog($tenant, TenantActionSurface::TenantIndexRow, $user);
|
||||||
|
|
||||||
|
expect(tenantActionKeys($catalog))
|
||||||
|
->toBe(['view', 'archive', 'related_onboarding'])
|
||||||
|
->and($catalog[2]->label)->toBe('View completed onboarding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps tenant index catalogs within the two-primary-action overflow contract', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $tenant->workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'status' => 'completed',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$catalog = tenantActionCatalog($tenant, TenantActionSurface::TenantIndexRow, $user);
|
||||||
|
|
||||||
|
$primaryKeys = collect($catalog)
|
||||||
|
->filter(static fn ($action): bool => $action->group === 'primary')
|
||||||
|
->map(static fn ($action): string => $action->key)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$overflowKeys = collect($catalog)
|
||||||
|
->filter(static fn ($action): bool => $action->group === 'overflow')
|
||||||
|
->map(static fn ($action): string => $action->key)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($primaryKeys)->toBe(['view', 'archive'])
|
||||||
|
->and($overflowKeys)->toBe(['related_onboarding'])
|
||||||
|
->and(TenantResource::actionSurfaceDeclaration()->listRowPrimaryActionLimit())->toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates cached tenant index catalogs when the related onboarding draft lifecycle changes', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $tenant->workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$initialCatalog = tenantActionCatalog($tenant, TenantActionSurface::TenantIndexRow, $user);
|
||||||
|
|
||||||
|
expect(tenantActionKeys($initialCatalog))->toBe(['view', 'archive', 'related_onboarding'])
|
||||||
|
->and($initialCatalog[2]->group)->toBe('overflow')
|
||||||
|
->and($initialCatalog[2]->label)->toBe('View related onboarding');
|
||||||
|
|
||||||
|
$draft->forceFill([
|
||||||
|
'completed_at' => now()->addSecond(),
|
||||||
|
'lifecycle_state' => 'completed',
|
||||||
|
'updated_at' => now()->addSecond(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$tenant->refresh();
|
||||||
|
|
||||||
|
$updatedCatalog = tenantActionCatalog($tenant, TenantActionSurface::TenantIndexRow, $user);
|
||||||
|
|
||||||
|
expect(tenantActionKeys($updatedCatalog))->toBe(['view', 'archive', 'related_onboarding'])
|
||||||
|
->and($updatedCatalog[2]->group)->toBe('overflow')
|
||||||
|
->and($updatedCatalog[2]->label)->toBe('View completed onboarding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses workflow-accurate onboarding entry labels', function (int $draftCount, string $expectedLabel): void {
|
||||||
|
$descriptor = app(TenantActionPolicySurface::class)->onboardingEntryDescriptor($draftCount);
|
||||||
|
|
||||||
|
expect($descriptor->label)->toBe($expectedLabel);
|
||||||
|
})->with([
|
||||||
|
'no drafts' => [0, 'Add tenant'],
|
||||||
|
'one draft' => [1, 'Resume onboarding'],
|
||||||
|
'multiple drafts' => [2, 'Choose onboarding draft'],
|
||||||
|
]);
|
||||||
@ -35,7 +35,7 @@
|
|||||||
TenantLifecycle::Draft,
|
TenantLifecycle::Draft,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true,
|
false,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
],
|
],
|
||||||
@ -44,7 +44,7 @@
|
|||||||
TenantLifecycle::Onboarding,
|
TenantLifecycle::Onboarding,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true,
|
false,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
],
|
],
|
||||||
@ -67,3 +67,30 @@
|
|||||||
false,
|
false,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
it('returns lifecycle-safe primary management action keys', function (
|
||||||
|
\Closure $tenantFactory,
|
||||||
|
?string $expectedActionKey,
|
||||||
|
): void {
|
||||||
|
$tenant = $tenantFactory();
|
||||||
|
|
||||||
|
expect(app(TenantOperabilityService::class)->primaryManagementActionKey($tenant))
|
||||||
|
->toBe($expectedActionKey);
|
||||||
|
})->with([
|
||||||
|
'draft-primary' => [fn (): Tenant => Tenant::factory()->draft()->create(), null],
|
||||||
|
'onboarding-primary' => [fn (): Tenant => Tenant::factory()->onboarding()->create(), null],
|
||||||
|
'active-primary' => [fn (): Tenant => Tenant::factory()->active()->create(), 'archive'],
|
||||||
|
'archived-primary' => [fn (): Tenant => Tenant::factory()->archived()->create(), 'restore'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('can prefer onboarding as the primary management action for draft-like tenants', function (
|
||||||
|
\Closure $tenantFactory,
|
||||||
|
): void {
|
||||||
|
$tenant = $tenantFactory();
|
||||||
|
|
||||||
|
expect(app(TenantOperabilityService::class)->primaryManagementActionKey($tenant, preferOnboarding: true))
|
||||||
|
->toBe('resume_onboarding');
|
||||||
|
})->with([
|
||||||
|
'draft-prefers-onboarding' => [fn (): Tenant => Tenant::factory()->draft()->create()],
|
||||||
|
'onboarding-prefers-onboarding' => [fn (): Tenant => Tenant::factory()->onboarding()->create()],
|
||||||
|
]);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user