## 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
398 lines
13 KiB
PHP
398 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Pages\Operations;
|
|
|
|
use App\Filament\Resources\OperationRunResource;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
|
use App\Services\Tenants\TenantOperabilityService;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Navigation\CanonicalNavigationContext;
|
|
use App\Support\OperateHub\OperateHubShell;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
use App\Support\OpsUx\RunDetailPolling;
|
|
use App\Support\RedactionIntegrity;
|
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
|
use App\Support\Tenants\TenantInteractionLane;
|
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
|
use Filament\Actions\Action;
|
|
use Filament\Actions\ActionGroup;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Filament\Schemas\Components\EmbeddedSchema;
|
|
use Filament\Schemas\Schema;
|
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
|
use Illuminate\Support\Str;
|
|
|
|
class TenantlessOperationRunViewer extends Page
|
|
{
|
|
use AuthorizesRequests;
|
|
|
|
protected static bool $shouldRegisterNavigation = false;
|
|
|
|
protected static bool $isDiscovered = false;
|
|
|
|
protected static ?string $title = 'Operation run';
|
|
|
|
protected string $view = 'filament.pages.operations.tenantless-operation-run-viewer';
|
|
|
|
public OperationRun $run;
|
|
|
|
/**
|
|
* @var array<string, mixed>|null
|
|
*/
|
|
public ?array $navigationContextPayload = null;
|
|
|
|
public bool $opsUxIsTabHidden = false;
|
|
|
|
/**
|
|
* @return array<Action|ActionGroup>
|
|
*/
|
|
protected function getHeaderActions(): array
|
|
{
|
|
$operateHubShell = app(OperateHubShell::class);
|
|
$navigationContext = $this->navigationContext();
|
|
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
|
$runTenantId = isset($this->run) ? (int) ($this->run->tenant_id ?? 0) : 0;
|
|
|
|
$actions = [
|
|
Action::make('operate_hub_scope_run_detail')
|
|
->label($operateHubShell->scopeLabel(request()))
|
|
->color('gray')
|
|
->disabled(),
|
|
];
|
|
|
|
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
|
$actions[] = Action::make('operate_hub_back_to_origin_run_detail')
|
|
->label($navigationContext->backLinkLabel)
|
|
->color('gray')
|
|
->url($navigationContext->backLinkUrl);
|
|
} elseif ($activeTenant instanceof Tenant && (int) $activeTenant->getKey() === $runTenantId) {
|
|
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
|
|
->label('← Back to '.$activeTenant->name)
|
|
->color('gray')
|
|
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
|
} else {
|
|
$actions[] = Action::make('operate_hub_back_to_operations')
|
|
->label('Back to Operations')
|
|
->color('gray')
|
|
->url(fn (): string => route('admin.operations.index'));
|
|
}
|
|
|
|
if ($activeTenant instanceof Tenant) {
|
|
$actions[] = Action::make('operate_hub_show_all_operations')
|
|
->label('Show all operations')
|
|
->color('gray')
|
|
->url(fn (): string => route('admin.operations.index'));
|
|
}
|
|
|
|
$actions[] = Action::make('refresh')
|
|
->label('Refresh')
|
|
->icon('heroicon-o-arrow-path')
|
|
->color('gray')
|
|
->url(fn (): string => isset($this->run)
|
|
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
|
|
: route('admin.operations.index'));
|
|
|
|
if (! isset($this->run)) {
|
|
return $actions;
|
|
}
|
|
|
|
$related = OperationRunLinks::related($this->run, $this->relatedLinksTenant());
|
|
|
|
$relatedActions = [];
|
|
|
|
foreach ($related as $label => $url) {
|
|
$relatedActions[] = Action::make(Str::slug((string) $label, '_'))
|
|
->label((string) $label)
|
|
->url((string) $url)
|
|
->openUrlInNewTab();
|
|
}
|
|
|
|
if ($relatedActions !== []) {
|
|
$actions[] = ActionGroup::make($relatedActions)
|
|
->label('Open')
|
|
->icon('heroicon-o-arrow-top-right-on-square')
|
|
->color('gray');
|
|
}
|
|
|
|
$actions[] = $this->resumeCaptureAction();
|
|
|
|
return $actions;
|
|
}
|
|
|
|
public function mount(OperationRun $run): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->authorize('view', $run);
|
|
|
|
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
|
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
|
}
|
|
|
|
public function infolist(Schema $schema): Schema
|
|
{
|
|
return OperationRunResource::infolist($schema);
|
|
}
|
|
|
|
public function defaultInfolist(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->record($this->run)
|
|
->columns(2);
|
|
}
|
|
|
|
public function redactionIntegrityNote(): ?string
|
|
{
|
|
return isset($this->run) ? RedactionIntegrity::noteForRun($this->run) : null;
|
|
}
|
|
|
|
/**
|
|
* @return array{tone: string, title: string, body: string}|null
|
|
*/
|
|
public function canonicalContextBanner(): ?array
|
|
{
|
|
if (! isset($this->run)) {
|
|
return null;
|
|
}
|
|
|
|
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
|
$runTenant = $this->run->tenant;
|
|
|
|
if (! $runTenant instanceof Tenant) {
|
|
return [
|
|
'tone' => 'slate',
|
|
'title' => 'Workspace-level run',
|
|
'body' => $activeTenant instanceof Tenant
|
|
? 'This canonical workspace view is not tied to the current tenant context ('.$activeTenant->name.').'
|
|
: 'This canonical workspace view is not tied to any tenant.',
|
|
];
|
|
}
|
|
|
|
$messages = ['Run tenant: '.$runTenant->name.'.'];
|
|
$tone = 'sky';
|
|
$title = null;
|
|
|
|
if ($activeTenant instanceof Tenant && ! $activeTenant->is($runTenant)) {
|
|
$title = 'Current tenant context differs from this run';
|
|
array_unshift($messages, 'Current tenant context: '.$activeTenant->name.'.');
|
|
$messages[] = 'This canonical workspace view remains valid without switching tenant context.';
|
|
}
|
|
|
|
$referencedTenant = ReferencedTenantLifecyclePresentation::forOperationRun($runTenant);
|
|
|
|
if ($selectorAvailabilityMessage = $referencedTenant->selectorAvailabilityMessage()) {
|
|
$title ??= 'Run tenant is not available in the current tenant selector';
|
|
$tone = 'amber';
|
|
$messages[] = $selectorAvailabilityMessage;
|
|
|
|
if ($referencedTenant->contextNote !== null) {
|
|
$messages[] = $referencedTenant->contextNote;
|
|
}
|
|
} elseif (! $activeTenant instanceof Tenant) {
|
|
$title ??= 'Canonical workspace view';
|
|
$messages[] = 'No tenant context is currently selected.';
|
|
}
|
|
|
|
if ($title === null) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'tone' => $tone,
|
|
'title' => $title,
|
|
'body' => implode(' ', $messages),
|
|
];
|
|
}
|
|
|
|
public function pollInterval(): ?string
|
|
{
|
|
if (! isset($this->run)) {
|
|
return null;
|
|
}
|
|
|
|
if ($this->opsUxIsTabHidden === true) {
|
|
return null;
|
|
}
|
|
|
|
if (filled($this->mountedActions ?? null)) {
|
|
return null;
|
|
}
|
|
|
|
return RunDetailPolling::interval($this->run);
|
|
}
|
|
|
|
public function content(Schema $schema): Schema
|
|
{
|
|
return $schema->schema([
|
|
EmbeddedSchema::make('infolist'),
|
|
]);
|
|
}
|
|
|
|
private function resumeCaptureAction(): Action
|
|
{
|
|
return Action::make('resumeCapture')
|
|
->label('Resume capture')
|
|
->icon('heroicon-o-forward')
|
|
->requiresConfirmation()
|
|
->modalHeading('Resume capture')
|
|
->modalDescription('This will start a follow-up operation to capture remaining baseline evidence for this scope.')
|
|
->visible(fn (): bool => $this->canResumeCapture())
|
|
->action(function (): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
if (! isset($this->run)) {
|
|
Notification::make()
|
|
->title('Run not loaded')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$service = app(BaselineEvidenceCaptureResumeService::class);
|
|
$result = $service->resume($this->run, $user);
|
|
|
|
if (! ($result['ok'] ?? false)) {
|
|
$reason = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
|
|
|
Notification::make()
|
|
->title('Cannot resume capture')
|
|
->body('Reason: '.str_replace('.', ' ', $reason))
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$run = $result['run'] ?? null;
|
|
|
|
if (! $run instanceof OperationRun) {
|
|
Notification::make()
|
|
->title('Cannot resume capture')
|
|
->body('Reason: missing operation run')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$viewAction = Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::tenantlessView($run));
|
|
|
|
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
|
|
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
|
|
->actions([$viewAction])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
|
|
OperationUxPresenter::queuedToast((string) $run->type)
|
|
->actions([$viewAction])
|
|
->send();
|
|
});
|
|
}
|
|
|
|
private function navigationContext(): ?CanonicalNavigationContext
|
|
{
|
|
if (! is_array($this->navigationContextPayload)) {
|
|
return CanonicalNavigationContext::fromRequest(request());
|
|
}
|
|
|
|
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
|
|
|
|
return CanonicalNavigationContext::fromRequest($request);
|
|
}
|
|
|
|
private function canResumeCapture(): bool
|
|
{
|
|
if (! isset($this->run)) {
|
|
return false;
|
|
}
|
|
|
|
if ((string) $this->run->status !== 'completed') {
|
|
return false;
|
|
}
|
|
|
|
if (! in_array((string) $this->run->type, ['baseline_capture', 'baseline_compare'], true)) {
|
|
return false;
|
|
}
|
|
|
|
$context = is_array($this->run->context) ? $this->run->context : [];
|
|
$tokenKey = (string) $this->run->type === 'baseline_capture'
|
|
? 'baseline_capture.resume_token'
|
|
: 'baseline_compare.resume_token';
|
|
$token = data_get($context, $tokenKey);
|
|
|
|
if (! is_string($token) || trim($token) === '') {
|
|
return false;
|
|
}
|
|
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
$workspace = $this->run->workspace;
|
|
|
|
if (! $workspace instanceof \App\Models\Workspace) {
|
|
return false;
|
|
}
|
|
|
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
|
|
|
return $resolver->isMember($user, $workspace)
|
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
|
}
|
|
|
|
private function relatedLinksTenant(): ?Tenant
|
|
{
|
|
if (! isset($this->run)) {
|
|
return null;
|
|
}
|
|
|
|
$user = auth()->user();
|
|
$tenant = $this->run->tenant;
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return null;
|
|
}
|
|
|
|
if (! app(CapabilityResolver::class)->isMember($user, $tenant)) {
|
|
return null;
|
|
}
|
|
|
|
return app(TenantOperabilityService::class)->outcomeFor(
|
|
tenant: $tenant,
|
|
question: TenantOperabilityQuestion::SelectorEligibility,
|
|
actor: $user,
|
|
workspaceId: (int) ($this->run->workspace_id ?? 0),
|
|
lane: TenantInteractionLane::StandardActiveOperating,
|
|
)->allowed ? $tenant : null;
|
|
}
|
|
}
|