TenantAtlas/app/Filament/Resources/TenantResource/Pages/ViewTenant.php
ahmido 417df4f9aa feat: central tenant operability policy (#177)
## Summary
- centralize tenant operability into a lane-aware, actor-aware policy boundary
- align selector eligibility, administrative discoverability, remembered context, tenant-bound routes, and canonical run viewers
- add focused Pest coverage plus Spec 148 artifacts and final polish task completion

## Validation
- `vendor/bin/sail artisan test --compact tests/Unit/Tenants/TenantOperabilityServiceTest.php tests/Unit/Tenants/TenantOperabilityOutcomeTest.php tests/Feature/Workspaces/ChooseTenantPageTest.php tests/Feature/Workspaces/SelectTenantControllerTest.php tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/OpsUx/OperateHubShellTest.php tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php tests/Feature/Rbac/TenantResourceAuthorizationTest.php tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`
- `vendor/bin/sail bin pint --dirty --format agent`
- manual browser smoke checks on `/admin/choose-tenant`, `/admin/tenants`, `/admin/onboarding`, `/admin/onboarding/{draft}`, and `/admin/operations/{run}`

## Filament / platform notes
- Livewire v4 compliance preserved
- panel provider registration unchanged in `bootstrap/providers.php`
- Tenant resource global search remains backed by existing view/edit pages and is now separated from active-only selector eligibility
- destructive actions remain action closures with confirmation and authorization enforcement
- no asset pipeline changes and no new `filament:assets` deployment requirement

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #177
2026-03-17 11:48:55 +00:00

314 lines
16 KiB
PHP

<?php
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Widgets\Tenant\AdminRolesSummaryWidget;
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
use App\Filament\Widgets\Tenant\TenantVerificationReport;
use App\Jobs\RefreshTenantRbacHealthJob;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\OperationRunService;
use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use App\Support\Tenants\TenantActionSurface;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
class ViewTenant extends ViewRecord
{
protected static string $resource = TenantResource::class;
public function getHeaderWidgetsColumns(): int|array
{
return 1;
}
protected function getHeaderWidgets(): array
{
return [
TenantArchivedBanner::class,
RecentOperationsSummary::class,
TenantVerificationReport::class,
AdminRolesSummaryWidget::class,
];
}
protected function getHeaderActions(): array
{
return [
Actions\ActionGroup::make([
UiEnforcement::forAction(
Actions\Action::make('provider_connections')
->label('Provider connections')
->icon('heroicon-o-link')
->url(fn (Tenant $record): string => ProviderConnectionResource::getUrl('index', ['tenant_id' => $record->external_id], panel: 'admin'))
)
->requireCapability(Capabilities::PROVIDER_VIEW)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('edit')
->label('Edit')
->icon('heroicon-o-pencil-square')
->url(fn (Tenant $record): string => TenantResource::getUrl('edit', ['record' => $record]))
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
Actions\Action::make('related_onboarding')
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantViewHeader) ?? 'View related onboarding')
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-eye')
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
Actions\Action::make('admin_consent')
->label('Grant admin consent')
->icon('heroicon-o-clipboard-document')
->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record))
->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null)
->openUrlInNewTab(),
Actions\Action::make('open_in_entra')
->label('Open in Entra')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record) => TenantResource::entraUrl($record))
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
->openUrlInNewTab(),
UiEnforcement::forAction(
Actions\Action::make('verify')
->label('Verify configuration')
->icon('heroicon-o-check-badge')
->color('primary')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => TenantResource::verificationActionVisible($record))
->action(function (
Tenant $record,
StartVerification $verification,
): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($record)) {
abort(404);
}
$result = $verification->providerConnectionCheckForTenant(
tenant: $record,
initiator: $user,
extraContext: [
'surface' => [
'kind' => 'tenant_view_header',
],
],
);
$runUrl = OperationRunLinks::tenantlessView($result->run);
if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
Notification::make()
->title('Another operation is already running')
->body('Please wait for the active run to finish.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
return;
}
if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
return;
}
if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
$actions = [
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
];
$nextSteps = $result->run->context['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
foreach ($nextSteps as $index => $step) {
if (! is_array($step)) {
continue;
}
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$actions[] = Actions\Action::make('next_step_'.$index)
->label($label)
->url($url);
break;
}
Notification::make()
->title('Verification blocked')
->body("Blocked by provider configuration ({$reasonCode}).")
->warning()
->actions($actions)
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
}),
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
TenantResource::rbacAction(),
UiEnforcement::forAction(
Actions\Action::make('refresh_rbac')
->label('Refresh RBAC status')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => $record->isActive())
->action(function (Tenant $record): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($record)) {
abort(404);
}
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->ensureRun(
tenant: $record,
type: OperationRunType::RbacHealthCheck->value,
inputs: [
'tenant_id' => (int) $record->getKey(),
'surface' => 'tenant_view_header',
],
initiator: $user,
);
$runUrl = OperationRunLinks::tenantlessView($opRun);
if ($opRun->wasRecentlyCreated === false) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
return;
}
RefreshTenantRbacHealthJob::dispatch(
(int) $record->getKey(),
(int) $user->getKey(),
$opRun,
);
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
}),
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('restore')
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Restore')
->color('success')
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->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 {
TenantResource::restoreTenant($record, $auditLogger);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->destructive()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('archive')
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Archive')
->color('danger')
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->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 {
TenantResource::archiveTenant($record, $auditLogger);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->destructive()
->apply(),
])
->label('Actions')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
];
}
}