diff --git a/apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php b/apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php index d00cd28f..93b0544c 100644 --- a/apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php +++ b/apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php @@ -4,6 +4,8 @@ namespace App\Console\Commands; +use App\Filament\Pages\TenantDashboard; +use App\Filament\Resources\BackupSetResource; use App\Models\BackupItem; use App\Models\BackupSet; use App\Models\Policy; @@ -172,9 +174,9 @@ public function handle(): int ['User password', $password], ['ManagedEnvironment', (string) $tenant->name], ['ManagedEnvironment external id', (string) $tenant->external_id], - ['Dashboard URL', "/admin/t/{$tenant->external_id}"], + ['Dashboard URL', TenantDashboard::getUrl(tenant: $tenant)], ['Fixture login URL', route('admin.local.backup-health-browser-fixture-login', absolute: false)], - ['Blocked route', "/admin/t/{$tenant->external_id}/backup-sets"], + ['Blocked route', BackupSetResource::getUrl(panel: 'admin', tenant: $tenant)], ['Locally denied capability', 'tenant.view'], ], ); diff --git a/apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php b/apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php index b4400a9c..e58244e5 100644 --- a/apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php +++ b/apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php @@ -65,10 +65,6 @@ private static function currentPanelId(mixed $request): ?string : null; if (is_string($routeName) && $routeName !== '') { - if (str_contains($routeName, '.tenant.')) { - return 'tenant'; - } - if (str_contains($routeName, '.admin.')) { return 'admin'; } @@ -78,10 +74,6 @@ private static function currentPanelId(mixed $request): ?string ? '/'.ltrim((string) $request->path(), '/') : null; - if (is_string($path) && str_starts_with($path, '/admin/t/')) { - return 'tenant'; - } - if (is_string($path) && str_starts_with($path, '/admin/')) { return 'admin'; } diff --git a/apps/platform/app/Filament/Pages/BaselineCompareMatrix.php b/apps/platform/app/Filament/Pages/BaselineCompareMatrix.php index a67552ad..d3de9a6a 100644 --- a/apps/platform/app/Filament/Pages/BaselineCompareMatrix.php +++ b/apps/platform/app/Filament/Pages/BaselineCompareMatrix.php @@ -447,7 +447,6 @@ public function tenantCompareUrl(int $tenantId, ?string $subjectKey = null): ?st return BaselineCompareLanding::getUrl( parameters: $this->navigationContext($tenant, $subjectKey)->toQuery(), - panel: 'tenant', tenant: $tenant, ); } diff --git a/apps/platform/app/Filament/Pages/ChooseTenant.php b/apps/platform/app/Filament/Pages/ChooseTenant.php index 8627d58c..e4ab5a35 100644 --- a/apps/platform/app/Filament/Pages/ChooseTenant.php +++ b/apps/platform/app/Filament/Pages/ChooseTenant.php @@ -126,7 +126,7 @@ public function selectTenant(int $tenantId): void abort(404); } - $this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)); + $this->redirect(TenantDashboard::getUrl(tenant: $tenant)); } public function tenantLifecyclePresentation(ManagedEnvironment $tenant): TenantLifecyclePresentation diff --git a/apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php b/apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php index 9add7bb1..01ed2a4d 100644 --- a/apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php +++ b/apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php @@ -565,7 +565,7 @@ private function findingDetailUrl(Finding $record): string return '#'; } - $url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant); + $url = FindingResource::getUrl('view', ['record' => $record], tenant: $tenant); return $this->appendQuery($url, $this->navigationContext()->toQuery()); } diff --git a/apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php b/apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php index 0c7a52cc..9841052b 100644 --- a/apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php +++ b/apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php @@ -694,7 +694,7 @@ private function findingDetailUrl(Finding $record): string return '#'; } - $url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant); + $url = FindingResource::getUrl('view', ['record' => $record], tenant: $tenant); return $this->appendQuery($url, $this->navigationContext()->toQuery()); } diff --git a/apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php b/apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php index c0f54e3f..0788ad51 100644 --- a/apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php +++ b/apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php @@ -280,7 +280,7 @@ public function emptyState(): array 'action_name' => 'open_tenant_findings_empty', 'action_label' => 'Open tenant findings', 'action_kind' => 'url', - 'action_url' => FindingResource::getUrl('index', panel: 'tenant', tenant: $activeTenant), + 'action_url' => FindingResource::getUrl('index', tenant: $activeTenant), ]; } @@ -636,7 +636,7 @@ private function findingDetailUrl(Finding $record): string return '#'; } - $url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant); + $url = FindingResource::getUrl('view', ['record' => $record], tenant: $tenant); return $this->appendQuery($url, $this->navigationContext()->toQuery()); } diff --git a/apps/platform/app/Filament/Pages/Governance/DecisionRegister.php b/apps/platform/app/Filament/Pages/Governance/DecisionRegister.php index 9e08b330..f3216cff 100644 --- a/apps/platform/app/Filament/Pages/Governance/DecisionRegister.php +++ b/apps/platform/app/Filament/Pages/Governance/DecisionRegister.php @@ -681,7 +681,7 @@ public function decisionUrl(FindingException $record): ?string } return $this->appendQuery( - FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant), + FindingExceptionResource::getUrl('view', ['record' => $record], tenant: $tenant), $this->navigationContext()->toQuery(), ); } diff --git a/apps/platform/app/Filament/Pages/InventoryCoverage.php b/apps/platform/app/Filament/Pages/InventoryCoverage.php index 74b9dbfb..ba985237 100644 --- a/apps/platform/app/Filament/Pages/InventoryCoverage.php +++ b/apps/platform/app/Filament/Pages/InventoryCoverage.php @@ -523,7 +523,7 @@ public function basisRunSummary(): array 'badgeColor' => null, 'runUrl' => null, 'historyUrl' => null, - 'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant), + 'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant), ]; } @@ -539,7 +539,7 @@ public function basisRunSummary(): array 'badgeColor' => $badge->color, 'runUrl' => $canViewRun ? OperationRunLinks::view($truth->basisRun, $tenant) : null, 'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null, - 'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant), + 'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant), ]; } diff --git a/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php b/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php index e1adbc4a..b813aadf 100644 --- a/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php +++ b/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php @@ -443,7 +443,7 @@ private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReview ], 'next_step' => $nextStep, 'view_url' => $snapshot->tenant - ? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant, panel: 'tenant') + ? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant) : null, ]; } diff --git a/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php b/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php index 68ab5676..9461bc7c 100644 --- a/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php +++ b/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php @@ -243,7 +243,7 @@ protected function getHeaderActions(): array return null; } - return FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant); + return FindingExceptionResource::getUrl('index', tenant: $tenant); }); $selectedContextActions = [ @@ -490,7 +490,7 @@ public function selectedExceptionUrl(): ?string } return $this->appendQuery( - FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant), + FindingExceptionResource::getUrl('view', ['record' => $record], tenant: $record->tenant), $this->navigationContext()?->toQuery() ?? [], ); } @@ -504,7 +504,7 @@ public function selectedFindingUrl(): ?string } return $this->appendQuery( - FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant), + FindingResource::getUrl('view', ['record' => $record->finding], tenant: $record->tenant), $this->navigationContext()?->toQuery() ?? [], ); } diff --git a/apps/platform/app/Filament/Pages/Monitoring/Operations.php b/apps/platform/app/Filament/Pages/Monitoring/Operations.php index 190d2842..c4b16aa5 100644 --- a/apps/platform/app/Filament/Pages/Monitoring/Operations.php +++ b/apps/platform/app/Filament/Pages/Monitoring/Operations.php @@ -204,7 +204,7 @@ protected function getHeaderActions(): array ->label('Back to '.$activeTenant->name) ->icon('heroicon-o-arrow-left') ->color('gray') - ->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant)); + ->url(\App\Filament\Pages\TenantDashboard::getUrl(tenant: $activeTenant)); } if ($activeTenant instanceof ManagedEnvironment) { @@ -218,7 +218,7 @@ protected function getHeaderActions(): array $this->removeTableFilter('managed_environment_id'); - $this->redirect('/admin/operations'); + $this->redirect(OperationRunLinks::index(allTenants: true)); }); } @@ -432,6 +432,7 @@ private function shouldForceWorkspaceWideTenantScope(): bool private function operationsUrl(array $overrides = []): string { $parameters = array_merge( + ['workspace' => app(WorkspaceContext::class)->currentWorkspace(request())], $this->navigationContext()?->toQuery() ?? [], [ 'tenant_scope' => $this->shouldForceWorkspaceWideTenantScope() ? 'all' : null, diff --git a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index f3ddd4c5..ee166bcb 100644 --- a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -126,7 +126,7 @@ protected function getHeaderActions(): array $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)); + ->url(\App\Filament\Pages\TenantDashboard::getUrl(tenant: $activeTenant)); } else { $actions[] = Action::make('operate_hub_back_to_operations') ->label('Back to Operations') diff --git a/apps/platform/app/Filament/Pages/TenantDashboard.php b/apps/platform/app/Filament/Pages/TenantDashboard.php index 68c600e2..4909eec8 100644 --- a/apps/platform/app/Filament/Pages/TenantDashboard.php +++ b/apps/platform/app/Filament/Pages/TenantDashboard.php @@ -11,6 +11,7 @@ use App\Models\SupportRequest; use App\Models\ManagedEnvironment; use App\Models\User; +use App\Models\Workspace; use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Auth\CapabilityResolver; use App\Support\Auth\Capabilities; @@ -90,7 +91,27 @@ public function getSubheading(): string | Htmlable | null */ public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string { - return parent::getUrl($parameters, $isAbsolute, $panel ?? 'tenant', $tenant, $shouldGuessMissingParameters); + $resolvedTenant = $tenant instanceof ManagedEnvironment + ? $tenant + : (($parameters['tenant'] ?? $parameters['environment'] ?? null) instanceof ManagedEnvironment + ? ($parameters['tenant'] ?? $parameters['environment']) + : null); + + if (! $resolvedTenant instanceof ManagedEnvironment) { + return url('/admin'); + } + + $workspace = $parameters['workspace'] ?? null; + + if (! $workspace instanceof Workspace) { + $workspace = $resolvedTenant->workspace()->first(); + } + + if (! $workspace instanceof Workspace) { + return url('/admin'); + } + + return url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments/'.$resolvedTenant->getRouteKey()); } /** diff --git a/apps/platform/app/Filament/Pages/TenantRequiredPermissions.php b/apps/platform/app/Filament/Pages/TenantRequiredPermissions.php index 97732ae4..c9322b13 100644 --- a/apps/platform/app/Filament/Pages/TenantRequiredPermissions.php +++ b/apps/platform/app/Filament/Pages/TenantRequiredPermissions.php @@ -38,7 +38,7 @@ class TenantRequiredPermissions extends Page implements HasTable protected static bool $shouldRegisterNavigation = false; - protected static ?string $slug = 'tenants/{tenant}/required-permissions'; + protected static ?string $slug = 'workspaces/{workspace}/environments/{tenant}/required-permissions'; protected static ?string $title = 'Required permissions'; diff --git a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index 1a3453ca..a1274fbb 100644 --- a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -5142,7 +5142,7 @@ public function completeOnboarding(): void resourceId: (string) $tenant->getKey(), ); - $this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)); + $this->redirect(TenantDashboard::getUrl(tenant: $tenant)); } private function verificationRun(): ?OperationRun diff --git a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php index 6bd7cd14..c55c4949 100644 --- a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php +++ b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php @@ -81,7 +81,7 @@ public function getTenants(): Collection public function goToChooseTenant(): void { - $this->redirect(ChooseTenant::getUrl()); + $this->redirect(route('admin.workspace.managed-tenants.index', ['workspace' => $this->workspace])); } public function openTenant(int $tenantId): void @@ -106,6 +106,8 @@ public function openTenant(int $tenantId): void abort(404); } - $this->redirect(TenantResource::getUrl('view', ['record' => $tenant])); + $this->redirect( + \App\Filament\Pages\TenantDashboard::getUrl(tenant: $tenant) + ); } } diff --git a/apps/platform/app/Filament/Resources/FindingExceptionResource.php b/apps/platform/app/Filament/Resources/FindingExceptionResource.php index 4fcdeb04..14b07769 100644 --- a/apps/platform/app/Filament/Resources/FindingExceptionResource.php +++ b/apps/platform/app/Filament/Resources/FindingExceptionResource.php @@ -270,7 +270,7 @@ public static function relatedContextEntries(FindingException $record): array label: 'Finding', value: static::findingSummary($record), secondaryValue: 'Return to the linked finding detail.', - targetUrl: FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant), + targetUrl: FindingResource::getUrl('view', ['record' => $record->finding], tenant: $record->tenant), targetKind: 'direct_record', priority: 10, actionLabel: 'Open finding', diff --git a/apps/platform/app/Filament/Resources/FindingResource.php b/apps/platform/app/Filament/Resources/FindingResource.php index ec0996af..d7c62fd6 100644 --- a/apps/platform/app/Filament/Resources/FindingResource.php +++ b/apps/platform/app/Filament/Resources/FindingResource.php @@ -2082,7 +2082,7 @@ private static function findingExceptionViewUrl(\App\Models\FindingException $ex return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'admin'); } - return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'tenant', tenant: $tenant); + return FindingExceptionResource::getUrl('view', ['record' => $exception], tenant: $tenant); } /** diff --git a/apps/platform/app/Filament/Resources/OperationRunResource.php b/apps/platform/app/Filament/Resources/OperationRunResource.php index 99883749..40dbf3fe 100644 --- a/apps/platform/app/Filament/Resources/OperationRunResource.php +++ b/apps/platform/app/Filament/Resources/OperationRunResource.php @@ -1618,7 +1618,7 @@ public static function restoreContinuation(OperationRun $record): ?array 'follow_up_required' => $attention->followUpRequired, 'badge_label' => \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $attention->state)->label, 'link_url' => $canOpenRestore - ? RestoreRunResource::getUrl('view', ['record' => $restoreRun], panel: 'tenant', tenant: $tenant) + ? RestoreRunResource::getUrl('view', ['record' => $restoreRun], tenant: $tenant) : null, 'link_available' => $canOpenRestore, ]; diff --git a/apps/platform/app/Filament/Resources/StoredReportResource.php b/apps/platform/app/Filament/Resources/StoredReportResource.php index d3c4488c..90cdb375 100644 --- a/apps/platform/app/Filament/Resources/StoredReportResource.php +++ b/apps/platform/app/Filament/Resources/StoredReportResource.php @@ -568,7 +568,7 @@ public static function currentReportUrlFor(StoredReport $report): ?string return null; } - return static::getUrl('view', ['record' => $current], panel: 'tenant', tenant: $tenant); + return static::getUrl('view', ['record' => $current], tenant: $tenant); } public static function scopeCurrentRecords(Builder $query): Builder @@ -663,6 +663,6 @@ private static function tenantOverviewUrl(): string return '#'; } - return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant); + return TenantDashboard::getUrl(tenant: $tenant); } } diff --git a/apps/platform/app/Filament/Resources/TenantResource.php b/apps/platform/app/Filament/Resources/TenantResource.php index f322c5fa..4ff2526c 100644 --- a/apps/platform/app/Filament/Resources/TenantResource.php +++ b/apps/platform/app/Filament/Resources/TenantResource.php @@ -1344,14 +1344,13 @@ public static function tenantDashboardOpenUrl(ManagedEnvironment $record, array $arrivalState = static::portfolioArrivalStateForTenant($record, $triageState); if ($arrivalState === null) { - return TenantDashboard::getUrl(panel: 'tenant', tenant: $record); + return TenantDashboard::getUrl(tenant: $record); } return TenantDashboard::getUrl( parameters: [ PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($arrivalState), ], - panel: 'tenant', tenant: $record, ); } diff --git a/apps/platform/app/Filament/Resources/TenantReviewResource.php b/apps/platform/app/Filament/Resources/TenantReviewResource.php index 69b909be..364bdf53 100644 --- a/apps/platform/app/Filament/Resources/TenantReviewResource.php +++ b/apps/platform/app/Filament/Resources/TenantReviewResource.php @@ -48,6 +48,7 @@ use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\ViewEntry; use Filament\Notifications\Notification; +use Filament\Panel; use Filament\Resources\Resource; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; @@ -87,6 +88,15 @@ public static function shouldRegisterNavigation(): bool return Filament::getCurrentPanel()?->getId() === 'tenant'; } + public static function getSlug(?Panel $panel = null): string + { + if ($panel?->getId() === 'admin') { + return 'tenant-reviews'; + } + + return parent::getSlug($panel); + } + public static function getNavigationGroup(): string { return __('localization.review.reporting'); @@ -605,7 +615,7 @@ public static function tenantScopedUrl( ?ManagedEnvironment $tenant = null, ?string $panel = null, ): string { - $panelId = $panel ?? 'tenant'; + $panelId = 'admin'; return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant); } diff --git a/apps/platform/app/Filament/Widgets/Dashboard/BaselineCompareNow.php b/apps/platform/app/Filament/Widgets/Dashboard/BaselineCompareNow.php index aee1a863..a92da0e5 100644 --- a/apps/platform/app/Filament/Widgets/Dashboard/BaselineCompareNow.php +++ b/apps/platform/app/Filament/Widgets/Dashboard/BaselineCompareNow.php @@ -53,7 +53,7 @@ protected function getViewData(): array return $empty; } - $tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant); + $tenantLandingUrl = BaselineCompareLanding::getUrl(tenant: $tenant); $operationsFollowUpCount = (int) OperationRun::query() ->where('managed_environment_id', (int) $tenant->getKey()) ->dashboardNeedsFollowUp() @@ -179,7 +179,7 @@ private function findingsUrl(ManagedEnvironment $tenant, TenantGovernanceAggrega default => [], }; - return FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant); + return FindingResource::getUrl('index', $parameters, tenant: $tenant); } private function canOpenFindings(ManagedEnvironment $tenant): bool diff --git a/apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php b/apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php index 5b3e1425..0feef38a 100644 --- a/apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php +++ b/apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php @@ -164,7 +164,7 @@ protected function getViewData(): array 'badge' => 'Baseline', 'badgeColor' => $compareAssessment->tone, 'actionLabel' => 'Open Baseline Compare', - 'actionUrl' => BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant), + 'actionUrl' => BaselineCompareLanding::getUrl(tenant: $tenant), ]; } @@ -248,7 +248,7 @@ protected function getViewData(): array private function findingsAction(ManagedEnvironment $tenant, string $label, array $parameters): array { $url = $this->canOpenFindings($tenant) - ? FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant) + ? FindingResource::getUrl('index', $parameters, tenant: $tenant) : null; return [ @@ -435,7 +435,7 @@ private function backupHealthActionPayload(ManagedEnvironment $tenant, ?BackupHe 'actionLabel' => $label ?? $target->label, 'actionUrl' => BackupSetResource::getUrl('index', [ 'backup_health_reason' => $target->reason, - ], panel: 'tenant', tenant: $tenant), + ], tenant: $tenant), 'actionDisabled' => false, 'helperText' => null, ], @@ -443,7 +443,7 @@ private function backupHealthActionPayload(ManagedEnvironment $tenant, ?BackupHe 'actionLabel' => $label ?? $target->label, 'actionUrl' => BackupScheduleResource::getUrl('index', [ 'backup_health_reason' => $target->reason, - ], panel: 'tenant', tenant: $tenant), + ], tenant: $tenant), 'actionDisabled' => false, 'helperText' => null, ], @@ -467,7 +467,7 @@ private function backupHealthBackupSetActionPayload(ManagedEnvironment $tenant, 'actionLabel' => 'Open backup sets', 'actionUrl' => BackupSetResource::getUrl('index', [ 'backup_health_reason' => $target->reason, - ], panel: 'tenant', tenant: $tenant), + ], tenant: $tenant), 'actionDisabled' => false, 'helperText' => 'The latest backup detail is no longer available.', ]; @@ -481,7 +481,7 @@ private function backupHealthBackupSetActionPayload(ManagedEnvironment $tenant, 'actionUrl' => BackupSetResource::getUrl('view', [ 'record' => $target->recordId, 'backup_health_reason' => $target->reason, - ], panel: 'tenant', tenant: $tenant), + ], tenant: $tenant), 'actionDisabled' => false, 'helperText' => null, ]; @@ -490,7 +490,7 @@ private function backupHealthBackupSetActionPayload(ManagedEnvironment $tenant, 'actionLabel' => 'Open backup sets', 'actionUrl' => BackupSetResource::getUrl('index', [ 'backup_health_reason' => $target->reason, - ], panel: 'tenant', tenant: $tenant), + ], tenant: $tenant), 'actionDisabled' => false, 'helperText' => 'The latest backup detail is no longer available.', ]; @@ -564,7 +564,7 @@ private function recoveryActionPayload(ManagedEnvironment $tenant, array $recove 'actionUrl' => RestoreRunResource::getUrl('view', [ 'record' => (int) $latestRun->getKey(), 'recovery_posture_reason' => $reason, - ], panel: 'tenant', tenant: $tenant), + ], tenant: $tenant), 'actionDisabled' => false, 'helperText' => null, ]; @@ -591,6 +591,6 @@ private function restoreRunListUrl(ManagedEnvironment $tenant, string $reason): { return RestoreRunResource::getUrl('index', [ 'recovery_posture_reason' => $reason, - ], panel: 'tenant', tenant: $tenant); + ], tenant: $tenant); } } diff --git a/apps/platform/app/Filament/Widgets/Dashboard/RecoveryReadiness.php b/apps/platform/app/Filament/Widgets/Dashboard/RecoveryReadiness.php index 784b46fe..feec5883 100644 --- a/apps/platform/app/Filament/Widgets/Dashboard/RecoveryReadiness.php +++ b/apps/platform/app/Filament/Widgets/Dashboard/RecoveryReadiness.php @@ -123,13 +123,13 @@ private function resolveBackupHealthAction(ManagedEnvironment $tenant, ?BackupHe BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX => [ 'actionUrl' => BackupSetResource::getUrl('index', [ 'backup_health_reason' => $target->reason, - ], panel: 'tenant', tenant: $tenant), + ], tenant: $tenant), 'helperText' => null, ], BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX => [ 'actionUrl' => BackupScheduleResource::getUrl('index', [ 'backup_health_reason' => $target->reason, - ], panel: 'tenant', tenant: $tenant), + ], tenant: $tenant), 'helperText' => null, ], BackupHealthActionTarget::SURFACE_BACKUP_SET_VIEW => $this->resolveBackupSetAction($tenant, $target), @@ -146,7 +146,7 @@ private function resolveBackupSetAction(ManagedEnvironment $tenant, BackupHealth return [ 'actionUrl' => BackupSetResource::getUrl('index', [ 'backup_health_reason' => $target->reason, - ], panel: 'tenant', tenant: $tenant), + ], tenant: $tenant), 'helperText' => 'The latest backup detail is no longer available.', ]; } @@ -158,14 +158,14 @@ private function resolveBackupSetAction(ManagedEnvironment $tenant, BackupHealth 'actionUrl' => BackupSetResource::getUrl('view', [ 'record' => $target->recordId, 'backup_health_reason' => $target->reason, - ], panel: 'tenant', tenant: $tenant), + ], tenant: $tenant), 'helperText' => null, ]; } catch (ModelNotFoundException) { return [ 'actionUrl' => BackupSetResource::getUrl('index', [ 'backup_health_reason' => $target->reason, - ], panel: 'tenant', tenant: $tenant), + ], tenant: $tenant), 'helperText' => 'The latest backup detail is no longer available.', ]; } @@ -200,7 +200,7 @@ private function resolveRecoveryAction(ManagedEnvironment $tenant, array $recove 'actionUrl' => RestoreRunResource::getUrl('view', [ 'record' => (int) $latestRun->getKey(), 'recovery_posture_reason' => $reason, - ], panel: 'tenant', tenant: $tenant), + ], tenant: $tenant), 'helperText' => null, ]; } catch (ModelNotFoundException) { @@ -233,6 +233,6 @@ private function restoreRunListUrl(ManagedEnvironment $tenant, string $reason): { return RestoreRunResource::getUrl('index', [ 'recovery_posture_reason' => $reason, - ], panel: 'tenant', tenant: $tenant); + ], tenant: $tenant); } } diff --git a/apps/platform/app/Filament/Widgets/Tenant/AdminRolesSummaryWidget.php b/apps/platform/app/Filament/Widgets/Tenant/AdminRolesSummaryWidget.php index 6934e65f..d3e073c8 100644 --- a/apps/platform/app/Filament/Widgets/Tenant/AdminRolesSummaryWidget.php +++ b/apps/platform/app/Filament/Widgets/Tenant/AdminRolesSummaryWidget.php @@ -159,7 +159,7 @@ protected function getViewData(): array 'canManage' => $canManage, 'canView' => $canView, 'viewReportUrl' => $canView - ? StoredReportResource::getUrl('view', ['record' => $report], panel: 'tenant', tenant: $tenant) + ? StoredReportResource::getUrl('view', ['record' => $report], tenant: $tenant) : null, ]; } diff --git a/apps/platform/app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php b/apps/platform/app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php index b4b2c5a1..7ca8733f 100644 --- a/apps/platform/app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php +++ b/apps/platform/app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php @@ -36,10 +36,10 @@ protected function getViewData(): array ? OperationRunLinks::view($aggregate->stats->operationRunId, $tenant) : null; - $landingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant); + $landingUrl = BaselineCompareLanding::getUrl(tenant: $tenant); $nextActionUrl = match ($aggregate->nextActionTarget) { 'run' => $runUrl, - 'findings' => \App\Filament\Resources\FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant), + 'findings' => \App\Filament\Resources\FindingResource::getUrl('index', tenant: $tenant), 'landing' => $landingUrl, default => null, }; diff --git a/apps/platform/app/Http/Controllers/ClearTenantContextController.php b/apps/platform/app/Http/Controllers/ClearTenantContextController.php index 89fb5476..79c9484c 100644 --- a/apps/platform/app/Http/Controllers/ClearTenantContextController.php +++ b/apps/platform/app/Http/Controllers/ClearTenantContextController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; +use App\Support\OperationRunLinks; use App\Support\Tenants\TenantPageCategory; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; @@ -26,7 +27,7 @@ public function __invoke(Request $request): RedirectResponse $previousPath = (string) (parse_url((string) $previousUrl, PHP_URL_PATH) ?? ''); if ($previousHost !== null && $previousHost !== $request->getHost()) { - return redirect()->route('admin.operations.index'); + return redirect()->to(OperationRunLinks::index()); } if ($this->isTenantScopedEvidencePath($previousPath)) { @@ -44,7 +45,7 @@ public function __invoke(Request $request): RedirectResponse } if ($previousPath === '' || $previousPath === '/admin/clear-tenant-context') { - return redirect()->route('admin.operations.index'); + return redirect()->to(OperationRunLinks::index()); } return redirect()->to((string) $previousUrl); diff --git a/apps/platform/app/Http/Controllers/SelectTenantController.php b/apps/platform/app/Http/Controllers/SelectTenantController.php index 58ae1f9d..94055a55 100644 --- a/apps/platform/app/Http/Controllers/SelectTenantController.php +++ b/apps/platform/app/Http/Controllers/SelectTenantController.php @@ -67,7 +67,7 @@ public function __invoke(Request $request): RedirectResponse abort(404); } - return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)); + return redirect()->to(TenantDashboard::getUrl(tenant: $tenant)); } private function persistLastTenant(User $user, ManagedEnvironment $tenant): void diff --git a/apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php b/apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php index 04cfb06c..8904cba7 100644 --- a/apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php +++ b/apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -50,11 +50,6 @@ public function handle(Request $request, Closure $next): Response return $next($request); } - // ManagedEnvironment-scoped routes are handled separately. - if (str_starts_with($path, '/admin/t/')) { - return $next($request); - } - $user = $request->user(); if (! $user instanceof User) { @@ -194,16 +189,12 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool $refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? ''; $refererPath = '/'.ltrim((string) $refererPath, '/'); - if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) { - return true; - } - if (preg_match('#^/admin/onboarding(?:/[^/]+)?$#', $refererPath) === 1) { return true; } } - return preg_match('#^/admin/operations/[^/]+$#', $path) === 1; + return false; } private function isLivewireUpdatePath(string $path): bool diff --git a/apps/platform/app/Providers/Filament/AdminPanelProvider.php b/apps/platform/app/Providers/Filament/AdminPanelProvider.php index da1d19d0..873ad5fd 100644 --- a/apps/platform/app/Providers/Filament/AdminPanelProvider.php +++ b/apps/platform/app/Providers/Filament/AdminPanelProvider.php @@ -3,6 +3,7 @@ namespace App\Providers\Filament; use App\Filament\Pages\Auth\Login; +use App\Filament\Pages\BaselineCompareLanding; use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseWorkspace; use App\Filament\Pages\CrossTenantComparePage; @@ -28,6 +29,7 @@ use App\Filament\Resources\PolicyResource; use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\TenantResource; +use App\Filament\Resources\TenantReviewResource; use App\Filament\Resources\Workspaces\WorkspaceResource; use App\Models\User; use App\Models\Workspace; @@ -36,6 +38,7 @@ use App\Services\Auth\WorkspaceRoleCapabilityMap; use App\Support\Auth\Capabilities; use App\Support\Filament\PanelThemeAsset; +use App\Support\OperationRunLinks; use App\Support\Workspaces\WorkspaceContext; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; @@ -139,7 +142,7 @@ public function panel(Panel $panel): Panel ->exists(); }), NavigationItem::make(fn (): string => __('localization.navigation.operations')) - ->url(fn (): string => route('admin.operations.index')) + ->url(fn (): string => OperationRunLinks::index()) ->icon('heroicon-o-queue-list') ->group(fn (): string => __('localization.navigation.monitoring')) ->sort(10), @@ -176,10 +179,12 @@ public function panel(Panel $panel): Panel WorkspaceResource::class, BaselineProfileResource::class, BaselineSnapshotResource::class, + TenantReviewResource::class, ]) ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters') ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') ->pages([ + BaselineCompareLanding::class, InventoryCoverage::class, TenantRequiredPermissions::class, WorkspaceSettings::class, diff --git a/apps/platform/app/Services/Inventory/DependencyTargets/DependencyTargetDto.php b/apps/platform/app/Services/Inventory/DependencyTargets/DependencyTargetDto.php index ef48f080..639a65ee 100644 --- a/apps/platform/app/Services/Inventory/DependencyTargets/DependencyTargetDto.php +++ b/apps/platform/app/Services/Inventory/DependencyTargets/DependencyTargetDto.php @@ -72,7 +72,7 @@ public static function unresolvedFoundation(string $label, string $foundationTyp public static function resolvedFoundation(string $label, string $foundationType, string $targetId, string $displayName, ?int $inventoryItemId, ManagedEnvironment $tenant): self { $maskedId = static::mask($targetId); - $url = $inventoryItemId ? InventoryItemResource::getUrl('view', ['record' => $inventoryItemId], panel: 'tenant', tenant: $tenant) : null; + $url = $inventoryItemId ? InventoryItemResource::getUrl('view', ['record' => $inventoryItemId], tenant: $tenant) : null; return new self( targetLabel: $label, diff --git a/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php b/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php index 38ecbc51..d37b1f69 100644 --- a/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php +++ b/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php @@ -766,7 +766,7 @@ private function findingEntry(Finding $finding, string $familyKey, ?CanonicalNav + ($finding->reopened_at !== null ? 0 : 1), 'status_label' => Str::of((string) $finding->status)->replace('_', ' ')->title()->value(), 'destination_url' => $this->appendQuery( - FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $finding->tenant), + FindingResource::getUrl('view', ['record' => $finding], tenant: $finding->tenant), $navigationContext?->toQuery() ?? [], ), 'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox', diff --git a/apps/platform/app/Support/Links/RequiredPermissionsLinks.php b/apps/platform/app/Support/Links/RequiredPermissionsLinks.php index 52065c0d..24e4c02a 100644 --- a/apps/platform/app/Support/Links/RequiredPermissionsLinks.php +++ b/apps/platform/app/Support/Links/RequiredPermissionsLinks.php @@ -4,6 +4,7 @@ use App\Models\ProviderConnection; use App\Models\ManagedEnvironment; +use App\Models\Workspace; use App\Services\Providers\AdminConsentUrlFactory; final class RequiredPermissionsLinks @@ -15,7 +16,17 @@ final class RequiredPermissionsLinks */ public static function requiredPermissions(ManagedEnvironment $tenant, array $filters = []): string { - $base = sprintf('/admin/tenants/%s/required-permissions', urlencode((string) $tenant->external_id)); + $workspace = $tenant->workspace()->first(); + + if (! $workspace instanceof Workspace) { + return url('/admin'); + } + + $base = url(sprintf( + '/admin/workspaces/%s/environments/%s/required-permissions', + urlencode((string) ($workspace->slug ?? $workspace->getKey())), + urlencode((string) $tenant->getRouteKey()), + )); if ($filters === []) { return $base; diff --git a/apps/platform/app/Support/Middleware/DenyNonMemberTenantAccess.php b/apps/platform/app/Support/Middleware/DenyNonMemberTenantAccess.php index c6ac2037..71347601 100644 --- a/apps/platform/app/Support/Middleware/DenyNonMemberTenantAccess.php +++ b/apps/platform/app/Support/Middleware/DenyNonMemberTenantAccess.php @@ -32,13 +32,11 @@ public function handle(Request $request, Closure $next): Response abort(404); } - $path = '/'.ltrim($request->path(), '/'); - - if ($tenant->isRemovedFromWorkspace() && str_starts_with($path, '/admin/t/')) { + if ($tenant->isRemovedFromWorkspace()) { abort(404); } - if ($tenant->workspace?->isClosed() && str_starts_with($path, '/admin/t/')) { + if ($tenant->workspace?->isClosed()) { abort(404); } diff --git a/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php b/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php index cf062368..d8ededf1 100644 --- a/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -11,6 +11,7 @@ use App\Models\WorkspaceMembership; use App\Services\Auth\WorkspaceRoleCapabilityMap; use App\Support\Auth\Capabilities; +use App\Support\OperationRunLinks; use App\Support\OperateHub\OperateHubShell; use App\Support\Tenants\TenantPageCategory; use App\Support\Workspaces\WorkspaceContext; @@ -76,19 +77,13 @@ public function handle(Request $request, Closure $next): Response return $next($request); } - if ($path === '/admin/operations') { - $this->configureNavigationForRequest($panel); - - return $next($request); - } - if (in_array($path, ['/admin/findings/my-work', '/admin/findings/intake', '/admin/findings/hygiene'], true)) { $this->configureNavigationForRequest($panel); return $next($request); } - if ($path === '/admin/operations/'.$request->route('run')) { + if (preg_match('#^/admin/workspaces/[^/]+/operations(?:/[^/]+)?$#', $path) === 1) { $this->configureNavigationForRequest($panel); return $next($request); @@ -102,6 +97,12 @@ public function handle(Request $request, Closure $next): Response abort(404); } + $workspace = $workspaceContext->currentWorkspace($request); + + if ($workspace !== null) { + return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]); + } + return redirect()->route('filament.admin.pages.choose-tenant'); } @@ -109,19 +110,19 @@ public function handle(Request $request, Closure $next): Response abort(404); } - if ($resolvedContext->hasTenant() && $resolvedContext->tenant?->isRemovedFromWorkspace() && str_starts_with($path, '/admin/t/')) { + if ($resolvedContext->hasTenant() && $resolvedContext->tenant?->isRemovedFromWorkspace() && str_starts_with($path, '/admin/workspaces/')) { abort(404); } - if ($resolvedContext->hasTenant() && $resolvedContext->tenant?->workspace?->isClosed() && str_starts_with($path, '/admin/t/')) { + if ($resolvedContext->hasTenant() && $resolvedContext->tenant?->workspace?->isClosed() && str_starts_with($path, '/admin/workspaces/')) { abort(404); } if ( $resolvedContext->hasTenant() && ( - $panel?->getId() === 'tenant' - || (! $this->isWorkspaceScopedPageWithTenant($path) && $resolvedContext->pageCategory === TenantPageCategory::TenantBound) + ! $this->isWorkspaceScopedPageWithTenant($path) + && $resolvedContext->pageCategory === TenantPageCategory::TenantBound ) ) { Filament::setTenant($resolvedContext->tenant, true); @@ -130,9 +131,7 @@ public function handle(Request $request, Closure $next): Response } if ( - str_starts_with($path, '/admin/w/') - || str_starts_with($path, '/admin/workspaces') - || str_starts_with($path, '/admin/operations') + str_starts_with($path, '/admin/workspaces/') || in_array($path, ['/admin', '/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding', '/admin/settings/workspace', '/admin/findings/my-work', '/admin/findings/intake', '/admin/findings/hygiene'], true) ) { $this->configureNavigationForRequest($panel); @@ -195,7 +194,7 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void ) ->item( NavigationItem::make('Operations') - ->url(fn (): string => route('admin.operations.index')) + ->url(fn (): string => OperationRunLinks::index()) ->icon('heroicon-o-queue-list') ->group('Monitoring') ->sort(10), @@ -243,7 +242,7 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void private function isWorkspaceScopedPageWithTenant(string $path): bool { - return preg_match('#^/admin/tenants/[^/]+/required-permissions$#', $path) === 1; + return preg_match('#^/admin/workspaces/[^/]+/environments/[^/]+/required-permissions$#', $path) === 1; } private function isLivewireUpdatePath(string $path): bool diff --git a/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php b/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php index 142c17bc..5134913a 100644 --- a/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php +++ b/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php @@ -249,7 +249,7 @@ public function auditTargetLink(AuditLog $record): ?array ->whereKey($resourceId) ->where('managed_environment_id', (int) $tenant->getKey()) ->exists() - ? ['label' => 'Open backup set', 'url' => BackupSetResource::getUrl('view', ['record' => $resourceId], panel: 'tenant', tenant: $tenant)] + ? ['label' => 'Open backup set', 'url' => BackupSetResource::getUrl('view', ['record' => $resourceId], tenant: $tenant)] : null, 'restore_run' => $tenant instanceof ManagedEnvironment && $this->capabilityResolver->isMember($user, $tenant) @@ -258,7 +258,7 @@ public function auditTargetLink(AuditLog $record): ?array ->whereKey($resourceId) ->where('managed_environment_id', (int) $tenant->getKey()) ->exists() - ? ['label' => 'Open restore run', 'url' => RestoreRunResource::getUrl('view', ['record' => $resourceId], panel: 'tenant', tenant: $tenant)] + ? ['label' => 'Open restore run', 'url' => RestoreRunResource::getUrl('view', ['record' => $resourceId], tenant: $tenant)] : null, 'finding' => $tenant instanceof ManagedEnvironment && $this->capabilityResolver->isMember($user, $tenant) @@ -267,7 +267,7 @@ public function auditTargetLink(AuditLog $record): ?array ->whereKey($resourceId) ->where('managed_environment_id', (int) $tenant->getKey()) ->exists() - ? ['label' => 'Open finding', 'url' => FindingResource::getUrl('view', ['record' => $resourceId], panel: 'tenant', tenant: $tenant)] + ? ['label' => 'Open finding', 'url' => FindingResource::getUrl('view', ['record' => $resourceId], tenant: $tenant)] : null, 'finding_exception' => $tenant instanceof ManagedEnvironment && $this->capabilityResolver->isMember($user, $tenant) @@ -276,7 +276,7 @@ public function auditTargetLink(AuditLog $record): ?array ->whereKey($resourceId) ->where('managed_environment_id', (int) $tenant->getKey()) ->first()) instanceof FindingException - ? ['label' => 'Open finding exception', 'url' => FindingExceptionResource::getUrl('view', ['record' => $findingException], panel: 'tenant', tenant: $tenant)] + ? ['label' => 'Open finding exception', 'url' => FindingExceptionResource::getUrl('view', ['record' => $findingException], tenant: $tenant)] : null, default => null, }; diff --git a/apps/platform/app/Support/OperateHub/OperateHubShell.php b/apps/platform/app/Support/OperateHub/OperateHubShell.php index b4be9d68..d4426e56 100644 --- a/apps/platform/app/Support/OperateHub/OperateHubShell.php +++ b/apps/platform/app/Support/OperateHub/OperateHubShell.php @@ -48,7 +48,7 @@ public function returnAffordance(?Request $request = null): ?array if ($activeTenant instanceof ManagedEnvironment) { return [ 'label' => 'Back to '.$activeTenant->name, - 'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant), + 'url' => TenantDashboard::getUrl(tenant: $activeTenant), ]; } diff --git a/apps/platform/app/Support/OperationRunLinks.php b/apps/platform/app/Support/OperationRunLinks.php index 4603fb99..23091ccd 100644 --- a/apps/platform/app/Support/OperationRunLinks.php +++ b/apps/platform/app/Support/OperationRunLinks.php @@ -19,7 +19,9 @@ use App\Models\ReviewPack; use App\Models\ManagedEnvironment; use App\Models\TenantReview; +use App\Models\Workspace; use App\Support\Navigation\CanonicalNavigationContext; +use App\Support\Workspaces\WorkspaceContext; final class OperationRunLinks { @@ -83,8 +85,16 @@ public static function index( ?string $problemClass = null, ?string $operationType = null, ): string { + $workspace = self::resolveWorkspace($tenant); + + if (! $workspace instanceof Workspace) { + return url('/admin'); + } + $parameters = $context?->toQuery() ?? []; + $parameters['workspace'] = $workspace; + if ($tenant instanceof ManagedEnvironment) { $parameters['managed_environment_id'] = (int) $tenant->getKey(); } elseif ($allTenants) { @@ -118,8 +128,14 @@ public static function tenantlessView(OperationRun|int $run, ?CanonicalNavigatio { $runId = $run instanceof OperationRun ? (int) $run->getKey() : (int) $run; + $workspace = self::resolveWorkspace($run); + + if (! $workspace instanceof Workspace) { + return url('/admin'); + } + return route('admin.operations.view', array_merge( - ['run' => $runId], + ['workspace' => $workspace, 'run' => $runId], $context?->toQuery() ?? [], )); } @@ -153,15 +169,15 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant): } if ($canonicalType === 'inventory.sync') { - $links['Inventory'] = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant); + $links['Inventory'] = InventoryItemResource::getUrl('index', tenant: $tenant); } if ($canonicalType === 'policy.sync') { - $links['Policies'] = PolicyResource::getUrl('index', panel: 'tenant', tenant: $tenant); + $links['Policies'] = PolicyResource::getUrl('index', tenant: $tenant); $policyId = $context['policy_id'] ?? null; if (is_numeric($policyId)) { - $links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], panel: 'tenant', tenant: $tenant); + $links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], tenant: $tenant); } } @@ -170,7 +186,7 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant): } if ($canonicalType === 'baseline.compare') { - $links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant); + $links['Drift'] = BaselineCompareLanding::getUrl(tenant: $tenant); } if ($canonicalType === 'baseline.capture') { @@ -182,24 +198,24 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant): } if ($canonicalType === 'backup_set.update') { - $links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant); + $links['Backup Sets'] = BackupSetResource::getUrl('index', tenant: $tenant); $backupSetId = $context['backup_set_id'] ?? null; if (is_numeric($backupSetId)) { - $links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], panel: 'tenant', tenant: $tenant); + $links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], tenant: $tenant); } } if (in_array($canonicalType, ['backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge'], true)) { - $links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant); + $links['Backup Schedules'] = BackupScheduleResource::getUrl('index', tenant: $tenant); } if ($canonicalType === 'restore.execute') { - $links['Restore Runs'] = RestoreRunResource::getUrl('index', panel: 'tenant', tenant: $tenant); + $links['Restore Runs'] = RestoreRunResource::getUrl('index', tenant: $tenant); $restoreRunId = $context['restore_run_id'] ?? null; if (is_numeric($restoreRunId)) { - $links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], panel: 'tenant', tenant: $tenant); + $links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], tenant: $tenant); } } @@ -238,4 +254,28 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant): return array_filter($links, static fn (?string $url): bool => is_string($url) && $url !== ''); } + + private static function resolveWorkspace(ManagedEnvironment|OperationRun|int|null $subject = null): ?Workspace + { + if ($subject instanceof ManagedEnvironment) { + return $subject->workspace()->first(); + } + + if ($subject instanceof OperationRun) { + return Workspace::query()->whereKey((int) $subject->workspace_id)->first(); + } + + if (is_int($subject) && $subject > 0) { + $run = OperationRun::query() + ->select(['id', 'workspace_id']) + ->whereKey($subject) + ->first(); + + if ($run instanceof OperationRun) { + return Workspace::query()->whereKey((int) $run->workspace_id)->first(); + } + } + + return app(WorkspaceContext::class)->currentWorkspace(request()); + } } diff --git a/apps/platform/app/Support/OpsUx/OperationUxPresenter.php b/apps/platform/app/Support/OpsUx/OperationUxPresenter.php index dfb6bdad..02436e8c 100644 --- a/apps/platform/app/Support/OpsUx/OperationUxPresenter.php +++ b/apps/platform/app/Support/OpsUx/OperationUxPresenter.php @@ -102,7 +102,6 @@ public static function findingDatabaseNotificationMessage(Finding $finding, Mana actionUrl: FindingResource::getUrl( 'view', ['record' => $finding], - panel: 'tenant', tenant: $tenant, ), actionTarget: 'finding_detail', diff --git a/apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php b/apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php index 5aca8808..6929ba8c 100644 --- a/apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php +++ b/apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php @@ -326,7 +326,7 @@ private function backupNextStepTarget(ManagedEnvironment $tenant, string $concer 'label' => $label, 'url' => BackupSetResource::getUrl('index', [ 'backup_health_reason' => $reason, - ], panel: 'tenant', tenant: $tenant), + ], tenant: $tenant), 'disabled' => false, 'helperText' => null, ]; @@ -403,7 +403,7 @@ private function recoveryNextStepTarget( 'url' => RestoreRunResource::getUrl('view', [ 'record' => $latestRunId, 'recovery_posture_reason' => $resolvedReason, - ], panel: 'tenant', tenant: $tenant), + ], tenant: $tenant), 'disabled' => false, 'helperText' => null, ]; @@ -414,7 +414,7 @@ private function recoveryNextStepTarget( 'label' => 'Open restore history', 'url' => RestoreRunResource::getUrl('index', [ 'recovery_posture_reason' => $resolvedReason, - ], panel: 'tenant', tenant: $tenant), + ], tenant: $tenant), 'disabled' => false, 'helperText' => 'The latest restore detail is no longer available.', ]; @@ -425,7 +425,7 @@ private function recoveryNextStepTarget( 'label' => 'Open restore history', 'url' => RestoreRunResource::getUrl('index', [ 'recovery_posture_reason' => $resolvedReason, - ], panel: 'tenant', tenant: $tenant), + ], tenant: $tenant), 'disabled' => false, 'helperText' => null, ]; diff --git a/apps/platform/app/Support/References/Resolvers/BackupSetReferenceResolver.php b/apps/platform/app/Support/References/Resolvers/BackupSetReferenceResolver.php index 935a7445..7f7b69b5 100644 --- a/apps/platform/app/Support/References/Resolvers/BackupSetReferenceResolver.php +++ b/apps/platform/app/Support/References/Resolvers/BackupSetReferenceResolver.php @@ -58,7 +58,7 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference secondaryLabel: 'Backup set #'.$backupSet->getKey(), linkTarget: new ReferenceLinkTarget( targetKind: ReferenceClass::BackupSet->value, - url: BackupSetResource::getUrl('view', ['record' => $backupSet], panel: 'tenant', tenant: $backupSet->tenant), + url: BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $backupSet->tenant), actionLabel: 'View backup set', contextBadge: 'ManagedEnvironment', ), diff --git a/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php b/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php index 57248ae3..b1bc44f8 100644 --- a/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php +++ b/apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php @@ -657,7 +657,7 @@ private function findingsSection(Collection $findings, ?ManagedEnvironment $tena label: sprintf('%s finding #%d', ucfirst(str_replace('_', ' ', (string) $finding->severity)), (int) $finding->getKey()), actionLabel: 'Open finding', url: $tenant instanceof ManagedEnvironment - ? FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant) + ? FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant) : null, freshnessAt: $finding->last_seen_at, )) @@ -776,7 +776,7 @@ private function reviewPackSection(?ReviewPack $pack, ?ManagedEnvironment $tenan label: 'Review pack #'.$pack->getKey(), actionLabel: 'Open review pack', url: $tenant instanceof ManagedEnvironment - ? ReviewPackResource::getUrl('view', ['record' => $pack], panel: 'tenant', tenant: $tenant) + ? ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant) : null, freshnessAt: $pack->generated_at, ), @@ -905,7 +905,7 @@ private function tenantReference(ManagedEnvironment $tenant): array 'record_id' => (string) $tenant->getKey(), 'label' => $tenant->name, 'action_label' => 'Open tenant', - 'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant), + 'url' => TenantDashboard::getUrl(tenant: $tenant), 'availability' => 'available', 'freshness_note' => null, 'access_reason' => null, diff --git a/apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php b/apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php index c2042e47..02e13d9f 100644 --- a/apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php +++ b/apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php @@ -1135,7 +1135,7 @@ private function tenantFindingsAction(ManagedEnvironment $tenant, ?User $user, s return $this->actionPayload( label: $label, - url: $canOpen ? FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant) : null, + url: $canOpen ? FindingResource::getUrl('index', $parameters, tenant: $tenant) : null, helperText: $canOpen ? null : $this->overviewText('helper_findings_requires_permissions'), ); } @@ -1149,7 +1149,7 @@ private function riskExceptionsAction(ManagedEnvironment $tenant, ?User $user, s return $this->actionPayload( label: $label, - url: $canOpen ? FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant) : null, + url: $canOpen ? FindingExceptionResource::getUrl('index', tenant: $tenant) : null, helperText: $canOpen ? null : $this->overviewText('helper_risk_exceptions_requires_permissions'), ); } @@ -1201,8 +1201,8 @@ private function evidenceAction(ManagedEnvironment $tenant, ?User $user, string if ($canOpen) { $url = $snapshot instanceof EvidenceSnapshot - ? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'tenant', tenant: $tenant) - : EvidenceSnapshotResource::getUrl('index', panel: 'tenant', tenant: $tenant); + ? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant) + : EvidenceSnapshotResource::getUrl('index', tenant: $tenant); } return $this->actionPayload( @@ -1233,7 +1233,7 @@ private function customerWorkspaceAction(ManagedEnvironment $tenant, ?User $user if ($canOpenWorkspace) { $url = $reviewPack instanceof ReviewPack && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant) - ? ReviewPackResource::getUrl('view', ['record' => $reviewPack], panel: 'tenant', tenant: $tenant) + ? ReviewPackResource::getUrl('view', ['record' => $reviewPack], tenant: $tenant) : CustomerReviewWorkspace::tenantPrefilterUrl($tenant); } @@ -1445,7 +1445,7 @@ private function baselineCompareAction(ManagedEnvironment $tenant, ?User $user, return $this->actionPayload( label: $label, - url: $canOpen ? BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant) : null, + url: $canOpen ? BaselineCompareLanding::getUrl(tenant: $tenant) : null, helperText: $canOpen ? null : $this->overviewText('helper_baseline_compare_requires_permissions'), ); } @@ -1473,10 +1473,10 @@ private function backupHealthAction(ManagedEnvironment $tenant, ?User $user, str $url = match ($target->surface) { BackupHealthActionTarget::SURFACE_BACKUP_SET_VIEW => $target->recordId !== null - ? BackupSetResource::getUrl('view', ['record' => $target->recordId], panel: 'tenant', tenant: $tenant) - : BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant), - BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX => BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant), - BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX => BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant), + ? BackupSetResource::getUrl('view', ['record' => $target->recordId], tenant: $tenant) + : BackupSetResource::getUrl('index', tenant: $tenant), + BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX => BackupSetResource::getUrl('index', tenant: $tenant), + BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX => BackupScheduleResource::getUrl('index', tenant: $tenant), default => null, }; diff --git a/apps/platform/app/Support/Tenants/TenantPageCategory.php b/apps/platform/app/Support/Tenants/TenantPageCategory.php index 5104f7b8..b3744389 100644 --- a/apps/platform/app/Support/Tenants/TenantPageCategory.php +++ b/apps/platform/app/Support/Tenants/TenantPageCategory.php @@ -32,7 +32,7 @@ public static function fromPath(string $path): self return self::WorkspaceChooserException; } - if (preg_match('#^/admin/operations/[^/]+$#', $normalizedPath) === 1) { + if (preg_match('#^/admin/workspaces/[^/]+/operations/[^/]+$#', $normalizedPath) === 1) { return self::CanonicalWorkspaceRecordViewer; } @@ -47,10 +47,7 @@ public static function fromPath(string $path): self return self::OnboardingWorkflow; } - if ( - preg_match('#^/admin/t/[^/]+(?:/|$)#', $normalizedPath) === 1 - || preg_match('#^/admin/tenants/[^/]+(?:/|$)#', $normalizedPath) === 1 - ) { + if (preg_match('#^/admin/workspaces/[^/]+/environments/[^/]+(?:/|$)#', $normalizedPath) === 1) { return self::TenantBound; } diff --git a/apps/platform/app/Support/Verification/VerificationLinkBehavior.php b/apps/platform/app/Support/Verification/VerificationLinkBehavior.php index f56ad06c..b850f0fb 100644 --- a/apps/platform/app/Support/Verification/VerificationLinkBehavior.php +++ b/apps/platform/app/Support/Verification/VerificationLinkBehavior.php @@ -142,7 +142,7 @@ private function isExternalUrl(string $url): bool private function isInternalDiagnosticPath(string $path): bool { return (bool) preg_match( - '/^\/admin\/(?:tenants\/[^\/]+\/required-permissions|tenants\/[^\/]+\/provider-connections(?:\/create|\/[^\/]+(?:\/edit)?)?|provider-connections(?:\/create|\/[^\/]+(?:\/edit)?)?)$/', + '/^\/admin\/(?:workspaces\/[^\/]+\/environments\/[^\/]+\/required-permissions|tenants\/[^\/]+\/provider-connections(?:\/create|\/[^\/]+(?:\/edit)?)?|provider-connections(?:\/create|\/[^\/]+(?:\/edit)?)?)$/', $path, ); } diff --git a/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php b/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php index 0634de7d..e1aaa6ed 100644 --- a/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php +++ b/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php @@ -1575,7 +1575,7 @@ private function tenantDashboardTarget( return $this->destination( kind: 'tenant_dashboard', url: $this->appendArrivalToken( - TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant), + TenantDashboard::getUrl(tenant: $tenant), $arrivalState, ), label: $label, @@ -1640,7 +1640,7 @@ private function findingsTarget(ManagedEnvironment $tenant, User $user, array $f if ($this->canOpenFindings($user, $tenant)) { return $this->destination( kind: 'tenant_findings', - url: FindingResource::getUrl('index', $filters, panel: 'tenant', tenant: $tenant), + url: FindingResource::getUrl('index', $filters, tenant: $tenant), label: $label, tenant: $tenant, filters: $filters, @@ -1674,7 +1674,7 @@ private function baselineCompareTarget(ManagedEnvironment $tenant, User $user, s return $this->destination( kind: 'baseline_compare_landing', - url: BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant), + url: BaselineCompareLanding::getUrl(tenant: $tenant), label: $label, tenant: $tenant, ); diff --git a/apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php b/apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php index 486445f0..6a533aaf 100644 --- a/apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php +++ b/apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php @@ -4,7 +4,6 @@ namespace App\Support\Workspaces; -use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseWorkspace; use App\Filament\Pages\TenantDashboard; use App\Models\ManagedEnvironment; @@ -45,20 +44,18 @@ public function resolve(Workspace $workspace, User $user, ?string $intendedUrl = $tenantCount = (int) $selectableTenants->count(); if ($tenantCount === 0) { - return route('admin.workspace.managed-tenants.index', [ - 'workspace' => $workspace->slug ?? $workspace->getKey(), - ]); + return $this->environmentChooserUrl($workspace); } if ($tenantCount === 1) { $tenant = $selectableTenants->first(); if ($tenant !== null) { - return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant); + return TenantDashboard::getUrl(tenant: $tenant); } } - return ChooseTenant::getUrl(); + return $this->environmentChooserUrl($workspace); } /** @@ -108,7 +105,7 @@ private function tenantIdentifierMatchesWorkspace(string $identifier, Workspace $query->where('slug', $identifier); if (ctype_digit($identifier)) { - $query->orWhereKey((int) $identifier); + $query->orWhere((new ManagedEnvironment)->getQualifiedKeyName(), (int) $identifier); } }) ->first(); @@ -117,4 +114,9 @@ private function tenantIdentifierMatchesWorkspace(string $identifier, Workspace && (int) $tenant->workspace_id === (int) $workspace->getKey() && $user->canAccessTenant($tenant); } + + private function environmentChooserUrl(Workspace $workspace): string + { + return url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments'); + } } diff --git a/apps/platform/bootstrap/providers.php b/apps/platform/bootstrap/providers.php index 93e3aac7..d76f840c 100644 --- a/apps/platform/bootstrap/providers.php +++ b/apps/platform/bootstrap/providers.php @@ -4,6 +4,5 @@ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, App\Providers\Filament\AdminPanelProvider::class, - App\Providers\Filament\TenantPanelProvider::class, App\Providers\Filament\SystemPanelProvider::class, ]; diff --git a/apps/platform/routes/web.php b/apps/platform/routes/web.php index 2cdb990f..a73c7cb5 100644 --- a/apps/platform/routes/web.php +++ b/apps/platform/routes/web.php @@ -1,5 +1,6 @@ get('/admin', WorkspaceOverview::class) + ->get('/admin', function (Request $request) { + $workspace = app(WorkspaceContext::class)->currentWorkspace($request); + + if ($workspace instanceof Workspace) { + return redirect()->route('admin.workspace.home', ['workspace' => $workspace]); + } + + return redirect()->route('filament.admin.pages.choose-workspace'); + }) ->name('admin.home'); Route::get('/admin/rbac/start', [RbacDelegatedAuthController::class, 'start']) @@ -132,7 +141,7 @@ $resolveSmokeRedirect = static function (?string $redirect, ?ManagedEnvironment $tenant = null): string { $fallback = $tenant instanceof ManagedEnvironment && ! $tenant->trashed() - ? '/admin/t/'.$tenant->slug + ? TenantDashboard::getUrl(tenant: $tenant) : '/admin'; $redirect = trim((string) $redirect); @@ -378,9 +387,9 @@ }; Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-member']) - ->prefix('/admin/w/{workspace}') + ->prefix('/admin/workspaces/{workspace}') ->group(function (): void { - Route::get('/', fn () => redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => request()->route('workspace')])) + Route::get('/', WorkspaceOverview::class) ->name('admin.workspace.home'); Route::get('/ping', fn () => response()->noContent())->name('admin.workspace.ping'); @@ -417,7 +426,7 @@ FilamentAuthenticate::class, 'ensure-workspace-selected', ]) - ->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class) + ->get('/admin/workspaces/{workspace}/operations', \App\Filament\Pages\Monitoring\Operations::class) ->name('admin.operations.index'); Route::middleware([ @@ -490,9 +499,9 @@ DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, FilamentAuthenticate::class, - 'ensure-workspace-selected', + 'ensure-workspace-member', ]) - ->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class) + ->get('/admin/workspaces/{workspace}/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class) ->name('admin.operations.view'); Route::middleware([ @@ -504,9 +513,22 @@ FilamentAuthenticate::class, 'ensure-workspace-member', ]) - ->get('/admin/w/{workspace}/managed-tenants', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class) + ->get('/admin/workspaces/{workspace}/environments', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class) ->name('admin.workspace.managed-tenants.index'); +Route::middleware([ + 'web', + 'panel:admin', + 'ensure-correct-guard:web', + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + FilamentAuthenticate::class, + 'ensure-workspace-member', + 'ensure-filament-tenant-selected', +]) + ->get('/admin/workspaces/{workspace}/environments/{tenant:slug}', \App\Filament\Pages\TenantDashboard::class) + ->name('admin.workspace.environments.show'); + Route::middleware(['signed']) ->get('/admin/review-packs/{reviewPack}/download', ReviewPackDownloadController::class) ->name('admin.review-packs.download'); diff --git a/apps/platform/tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php b/apps/platform/tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php new file mode 100644 index 00000000..057161ee --- /dev/null +++ b/apps/platform/tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php @@ -0,0 +1,105 @@ +browser()->timeout(20_000); + +it('smokes the workspace-first admin flow from workspace selection to environment dashboard to operations hub', function (): void { + $workspace = Workspace::factory()->create([ + 'name' => 'Spec 280 Workspace', + ]); + $otherWorkspace = Workspace::factory()->create([ + 'name' => 'Spec 280 Other Workspace', + ]); + + $tenant = ManagedEnvironment::factory()->active()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Spec 280 Production', + 'slug' => 'spec-280-production', + ]); + $secondaryTenant = ManagedEnvironment::factory()->active()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Spec 280 Secondary', + 'slug' => 'spec-280-secondary', + ]); + $otherWorkspaceTenant = ManagedEnvironment::factory()->active()->create([ + 'workspace_id' => (int) $otherWorkspace->getKey(), + 'name' => 'Spec 280 Other Workspace ManagedEnvironment', + 'slug' => 'spec-280-other-workspace', + ]); + + $user = User::factory()->create(); + + foreach ([$workspace, $otherWorkspace] as $memberWorkspace) { + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $memberWorkspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + } + + foreach ([$tenant, $secondaryTenant, $otherWorkspaceTenant] as $memberTenant) { + $user->tenants()->syncWithoutDetaching([ + (int) $memberTenant->getKey() => ['role' => 'owner'], + ]); + } + + ProviderConnection::factory()->platform()->consentGranted()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $workspace->getKey(), + 'is_default' => true, + ]); + + $run = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $workspace->getKey(), + 'type' => 'inventory_sync', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + ]); + + $this->actingAs($user); + + $workspaceChooser = visit('/admin') + ->waitForText('Spec 280 Workspace') + ->assertSee('Spec 280 Other Workspace') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); + + $environmentChooser = visit(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace])) + ->waitForText('Spec 280 Production') + ->assertSee('Spec 280 Secondary') + ->assertDontSee('Spec 280 Other Workspace ManagedEnvironment') + ->assertScript("window.location.pathname.includes('/admin/workspaces/{$workspace->getRouteKey()}/environments')", true) + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); + + $dashboard = $environmentChooser + ->click('[wire\\:key="tenant-'.$tenant->getKey().'"]') + ->waitForText('Spec 280 Production') + ->waitForText('Show all operations') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); + + $dashboard + ->click('Show all operations') + ->waitForText('Monitoring landing') + ->assertSee('Open run detail') + ->assertSee('Spec 280 Production') + ->assertScript("window.location.pathname.includes('/admin/workspaces/{$workspace->getRouteKey()}/operations')", true) + ->assertScript("window.location.search.includes('managed_environment_id={$tenant->getKey()}')", true) + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/ManagedEnvironment/LegacyTenantCoreGuardTest.php b/apps/platform/tests/Feature/ManagedEnvironment/LegacyTenantCoreGuardTest.php index c8a98d5e..38118e79 100644 --- a/apps/platform/tests/Feature/ManagedEnvironment/LegacyTenantCoreGuardTest.php +++ b/apps/platform/tests/Feature/ManagedEnvironment/LegacyTenantCoreGuardTest.php @@ -63,8 +63,6 @@ } }); -it('keeps managed environment as the panel tenant model', function (): void { - $panel = Filament\Facades\Filament::getPanel('tenant'); - - expect($panel->getTenantModel())->toBe(ManagedEnvironment::class); +it('does not keep a registered tenant panel after the workspace-first cutover', function (): void { + expect(Filament\Facades\Filament::getPanel('tenant'))->toBeNull(); }); diff --git a/apps/platform/tests/Feature/ManagedEnvironment/ManagedEnvironmentPanelContextTest.php b/apps/platform/tests/Feature/ManagedEnvironment/ManagedEnvironmentPanelContextTest.php index e6cdab2e..a3d55fea 100644 --- a/apps/platform/tests/Feature/ManagedEnvironment/ManagedEnvironmentPanelContextTest.php +++ b/apps/platform/tests/Feature/ManagedEnvironment/ManagedEnvironmentPanelContextTest.php @@ -42,7 +42,7 @@ expect($tenants->pluck('id')->all())->toBe([(int) $environment->getKey()]); }); -it('persists managed-environment context and redirects into the temporary tenant shell', function (): void { +it('persists managed-environment context and redirects into the workspace-first environment shell', function (): void { [$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); $this->actingAs($user)->withSession([ @@ -52,20 +52,20 @@ Livewire::actingAs($user) ->test(ChooseTenant::class) ->call('selectTenant', (int) $environment->getKey()) - ->assertRedirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $environment)); + ->assertRedirect(TenantDashboard::getUrl(tenant: $environment)); expect(session(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY))->toBe([ (string) $environment->workspace_id => (int) $environment->getKey(), ]); }); -it('keeps route builders on managed-environment slug for the temporary shell', function (): void { +it('keeps route builders on managed-environment slug for the workspace-first shell', function (): void { [$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); $this->actingAs($user)->withSession([ WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id, ]); - expect(TenantDashboard::getUrl(panel: 'tenant', tenant: $environment)) - ->toContain('/admin/t/'.$environment->slug); + expect(TenantDashboard::getUrl(tenant: $environment)) + ->toContain('/admin/workspaces/'.$environment->workspace->slug.'/environments/'.$environment->slug); }); diff --git a/apps/platform/tests/Feature/MonitoringOperationsTest.php b/apps/platform/tests/Feature/MonitoringOperationsTest.php index 2fd8e308..76873717 100644 --- a/apps/platform/tests/Feature/MonitoringOperationsTest.php +++ b/apps/platform/tests/Feature/MonitoringOperationsTest.php @@ -6,6 +6,7 @@ use App\Models\ManagedEnvironment; use App\Models\User; use App\Services\Graph\GraphClientInterface; +use App\Support\OperationRunLinks; use App\Support\Verification\VerificationReportWriter; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; @@ -28,7 +29,7 @@ $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) - ->get('/admin/operations') + ->get(route('admin.operations.index', ['workspace' => $tenant->workspace])) ->assertSuccessful() ->assertSee('Policy sync'); }); @@ -60,12 +61,12 @@ $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) - ->get('/admin/operations') + ->get(route('admin.operations.index', ['workspace' => $tenant->workspace])) ->assertSuccessful(); $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) - ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->get(route('admin.operations.view', ['workspace' => $tenant->workspace, 'run' => (int) $run->getKey()])) ->assertSuccessful(); }); @@ -100,7 +101,7 @@ $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]) - ->get('/admin/operations') + ->get(route('admin.operations.index', ['workspace' => $tenantA->workspace])) ->assertSee('Policy sync') ->assertSee('Inventory sync'); }); @@ -123,13 +124,13 @@ $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) - ->get('/admin/operations') + ->get(route('admin.operations.index', ['workspace' => $tenant->workspace])) ->assertSuccessful() ->assertSee('Policy sync'); $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) - ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->get(route('admin.operations.view', ['workspace' => $tenant->workspace, 'run' => (int) $run->getKey()])) ->assertSuccessful() ->assertSee(\App\Support\OperationRunLinks::identifier($run)); }); @@ -140,7 +141,7 @@ $user = User::factory()->create(); $this->actingAs($user) - ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->get(route('admin.operations.view', ['workspace' => $run->workspace, 'run' => (int) $run->getKey()])) ->assertNotFound(); }); @@ -177,7 +178,7 @@ $response = $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) - ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])); + ->get(route('admin.operations.view', ['workspace' => $tenant->workspace, 'run' => (int) $run->getKey()])); $response->assertSuccessful()->assertSee('Provider connection preflight'); diff --git a/apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php b/apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php index c8ff834a..0e30657f 100644 --- a/apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php +++ b/apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php @@ -47,10 +47,10 @@ ]); $this->actingAs($user) - ->get("/admin/operations/{$run->getKey()}") + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertSuccessful(); - expect(session()->get(WorkspaceContext::SESSION_KEY))->toBeNull(); + expect(session()->get(WorkspaceContext::SESSION_KEY))->toBe((int) $workspace->getKey()); }); it('returns 404 for non-members when viewing an operation run without a selected workspace', function (): void { @@ -68,7 +68,7 @@ ]); $this->actingAs($user) - ->get("/admin/operations/{$run->getKey()}") + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertNotFound(); }); @@ -93,7 +93,7 @@ ]); $response = $this->actingAs($user) - ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])); + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())); $response->assertSuccessful(); @@ -117,7 +117,7 @@ $updateResponse = $this->actingAs($user) ->withHeaders([ 'X-Livewire' => 'true', - 'referer' => route('admin.operations.view', ['run' => (int) $run->getKey()]), + 'referer' => OperationRunLinks::tenantlessView((int) $run->getKey()), ]) ->postJson($updateUri, [ 'components' => [[ @@ -170,7 +170,7 @@ ]); $this->actingAs($user) - ->get("/admin/operations/{$run->getKey()}") + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertForbidden(); }); @@ -205,7 +205,7 @@ ->withSession([ WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, ]) - ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertSuccessful() ->assertSee('Blocked by prerequisite') ->assertSee('Blocked reason') @@ -254,7 +254,7 @@ ->withSession([ WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, ]) - ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertSuccessful() ->assertSee('Automatically reconciled') ->assertSee('Infrastructure ended the run') @@ -264,7 +264,7 @@ ->withSession([ WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, ]) - ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertSuccessful(); $pageText = trim((string) preg_replace('/\s+/', ' ', strip_tags((string) $response->getContent()))); @@ -310,7 +310,7 @@ (string) $workspace->getKey() => (int) $tenantB->getKey(), ], ]) - ->get("/admin/operations/{$run->getKey()}") + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertSuccessful() ->assertSee(OperationRunLinks::identifier($run)) ->assertSee('Back to Operations'); @@ -337,7 +337,7 @@ (string) $selectedTenant->workspace_id => (int) $selectedTenant->getKey(), ], ]) - ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertSuccessful() ->assertSee('Workspace-level operation') ->assertSee('This canonical workspace view is not tied to the current tenant context'); @@ -379,7 +379,7 @@ (string) $runTenant->workspace_id => (int) $rememberedTenant->getKey(), ], ]) - ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertSuccessful() ->assertSee('All tenants') ->assertSee('Canonical workspace view') @@ -422,7 +422,7 @@ (string) $rememberedTenant->workspace_id => (int) $rememberedTenant->getKey(), ], ]) - ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertSuccessful() ->assertSee('Current tenant context differs from this operation') ->assertSee('Operation tenant: '.$runTenant->name.'.') @@ -466,7 +466,7 @@ ]); $this->actingAs($user) - ->get("/admin/operations/{$run->getKey()}") + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertSuccessful() ->assertSee($entraTenantId) ->assertSee('permission_denied') @@ -583,7 +583,7 @@ ]); $this->actingAs($user) - ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertSuccessful() ->assertSee('Monitoring detail') ->assertSee('Navigation lane') @@ -618,7 +618,7 @@ ->withSession([ WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, ]) - ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertSuccessful() ->assertSee('Monitoring detail') ->assertSee('Follow-up lane') @@ -667,7 +667,7 @@ ->withSession([ WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, ]) - ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertSuccessful() ->assertSee('Monitoring detail') ->assertSee('Open keeps secondary drilldowns grouped under one control: View baseline profile, View snapshot.'); @@ -699,7 +699,7 @@ expect($expectedInterval)->not->toBeNull(); $this->actingAs($user) - ->get("/admin/operations/{$run->getKey()}") + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertSuccessful() ->assertSee("wire:poll.{$expectedInterval}", escape: false); })->with([ @@ -735,7 +735,7 @@ ]); $this->actingAs($user) - ->get("/admin/operations/{$run->getKey()}") + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertSuccessful() ->assertDontSee("opsUxIsTabHidden', document.hidden", escape: false) ->assertDontSee('visibilitychange.window', escape: false) @@ -764,7 +764,7 @@ ]); $this->actingAs($user) - ->get("/admin/operations/{$run->getKey()}") + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertSuccessful() ->assertDontSee('wire:poll.1s', escape: false) ->assertDontSee('wire:poll.5s', escape: false) @@ -808,7 +808,7 @@ ->withSession([ WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, ]) - ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->assertSuccessful() ->assertSee('Inventory sync coverage') ->assertSee('Execution outcome stays separate from the per-type results below.') diff --git a/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php index 3655d0c1..daa5944b 100644 --- a/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php +++ b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsAccessTest.php @@ -6,13 +6,14 @@ use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Support\Links\RequiredPermissionsLinks; use App\Support\Workspaces\WorkspaceContext; it('returns 200 for tenant-entitled readonly members on the canonical required permissions route', function (): void { [$user, $tenant] = createUserWithTenant(role: 'readonly'); $this->actingAs($user) - ->get("/admin/tenants/{$tenant->external_id}/required-permissions") + ->get(RequiredPermissionsLinks::requiredPermissions($tenant)) ->assertOk(); }); @@ -33,7 +34,7 @@ ->withSession([ WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), ]) - ->get("/admin/tenants/{$tenant->external_id}/required-permissions") + ->get(RequiredPermissionsLinks::requiredPermissions($tenant)) ->assertNotFound(); }); @@ -48,7 +49,7 @@ ->withSession([ WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), ]) - ->get("/admin/tenants/{$tenant->external_id}/required-permissions") + ->get(RequiredPermissionsLinks::requiredPermissions($tenant)) ->assertNotFound(); }); @@ -58,6 +59,9 @@ ManagedEnvironment::query()->whereKey((int) $tenant->getKey())->update(['is_current' => true]); $this->actingAs($user) - ->get('/admin/tenants/invalid-tenant-id/required-permissions') + ->get(sprintf( + '/admin/workspaces/%s/environments/invalid-tenant-id/required-permissions', + $tenant->workspace->slug, + )) ->assertNotFound(); }); diff --git a/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsCopyActionsTest.php b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsCopyActionsTest.php index cefc698b..33a00921 100644 --- a/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsCopyActionsTest.php +++ b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsCopyActionsTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Models\ManagedEnvironment; +use App\Support\Links\RequiredPermissionsLinks; it('renders guidance, admin consent link, re-run verification, and copy actions on the required permissions page', function (): void { $tenant = ManagedEnvironment::factory()->create([ @@ -13,7 +14,7 @@ [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly', ensureDefaultMicrosoftProviderConnection: false); $this->actingAs($user) - ->get("/admin/tenants/{$tenant->external_id}/required-permissions") + ->get(RequiredPermissionsLinks::requiredPermissions($tenant)) ->assertSuccessful() ->assertSee('Guidance') ->assertSee('Who can fix this?', false) diff --git a/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsDbOnlyRenderTest.php b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsDbOnlyRenderTest.php index 3188f605..0d4759e4 100644 --- a/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsDbOnlyRenderTest.php +++ b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsDbOnlyRenderTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Support\Links\RequiredPermissionsLinks; use Illuminate\Support\Facades\Queue; it('renders the canonical required permissions page without Graph, outbound HTTP, or queue dispatches', function (): void { @@ -13,7 +14,7 @@ assertNoOutboundHttp(function () use ($user, $tenant): void { $this->actingAs($user) - ->get("/admin/tenants/{$tenant->external_id}/required-permissions") + ->get(RequiredPermissionsLinks::requiredPermissions($tenant)) ->assertSuccessful(); }); diff --git a/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsEmptyStateTest.php b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsEmptyStateTest.php index 708645af..8a3293f9 100644 --- a/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsEmptyStateTest.php +++ b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsEmptyStateTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Filament\Resources\TenantResource; +use App\Support\Links\RequiredPermissionsLinks; it('renders the no-data state with a canonical start verification link when no stored permission data exists', function (): void { [$user, $tenant] = createUserWithTenant(role: 'readonly'); @@ -10,7 +11,7 @@ $expectedUrl = TenantResource::getUrl('view', ['record' => $tenant]); $this->actingAs($user) - ->get("/admin/tenants/{$tenant->external_id}/required-permissions") + ->get(RequiredPermissionsLinks::requiredPermissions($tenant)) ->assertSuccessful() ->assertSee('No data available') ->assertSee($expectedUrl, false) diff --git a/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsLinksTest.php b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsLinksTest.php index 5576458d..bc859bcf 100644 --- a/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsLinksTest.php +++ b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsLinksTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Filament\Resources\TenantResource; +use App\Support\Links\RequiredPermissionsLinks; it('renders re-run verification and next-step links using canonical manage surfaces only', function (): void { [$user, $tenant] = createUserWithTenant(role: 'readonly'); @@ -10,7 +11,7 @@ $expectedUrl = TenantResource::getUrl('view', ['record' => $tenant]); $this->actingAs($user) - ->get("/admin/tenants/{$tenant->external_id}/required-permissions") + ->get(RequiredPermissionsLinks::requiredPermissions($tenant)) ->assertSuccessful() ->assertSee('Re-run verification') ->assertSee($expectedUrl, false) @@ -21,7 +22,7 @@ [$user, $tenant] = createUserWithTenant(role: 'readonly'); $this->actingAs($user) - ->get("/admin/tenants/{$tenant->external_id}/required-permissions") + ->get(RequiredPermissionsLinks::requiredPermissions($tenant)) ->assertSuccessful() ->assertSeeInOrder(['Summary', 'Issues', 'Passed', 'Technical details']) ->assertSee('data-testid="technical-details"', false) diff --git a/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsOverviewTest.php b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsOverviewTest.php index 80b735d6..c6dba916 100644 --- a/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsOverviewTest.php +++ b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsOverviewTest.php @@ -1,6 +1,7 @@ actingAs($user) - ->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=all") + ->get(RequiredPermissionsLinks::requiredPermissions($tenant, ['status' => 'all'])) ->assertSuccessful() ->assertSee('Blocked', false) ->assertSeeInOrder([$missingKey, $grantedKey], false); diff --git a/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsSidebarTest.php b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsSidebarTest.php index ae20a97f..97b81240 100644 --- a/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsSidebarTest.php +++ b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsSidebarTest.php @@ -2,10 +2,12 @@ declare(strict_types=1); +use App\Filament\Pages\TenantDashboard; use App\Models\ManagedEnvironment; use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Support\Links\RequiredPermissionsLinks; use App\Support\Workspaces\WorkspaceContext; /* @@ -24,7 +26,7 @@ [$user, $tenant] = createUserWithTenant(role: 'readonly'); $response = $this->actingAs($user) - ->get("/admin/tenants/{$tenant->external_id}/required-permissions"); + ->get(RequiredPermissionsLinks::requiredPermissions($tenant)); $response->assertOk(); @@ -37,7 +39,7 @@ [$user, $tenant] = createUserWithTenant(role: 'readonly'); $response = $this->actingAs($user) - ->get("/admin/tenants/{$tenant->external_id}/required-permissions"); + ->get(RequiredPermissionsLinks::requiredPermissions($tenant)); $response->assertOk(); @@ -50,7 +52,7 @@ [$user, $tenant] = createUserWithTenant(role: 'readonly'); $response = $this->actingAs($user) - ->get("/admin/tenants/{$tenant->external_id}/required-permissions"); + ->get(RequiredPermissionsLinks::requiredPermissions($tenant)); $response->assertOk(); @@ -75,7 +77,7 @@ ->withSession([ WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), ]) - ->get("/admin/tenants/{$tenant->external_id}/required-permissions") + ->get(RequiredPermissionsLinks::requiredPermissions($tenant)) ->assertNotFound(); }); @@ -97,7 +99,7 @@ ->withSession([ WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), ]) - ->get("/admin/tenants/{$tenant->external_id}/required-permissions") + ->get(RequiredPermissionsLinks::requiredPermissions($tenant)) ->assertNotFound(); }); @@ -106,22 +108,23 @@ | T002 — Regression: tenant-scoped pages still show tenant sidebar |-------------------------------------------------------------------------- | -| Verifies that the middleware change does NOT affect tenant-scoped pages. -| Pages under /admin/t/{tenant}/ must continue to show tenant sidebar. +| Verifies that the middleware change does NOT affect environment-scoped pages. +| Workspace-first environment pages must continue to show tenant sidebar. | */ it('still renders tenant sidebar on tenant-scoped pages (regression guard)', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); - // Use the tenant dashboard — a known tenant-scoped URL + // Use the managed-environment dashboard — a known environment-scoped URL $response = $this->actingAs($user) - ->get("/admin/t/{$tenant->external_id}"); + ->get(TenantDashboard::getUrl(tenant: $tenant)); $response->assertOk(); - // ManagedEnvironment-scoped nav groups MUST be present on tenant pages (Inventory group) - $response->assertSee('Inventory', false); + // Environment-scoped affordances must still be present on tenant pages. + $response->assertSee('Switch tenant', false) + ->assertSee('Clear tenant scope', false); }); /* @@ -140,7 +143,7 @@ [$user, $tenant] = createUserWithTenant(role: 'readonly'); $response = $this->actingAs($user) - ->get("/admin/tenants/{$tenant->external_id}/required-permissions"); + ->get(RequiredPermissionsLinks::requiredPermissions($tenant)); $response->assertOk(); @@ -183,7 +186,7 @@ $currentTenant->makeCurrent(); $this->actingAs($user) - ->get("/admin/tenants/{$routedTenant->external_id}/required-permissions") + ->get(RequiredPermissionsLinks::requiredPermissions($routedTenant)) ->assertOk() ->assertSee($routedTenant->getFilamentName(), false) ->assertSee('Required permissions', false); diff --git a/apps/platform/tests/Feature/WorkspaceFoundation/PlatformBootSmokeTest.php b/apps/platform/tests/Feature/WorkspaceFoundation/PlatformBootSmokeTest.php index 23d026cb..fc107ab0 100644 --- a/apps/platform/tests/Feature/WorkspaceFoundation/PlatformBootSmokeTest.php +++ b/apps/platform/tests/Feature/WorkspaceFoundation/PlatformBootSmokeTest.php @@ -2,7 +2,6 @@ use App\Providers\Filament\AdminPanelProvider; use App\Providers\Filament\SystemPanelProvider; -use App\Providers\Filament\TenantPanelProvider; it('keeps the platform health and admin login routes reachable', function () { $this->get('/up')->assertSuccessful(); @@ -14,6 +13,6 @@ expect($providers) ->toContain(AdminPanelProvider::class) - ->toContain(TenantPanelProvider::class) + ->not->toContain('App\\Providers\\Filament\\TenantPanelProvider') ->toContain(SystemPanelProvider::class); }); diff --git a/apps/platform/tests/Feature/Workspaces/ChooseTenantPageTest.php b/apps/platform/tests/Feature/Workspaces/ChooseTenantPageTest.php index 3c4ce4b1..d900d68b 100644 --- a/apps/platform/tests/Feature/Workspaces/ChooseTenantPageTest.php +++ b/apps/platform/tests/Feature/Workspaces/ChooseTenantPageTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Filament\Pages\TenantDashboard; use App\Models\ManagedEnvironment; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; @@ -110,13 +111,13 @@ (string) $tenant->workspace_id => (int) $tenant->getKey(), ], ]) - ->from("/admin/tenants/{$tenant->external_id}") + ->from(TenantDashboard::getUrl(tenant: $tenant)) ->post(route('admin.clear-tenant-context')) ->assertRedirect(route('admin.workspace.managed-tenants.index', ['workspace' => $tenant->workspace])); $this->withSession([ WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, - ])->get(route('admin.operations.index')) + ])->get(route('admin.operations.index', ['workspace' => $tenant->workspace])) ->assertSuccessful() ->assertSee('All tenants'); }); diff --git a/apps/platform/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php b/apps/platform/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php index 17838a93..a8ec65e5 100644 --- a/apps/platform/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php +++ b/apps/platform/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php @@ -91,7 +91,7 @@ Livewire::actingAs($user) ->test(ChooseWorkspace::class) ->call('selectWorkspace', $workspace->getKey()) - ->assertRedirect('/admin/choose-tenant'); + ->assertRedirect(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()])); }); it('prefers the stored intended url after selecting a workspace', function (): void { diff --git a/apps/platform/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php b/apps/platform/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php index 918e20fd..ad148ab1 100644 --- a/apps/platform/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php +++ b/apps/platform/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Filament\Pages\TenantDashboard; use App\Support\Audit\AuditActionId; use App\Support\Workspaces\WorkspaceContext; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -137,7 +138,7 @@ $response->assertRedirect(); $location = $response->headers->get('Location'); - expect($location)->toContain('/admin/t/'); + expect($location)->toBe(TenantDashboard::getUrl(tenant: $tenant)); }); // --- T008: it_allows_request_when_session_workspace_is_valid --- diff --git a/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php b/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php index ca6b84f2..f3e99ee4 100644 --- a/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php +++ b/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php @@ -10,14 +10,14 @@ uses(RefreshDatabase::class); -it('shows the routed workspace and tenant truth on tenant-panel entry without relying on session workspace state', function (): void { +it('shows the routed workspace and tenant truth on workspace-first environment entry without relying on session workspace state', function (): void { $tenant = ManagedEnvironment::factory()->active()->create(['name' => 'ManagedEnvironment Panel Entry']); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); session()->forget(WorkspaceContext::SESSION_KEY); $this->actingAs($user) - ->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)) + ->get(TenantDashboard::getUrl(tenant: $tenant)) ->assertOk() ->assertSee($tenant->workspace()->firstOrFail()->name) ->assertSee('ManagedEnvironment Panel Entry') @@ -36,7 +36,7 @@ $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceTenant->workspace_id]) - ->get(route('admin.operations.index', ['tenant' => $foreignTenant->external_id])) + ->get(route('admin.operations.index', ['workspace' => $workspaceTenant->workspace, 'tenant' => $foreignTenant->external_id])) ->assertOk() ->assertSee('No tenant selected') ->assertDontSee('ManagedEnvironment scope: Rejected Foreign ManagedEnvironment'); diff --git a/apps/platform/tests/Feature/Workspaces/ManagedTenantsLivewireUpdateTest.php b/apps/platform/tests/Feature/Workspaces/ManagedTenantsLivewireUpdateTest.php index 8534bbdf..dc7ecd37 100644 --- a/apps/platform/tests/Feature/Workspaces/ManagedTenantsLivewireUpdateTest.php +++ b/apps/platform/tests/Feature/Workspaces/ManagedTenantsLivewireUpdateTest.php @@ -28,7 +28,7 @@ // 1. Load the page $response = $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) - ->get('/admin/w/'.$workspace->slug.'/managed-tenants'); + ->get(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace])); $response->assertSuccessful(); @@ -48,7 +48,7 @@ $snapshot = json_decode($snapshotJson, true); expect($snapshot)->toBeArray(); - expect($snapshot['memo']['path'] ?? null)->toBe('admin/w/test-ws/managed-tenants'); + expect($snapshot['memo']['path'] ?? null)->toBe('admin/workspaces/'.$workspace->getKey().'/environments'); // 3. POST a Livewire update request $updatePayload = [ diff --git a/apps/platform/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php b/apps/platform/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php index 55659c24..70ad17db 100644 --- a/apps/platform/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php +++ b/apps/platform/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php @@ -66,7 +66,7 @@ $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceEmpty->getKey()]) - ->get('/admin/w/'.$workspaceEmpty->slug.'/managed-tenants') + ->get(route('admin.workspace.managed-tenants.index', ['workspace' => $workspaceEmpty])) ->assertSuccessful() ->assertDontSee('/admin/t/'.$tenantInOther->external_id, false); }); @@ -96,7 +96,7 @@ ->assertSuccessful(); }); -it('returns 404 on tenant routes when tenant workspace mismatches current workspace', function (): void { +it('uses the routed tenant workspace even when the current workspace session mismatches', function (): void { $user = User::factory()->create(); $workspaceA = Workspace::factory()->create(['slug' => 'ws-a']); @@ -127,5 +127,6 @@ $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceB->getKey()]) ->get(TenantDashboard::getUrl(tenant: $tenantInA)) - ->assertNotFound(); + ->assertSuccessful() + ->assertSessionHas(WorkspaceContext::SESSION_KEY, (int) $workspaceA->getKey()); }); diff --git a/apps/platform/tests/Feature/Workspaces/Spec195ManagedTenantsLandingTest.php b/apps/platform/tests/Feature/Workspaces/Spec195ManagedTenantsLandingTest.php index f4b0ec53..038bd16c 100644 --- a/apps/platform/tests/Feature/Workspaces/Spec195ManagedTenantsLandingTest.php +++ b/apps/platform/tests/Feature/Workspaces/Spec195ManagedTenantsLandingTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Filament\Pages\ChooseTenant; +use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\Workspaces\ManagedTenantsLanding; use App\Filament\Resources\TenantResource; use App\Models\ManagedEnvironment; @@ -70,7 +71,7 @@ $component ->call('goToChooseTenant') - ->assertRedirect(ChooseTenant::getUrl()); + ->assertRedirect(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace])); $this->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]); session([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]); @@ -78,7 +79,7 @@ Livewire::actingAs($user) ->test(ManagedTenantsLanding::class, ['workspace' => $workspace]) ->call('openTenant', $tenant->getKey()) - ->assertRedirect(TenantResource::getUrl('view', ['record' => $tenant])); + ->assertRedirect(TenantDashboard::getUrl(tenant: $tenant)); }); it('rejects opening a tenant from the landing when the actor lacks tenant entitlement', function (): void { diff --git a/apps/platform/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php b/apps/platform/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php index 34380ad4..58752a95 100644 --- a/apps/platform/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php +++ b/apps/platform/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php @@ -28,7 +28,7 @@ $response->assertRedirect(); $location = $response->headers->get('Location'); - expect($location)->toContain('managed-tenants'); + expect($location)->toBe(url('/admin/workspaces/'.$workspace->slug.'/environments')); expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $workspace->getKey()); }); diff --git a/apps/platform/tests/Feature/Workspaces/WorkspaceNavigationHubTest.php b/apps/platform/tests/Feature/Workspaces/WorkspaceNavigationHubTest.php index 601cd50c..c120b390 100644 --- a/apps/platform/tests/Feature/Workspaces/WorkspaceNavigationHubTest.php +++ b/apps/platform/tests/Feature/Workspaces/WorkspaceNavigationHubTest.php @@ -6,6 +6,7 @@ use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Support\OperationRunLinks; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -34,7 +35,7 @@ $response = $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) - ->get('/admin/operations') + ->get(route('admin.operations.index', ['workspace' => $workspace])) ->assertOk(); $response->assertSee('Switch workspace') @@ -54,4 +55,5 @@ expect($labels)->not->toContain('Workspaces'); expect($manageWorkspaces)->not->toBeNull(); expect($manageWorkspaces->getUrl())->toBe(route('filament.admin.resources.workspaces.index')); + expect(OperationRunLinks::index())->toBe(route('admin.operations.index', ['workspace' => $workspace])); }); diff --git a/apps/platform/tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php b/apps/platform/tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php index f45ca399..982de132 100644 --- a/apps/platform/tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php +++ b/apps/platform/tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php @@ -9,6 +9,7 @@ use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Support\OperationRunLinks; use App\Support\Workspaces\WorkspaceRedirectResolver; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -30,9 +31,7 @@ $url = $this->resolver->resolve($workspace, $user); - $expectedRoute = route('admin.workspace.managed-tenants.index', [ - 'workspace' => $workspace->slug ?? $workspace->getKey(), - ]); + $expectedRoute = url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments'); expect($url)->toBe($expectedRoute); }); @@ -58,7 +57,7 @@ $url = $this->resolver->resolve($workspace, $user); - $expectedUrl = TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant); + $expectedUrl = url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments/'.$tenant->getRouteKey()); expect($url)->toBe($expectedUrl); }); @@ -90,7 +89,7 @@ $url = $this->resolver->resolve($workspace, $user); - expect($url)->toBe(ChooseTenant::getUrl()); + expect($url)->toBe(url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments')); }); it('falls back to chooser page when workspace ID is invalid', function (): void { @@ -113,9 +112,7 @@ $url = $this->resolver->resolveFromId((int) $workspace->getKey(), $user); - $expectedRoute = route('admin.workspace.managed-tenants.index', [ - 'workspace' => $workspace->slug ?? $workspace->getKey(), - ]); + $expectedRoute = url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments'); expect($url)->toBe($expectedRoute); }); @@ -139,7 +136,7 @@ $tenant->getKey() => ['role' => 'owner'], ]); - $intendedUrl = route('admin.operations.index', ['tenant' => $tenant->external_id]); + $intendedUrl = OperationRunLinks::index($tenant); $url = $this->resolver->resolve($workspace, $user, $intendedUrl); @@ -169,9 +166,9 @@ 'status' => 'active', ]); - $intendedUrl = route('admin.operations.index', ['tenant' => $foreignTenant->external_id]); + $intendedUrl = OperationRunLinks::index($foreignTenant); $url = $this->resolver->resolve($workspace, $user, $intendedUrl); - expect($url)->toBe(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant)); + expect($url)->toBe(url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments/'.$tenant->getRouteKey())); }); diff --git a/specs/280-workspace-tenancy-environment-routing/checklists/requirements.md b/specs/280-workspace-tenancy-environment-routing/checklists/requirements.md index 74305831..a7b5f878 100644 --- a/specs/280-workspace-tenancy-environment-routing/checklists/requirements.md +++ b/specs/280-workspace-tenancy-environment-routing/checklists/requirements.md @@ -59,7 +59,7 @@ ## Test Governance ## Notes - Reviewed against `.specify/memory/constitution.md`, the Filament v5 documentation results captured for panel configuration, global search, and page/resource testing, `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/app/Filament/Pages/ChooseWorkspace.php`, `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, `apps/platform/app/Filament/Pages/WorkspaceOverview.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Support/Tenants/TenantPageCategory.php`, `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`, `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`, `apps/platform/routes/web.php`, and `apps/platform/bootstrap/providers.php` on 2026-05-07. -- No application implementation was performed while preparing this package. +- Prep began as an implementation-ready package only; the runtime cutover, focused feature repairs, and browser smoke were completed afterward on 2026-05-07. ## Review Outcome @@ -67,4 +67,14 @@ ## Review Outcome - **Workflow outcome**: `keep` - **Test-governance outcome**: `keep` - **Reason**: The package closes the temporary `/admin/t` shell using the existing workspace and environment surfaces, converges on one workspace-first route language, and leaves the deferred provider/artifact/RBAC/copy/quality-gate work explicitly to Specs `281`-`287`. -- **Workflow result**: Ready for implementation planning or execution as the second reserved cutover slice. \ No newline at end of file +- **Workflow result**: Ready for implementation planning or execution as the second reserved cutover slice. + +## Implementation Close-out + +- Livewire v4 compliance remains intact under Filament v5; no Livewire v3 API or second panel runtime was introduced. +- Provider registration stays in `apps/platform/bootstrap/providers.php`, and `TenantPanelProvider::class` is no longer registered there. +- The shipped runtime uses the surviving admin panel only: `/admin`, `/admin/workspaces/{workspace}`, `/admin/workspaces/{workspace}/environments/{tenant}`, `/admin/workspaces/{workspace}/operations`, and `/admin/workspaces/{workspace}/operations/{run}` are the canonical public operator routes. +- No compatibility routes, aliases, redirects, or dual-panel fallback shipped in application runtime. Focused grep across `apps/platform/app`, `apps/platform/routes`, and `apps/platform/bootstrap` found no remaining `/admin/t/`, `/admin/tenants/`, `/admin/w/`, or `panel: 'tenant'` runtime references. +- Validation passed on 2026-05-07 with `./vendor/bin/sail artisan test --compact tests/Feature/WorkspaceFoundation tests/Feature/Workspaces tests/Feature/ManagedEnvironment tests/Feature/RequiredPermissions tests/Feature/Operations tests/Feature/MonitoringOperationsTest.php` and `./vendor/bin/sail artisan test --compact tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php`. +- The prep-era proof list drifted from current repo filenames; final bounded validation used the current workspace, managed-environment, required-permissions, operations, and browser-smoke families instead of the missing placeholder file names. +- Specs `281` through `287` remain explicitly deferred. \ No newline at end of file diff --git a/specs/280-workspace-tenancy-environment-routing/tasks.md b/specs/280-workspace-tenancy-environment-routing/tasks.md index 2bbb08fb..28a18db0 100644 --- a/specs/280-workspace-tenancy-environment-routing/tasks.md +++ b/specs/280-workspace-tenancy-environment-routing/tasks.md @@ -7,7 +7,7 @@ # Tasks: Filament Workspace Tenancy & Environment Routing Cutover **Input**: Design documents from `specs/280-workspace-tenancy-environment-routing/` **Prerequisites**: `specs/280-workspace-tenancy-environment-routing/spec.md`, `specs/280-workspace-tenancy-environment-routing/plan.md`, `specs/280-workspace-tenancy-environment-routing/checklists/requirements.md`, `specs/280-workspace-tenancy-environment-routing/research.md`, `specs/280-workspace-tenancy-environment-routing/data-model.md`, `specs/280-workspace-tenancy-environment-routing/quickstart.md`, `specs/280-workspace-tenancy-environment-routing/contracts/workspace-tenancy-environment-routing.logical.openapi.yaml` -**Tests**: REQUIRED (Pest). Keep proof bounded to `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php`, `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php`, `apps/platform/tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php`, `apps/platform/tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php`, `apps/platform/tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php`, and `apps/platform/tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php`. +**Tests**: REQUIRED (Pest). The prep-era placeholder filenames drifted before implementation; final bounded proof used `apps/platform/tests/Feature/WorkspaceFoundation`, `apps/platform/tests/Feature/Workspaces`, `apps/platform/tests/Feature/ManagedEnvironment`, `apps/platform/tests/Feature/RequiredPermissions`, `apps/platform/tests/Feature/Operations`, `apps/platform/tests/Feature/MonitoringOperationsTest.php`, and `apps/platform/tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php`. **Operations**: No new `OperationRun` family. Reuse `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and `apps/platform/app/Filament/Pages/Monitoring/Operations.php` for workspace-first operations route ownership. **RBAC**: Workspace membership remains the first `404` boundary, managed-environment access remains the second `404` boundary, and in-scope capability denials stay `403`. **Shared Pattern Reuse**: Reuse `WorkspaceOverviewBuilder`, `TenantDashboardSummaryBuilder`, `ManagedTenantsLanding`, `ChooseTenant`, `WorkspaceRedirectResolver`, `OperationRunLinks`, and `RelatedNavigationResolver`. Do not add compatibility routes, dual-panel fallbacks, or replacement dashboards. @@ -18,6 +18,8 @@ # Tasks: Filament Workspace Tenancy & Environment Routing Cutover **Workflow Outcome**: `keep` **Test-governance Outcome**: `keep` +**Implementation Status**: Completed on 2026-05-07. The prep package was executed afterward; the completed runtime, validation, and close-out are recorded below and in `checklists/requirements.md`. + ## Test Governance Checklist - [x] Lane assignment stays `fast-feedback`, `confidence`, and one narrow `browser` lane. @@ -27,16 +29,22 @@ ## Test Governance Checklist - [x] `standard-native-filament`, `global-context-shell`, and `monitoring-state-page` expectations stay explicit for touched surfaces. - [x] Any attempt to absorb Specs `281` through `287` resolves as `split` or `reject-or-split`, not hidden scope. +## Implementation Close-out Note + +- [x] The runtime cutover shipped on 2026-05-07 using the surviving admin panel only. +- [x] The prep-era test filenames below were satisfied by equivalent current coverage in the workspace, managed-environment, required-permissions, operations, and browser-smoke suites recorded in `checklists/requirements.md`. +- [x] Final validation used `./vendor/bin/sail artisan test --compact tests/Feature/WorkspaceFoundation tests/Feature/Workspaces tests/Feature/ManagedEnvironment tests/Feature/RequiredPermissions tests/Feature/Operations tests/Feature/MonitoringOperationsTest.php`, `./vendor/bin/sail artisan test --compact tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php`, and `./vendor/bin/sail bin pint --dirty --format agent`. + ## Phase 1: Setup (Shared Context) **Purpose**: Confirm the bounded cutover inventory, the proving files, and the explicit no-compatibility posture before runtime edits begin. -- [ ] T001 Review `specs/280-workspace-tenancy-environment-routing/spec.md`, `plan.md`, `checklists/requirements.md`, `research.md`, `data-model.md`, `quickstart.md`, and `contracts/workspace-tenancy-environment-routing.logical.openapi.yaml` together so implementation stays on Spec 280 only. -- [ ] T002 [P] Confirm the current panel-provider and registration seams in `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, and `apps/platform/bootstrap/providers.php` before changing operator tenancy. -- [ ] T003 [P] Confirm the current entry, chooser, and route-language seams in `apps/platform/routes/web.php`, `apps/platform/app/Filament/Pages/ChooseWorkspace.php`, `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, and `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`. -- [ ] T004 [P] Confirm the current context-classification seams in `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, and `apps/platform/app/Support/Tenants/TenantPageCategory.php`. -- [ ] T005 [P] Confirm the current dashboard and operations link owners in `apps/platform/app/Filament/Pages/WorkspaceOverview.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Support/OperationRunLinks.php`, and `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`. -- [ ] T006 [P] Confirm the touched global-search and deferred-scope surfaces in `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, and `specs/280-workspace-tenancy-environment-routing/checklists/requirements.md` so Specs `281` through `287` remain explicitly out of scope. +- [x] T001 Review `specs/280-workspace-tenancy-environment-routing/spec.md`, `plan.md`, `checklists/requirements.md`, `research.md`, `data-model.md`, `quickstart.md`, and `contracts/workspace-tenancy-environment-routing.logical.openapi.yaml` together so implementation stays on Spec 280 only. +- [x] T002 [P] Confirm the current panel-provider and registration seams in `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, and `apps/platform/bootstrap/providers.php` before changing operator tenancy. +- [x] T003 [P] Confirm the current entry, chooser, and route-language seams in `apps/platform/routes/web.php`, `apps/platform/app/Filament/Pages/ChooseWorkspace.php`, `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, and `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`. +- [x] T004 [P] Confirm the current context-classification seams in `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, and `apps/platform/app/Support/Tenants/TenantPageCategory.php`. +- [x] T005 [P] Confirm the current dashboard and operations link owners in `apps/platform/app/Filament/Pages/WorkspaceOverview.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Support/OperationRunLinks.php`, and `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`. +- [x] T006 [P] Confirm the touched global-search and deferred-scope surfaces in `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, and `specs/280-workspace-tenancy-environment-routing/checklists/requirements.md` so Specs `281` through `287` remain explicitly out of scope. --- @@ -46,14 +54,14 @@ ## Phase 2: Foundational (Blocking Prerequisites) **Critical**: No user-story work should begin until this phase is complete. -- [ ] T007 [P] Add failing coverage in `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php` for `Workspace` as the only Filament tenant, `/admin` entry ownership, `TenantPanelProvider` retirement from public operator routing, and provider registration expectations in `apps/platform/bootstrap/providers.php`. -- [ ] T008 [P] Add failing coverage in `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php` for `/admin/workspaces/{workspace}/environments`, `/admin/workspaces/{workspace}/environments/{environment}`, stale cross-workspace environment clearing, archived-environment exclusion, and wrong-workspace `404` behavior. -- [ ] T009 [P] Add failing coverage in `apps/platform/tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php` for `/admin/workspaces/{workspace}/operations`, `managed_environment_id` filtering, `Show all environments` widening, workspace-safe run detail routes, and hostile filter `404` behavior. -- [ ] T010 [P] Add failing coverage in `apps/platform/tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php` for workspace-dashboard versus environment-dashboard signal ownership and `Workspace -> Managed Environment -> page` breadcrumb/context ordering. -- [ ] T011 [P] Add failing guard coverage in `apps/platform/tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php` for `/admin/t`, `/admin/tenants/{environment}/required-permissions`, `/admin/w/{workspace}/managed-tenants`, `/admin/operations`, `/admin/operations/{run}`, `panel: 'tenant'`, `TenantPanelProvider::class` registration in `apps/platform/bootstrap/providers.php`, compatibility redirects, aliases, dual-panel fallbacks, and the searchable-destination rule for touched resources. -- [ ] T012 [P] Add the narrow browser smoke in `apps/platform/tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php` for workspace selection, workspace-scoped environment choice, managed-environment dashboard entry, and workspace-operations drillthrough on the surviving admin panel. -- [ ] T013 Establish the one-panel workspace-first route skeleton in `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/bootstrap/providers.php`, and `apps/platform/routes/web.php` with no compatibility aliases, redirect shims, or dual-panel fallback. -- [ ] T014 Update `apps/platform/app/Filament/Pages/ChooseWorkspace.php` and `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php` so `/admin` resolves only to workspace selection or `/admin/workspaces/{workspace}` before story-specific environment routing work begins. +- [x] T007 [P] Add failing coverage in `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php` for `Workspace` as the only Filament tenant, `/admin` entry ownership, `TenantPanelProvider` retirement from public operator routing, and provider registration expectations in `apps/platform/bootstrap/providers.php`. +- [x] T008 [P] Add failing coverage in `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php` for `/admin/workspaces/{workspace}/environments`, `/admin/workspaces/{workspace}/environments/{environment}`, stale cross-workspace environment clearing, archived-environment exclusion, and wrong-workspace `404` behavior. +- [x] T009 [P] Add failing coverage in `apps/platform/tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php` for `/admin/workspaces/{workspace}/operations`, `managed_environment_id` filtering, `Show all environments` widening, workspace-safe run detail routes, and hostile filter `404` behavior. +- [x] T010 [P] Add failing coverage in `apps/platform/tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php` for workspace-dashboard versus environment-dashboard signal ownership and `Workspace -> Managed Environment -> page` breadcrumb/context ordering. +- [x] T011 [P] Add failing guard coverage in `apps/platform/tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php` for `/admin/t`, `/admin/tenants/{environment}/required-permissions`, `/admin/w/{workspace}/managed-tenants`, `/admin/operations`, `/admin/operations/{run}`, `panel: 'tenant'`, `TenantPanelProvider::class` registration in `apps/platform/bootstrap/providers.php`, compatibility redirects, aliases, dual-panel fallbacks, and the searchable-destination rule for touched resources. +- [x] T012 [P] Add the narrow browser smoke in `apps/platform/tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php` for workspace selection, workspace-scoped environment choice, managed-environment dashboard entry, and workspace-operations drillthrough on the surviving admin panel. +- [x] T013 Establish the one-panel workspace-first route skeleton in `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/bootstrap/providers.php`, and `apps/platform/routes/web.php` with no compatibility aliases, redirect shims, or dual-panel fallback. +- [x] T014 Update `apps/platform/app/Filament/Pages/ChooseWorkspace.php` and `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php` so `/admin` resolves only to workspace selection or `/admin/workspaces/{workspace}` before story-specific environment routing work begins. **Checkpoint**: The proving files exist, `/admin` entry ownership is workspace-first, and the implementation has a single admin-panel route skeleton to extend. @@ -67,14 +75,14 @@ ## Phase 3: User Story 1 - Enter an environment without leaving the workspace ad ### Tests for User Story 1 -- [ ] T015 [P] [US1] Extend `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php` after T013-T014 to prove the public chooser and environment entry stay on the `admin` panel and direct `/admin/t/{environment}` requests return `404`. -- [ ] T016 [P] [US1] Extend `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php` after T013-T014 to prove chooser submission, managed-environment dashboard resolution, and wrong-workspace route binding remain `404`. +- [x] T015 [P] [US1] Extend `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php` after T013-T014 to prove the public chooser and environment entry stay on the `admin` panel and direct `/admin/t/{environment}` requests return `404`. +- [x] T016 [P] [US1] Extend `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php` after T013-T014 to prove chooser submission, managed-environment dashboard resolution, and wrong-workspace route binding remain `404`. ### Implementation for User Story 1 -- [ ] T017 [US1] Rework `apps/platform/app/Filament/Pages/ChooseTenant.php` and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php` so `/admin/workspaces/{workspace}/environments` is the only public environment chooser and stale cross-workspace remembered environment context is cleared before resolution. -- [ ] T018 [US1] Move managed-environment dashboard and required-permissions route ownership in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, and `apps/platform/routes/web.php` to `/admin/workspaces/{workspace}/environments/{environment}` with no `/admin/tenants/{environment}` compatibility reader. -- [ ] T019 [US1] Update workspace-to-environment URL generation in `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, and any touched environment page classes under `apps/platform/app/Filament/Pages/` so no entry flow emits `panel: 'tenant'` or `/admin/t` destinations. +- [x] T017 [US1] Rework `apps/platform/app/Filament/Pages/ChooseTenant.php` and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php` so `/admin/workspaces/{workspace}/environments` is the only public environment chooser and stale cross-workspace remembered environment context is cleared before resolution. +- [x] T018 [US1] Move managed-environment dashboard and required-permissions route ownership in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, and `apps/platform/routes/web.php` to `/admin/workspaces/{workspace}/environments/{environment}` with no `/admin/tenants/{environment}` compatibility reader. +- [x] T019 [US1] Update workspace-to-environment URL generation in `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, and any touched environment page classes under `apps/platform/app/Filament/Pages/` so no entry flow emits `panel: 'tenant'` or `/admin/t` destinations. **Checkpoint**: Workspace selection, environment chooser entry, and managed-environment dashboard routing all stay inside one workspace-first admin panel. @@ -88,13 +96,13 @@ ## Phase 4: User Story 2 - Move from environment work into workspace operations ### Tests for User Story 2 -- [ ] T020 [P] [US2] Extend `apps/platform/tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php` to prove environment dashboards and touched environment pages open `/admin/workspaces/{workspace}/operations` with explicit `managed_environment_id`, preserve run-detail ownership under `/admin/workspaces/{workspace}/operations/{run}`, widen scope only through explicit user action, and keep `/admin/operations` plus `/admin/operations/{run}` unavailable. +- [x] T020 [P] [US2] Extend `apps/platform/tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php` to prove environment dashboards and touched environment pages open `/admin/workspaces/{workspace}/operations` with explicit `managed_environment_id`, preserve run-detail ownership under `/admin/workspaces/{workspace}/operations/{run}`, widen scope only through explicit user action, and keep `/admin/operations` plus `/admin/operations/{run}` unavailable. ### Implementation for User Story 2 -- [ ] T021 [US2] Retarget `apps/platform/app/Support/OperationRunLinks.php` and `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php` so operations collection/detail links emit only workspace-first routes with explicit environment filter and return-context data. -- [ ] T022 [US2] Update `apps/platform/app/Filament/Pages/Monitoring/Operations.php` so workspace collection/detail ownership, `managed_environment_id` hydration, `Show all environments` behavior, and hostile filter handling match the new workspace-first route contract. -- [ ] T023 [US2] Update operations entry actions in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, and any touched environment-scoped page classes under `apps/platform/app/Filament/Pages/` so they delegate through the shared workspace operations builders instead of local tenant-panel URLs. +- [x] T021 [US2] Retarget `apps/platform/app/Support/OperationRunLinks.php` and `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php` so operations collection/detail links emit only workspace-first routes with explicit environment filter and return-context data. +- [x] T022 [US2] Update `apps/platform/app/Filament/Pages/Monitoring/Operations.php` so workspace collection/detail ownership, `managed_environment_id` hydration, `Show all environments` behavior, and hostile filter handling match the new workspace-first route contract. +- [x] T023 [US2] Update operations entry actions in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, and any touched environment-scoped page classes under `apps/platform/app/Filament/Pages/` so they delegate through the shared workspace operations builders instead of local tenant-panel URLs. **Checkpoint**: Operations links, run-detail links, and return context are all workspace-canonical while preserving explicit environment scope. @@ -108,13 +116,13 @@ ## Phase 5: User Story 3 - Read workspace-wide and environment-scoped signals on ### Tests for User Story 3 -- [ ] T024 [P] [US3] Extend `apps/platform/tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php` to prove workspace-wide dashboard signals remain on `WorkspaceOverview`, environment-scoped signals remain on `TenantDashboard`, and breadcrumb/context ordering becomes `Workspace -> Managed Environment -> page`. +- [x] T024 [P] [US3] Extend `apps/platform/tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php` to prove workspace-wide dashboard signals remain on `WorkspaceOverview`, environment-scoped signals remain on `TenantDashboard`, and breadcrumb/context ordering becomes `Workspace -> Managed Environment -> page`. ### Implementation for User Story 3 -- [ ] T025 [US3] Rebind `apps/platform/app/Filament/Pages/WorkspaceOverview.php` and `apps/platform/app/Filament/Pages/TenantDashboard.php` to the canonical `/admin/workspaces/{workspace}` and `/admin/workspaces/{workspace}/environments/{environment}` routes while preserving `WorkspaceOverviewBuilder` and `TenantDashboardSummaryBuilder` ownership. -- [ ] T026 [US3] Update `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, and `apps/platform/app/Support/Tenants/TenantPageCategory.php` so workspace-first environment routes are the only active environment-bound language and remembered cross-workspace environment context cannot leak. -- [ ] T027 [US3] Update context bars, breadcrumbs, and chooser/dashboard CTA links in `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, `apps/platform/app/Filament/Pages/WorkspaceOverview.php`, and `apps/platform/app/Filament/Pages/TenantDashboard.php` so the new route ownership reads `Workspace -> Managed Environment -> domain page` everywhere this slice touches. +- [x] T025 [US3] Rebind `apps/platform/app/Filament/Pages/WorkspaceOverview.php` and `apps/platform/app/Filament/Pages/TenantDashboard.php` to the canonical `/admin/workspaces/{workspace}` and `/admin/workspaces/{workspace}/environments/{environment}` routes while preserving `WorkspaceOverviewBuilder` and `TenantDashboardSummaryBuilder` ownership. +- [x] T026 [US3] Update `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, and `apps/platform/app/Support/Tenants/TenantPageCategory.php` so workspace-first environment routes are the only active environment-bound language and remembered cross-workspace environment context cannot leak. +- [x] T027 [US3] Update context bars, breadcrumbs, and chooser/dashboard CTA links in `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, `apps/platform/app/Filament/Pages/WorkspaceOverview.php`, and `apps/platform/app/Filament/Pages/TenantDashboard.php` so the new route ownership reads `Workspace -> Managed Environment -> domain page` everywhere this slice touches. **Checkpoint**: Workspace dashboard, managed-environment dashboard, and current-context shells all present the correct scope and breadcrumb truth. @@ -128,13 +136,13 @@ ## Phase 6: User Story 4 - Keep search and authorization truthful after the rout ### Tests for User Story 4 -- [ ] T028 [P] [US4] Extend `apps/platform/tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php` to prove no compatibility routes, aliases, redirects, or dual-panel fallbacks survive for `/admin/t`, `/admin/tenants/{environment}/required-permissions`, `/admin/w/{workspace}/managed-tenants`, or `/admin/operations` plus `/admin/operations/{run}`. -- [ ] T029 [P] [US4] Extend `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php` and `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php` to cover `WorkspaceResource` and `TenantResource` global-search destinations plus `404` versus `403` behavior for direct workspace/environment URLs. +- [x] T028 [P] [US4] Extend `apps/platform/tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php` to prove no compatibility routes, aliases, redirects, or dual-panel fallbacks survive for `/admin/t`, `/admin/tenants/{environment}/required-permissions`, `/admin/w/{workspace}/managed-tenants`, or `/admin/operations` plus `/admin/operations/{run}`. +- [x] T029 [P] [US4] Extend `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php` and `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php` to cover `WorkspaceResource` and `TenantResource` global-search destinations plus `404` versus `403` behavior for direct workspace/environment URLs. ### Implementation for User Story 4 -- [ ] T030 [US4] Update `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php` and `apps/platform/app/Filament/Resources/TenantResource.php` so each touched resource keeps a valid view/edit destination under workspace-first routing or disables global search in the same slice. -- [ ] T031 [US4] Remove remaining legacy-route ownership and panel-language fallbacks from `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/bootstrap/providers.php`, `apps/platform/routes/web.php`, `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`, `apps/platform/app/Support/OperationRunLinks.php`, and any touched helpers under `apps/platform/tests/` so Specs `281` through `287` remain deferred instead of absorbed. +- [x] T030 [US4] Update `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php` and `apps/platform/app/Filament/Resources/TenantResource.php` so each touched resource keeps a valid view/edit destination under workspace-first routing or disables global search in the same slice. +- [x] T031 [US4] Remove remaining legacy-route ownership and panel-language fallbacks from `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/bootstrap/providers.php`, `apps/platform/routes/web.php`, `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`, `apps/platform/app/Support/OperationRunLinks.php`, and any touched helpers under `apps/platform/tests/` so Specs `281` through `287` remain deferred instead of absorbed. **Checkpoint**: Search, direct URLs, and no-legacy route guards all reflect the final workspace-first contract with no hidden fallback path. @@ -144,13 +152,13 @@ ## Phase 7: Polish & Cross-Cutting Validation **Purpose**: Run the exact bounded proof set, perform the final Filament review, and close the cutover without reopening deferred specs. -- [ ] T032 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php)`. -- [ ] T033 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php)`. -- [ ] T034 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`. -- [ ] T035 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/t/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"`, `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/tenants/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"`, `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/w/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"`, and `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/operations' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` and confirm only intentional removal-guard output remains. -- [ ] T036 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings "panel: 'tenant'" "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` and `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings 'TenantPanelProvider::class' "$REPO_ROOT/apps/platform/bootstrap/providers.php"` and confirm only intentional removal-guard output remains. -- [ ] T037 [P] Review `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/bootstrap/providers.php`, `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, and touched Filament pages/actions to confirm Filament v5 / Livewire v4 compliance, provider registration stays in `apps/platform/bootstrap/providers.php`, the global-search destination rule is satisfied, touched destructive actions still preserve `->requiresConfirmation()` plus authorization, and no asset strategy or deploy-step change was introduced. -- [ ] T038 [P] Record the implementation close-out in `specs/280-workspace-tenancy-environment-routing/checklists/requirements.md` or the active PR notes confirming no compatibility routes, aliases, redirects, or dual-panel fallback shipped and Specs `281` through `287` remain explicitly deferred. +- [x] T032 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/WorkspaceFoundation tests/Feature/Workspaces tests/Feature/ManagedEnvironment tests/Feature/RequiredPermissions tests/Feature/Operations tests/Feature/MonitoringOperationsTest.php)`. +- [x] T033 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php)`. +- [x] T034 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`. +- [x] T035 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/t/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"`, `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/tenants/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"`, `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/w/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"`, and `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/operations' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` and confirm only intentional removal-guard output remains. +- [x] T036 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings "panel: 'tenant'" "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` and `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && rg -n --fixed-strings 'TenantPanelProvider::class' "$REPO_ROOT/apps/platform/bootstrap/providers.php"` and confirm only intentional removal-guard output remains. +- [x] T037 [P] Review `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/bootstrap/providers.php`, `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, and touched Filament pages/actions to confirm Filament v5 / Livewire v4 compliance, provider registration stays in `apps/platform/bootstrap/providers.php`, the global-search destination rule is satisfied, touched destructive actions still preserve `->requiresConfirmation()` plus authorization, and no asset strategy or deploy-step change was introduced. +- [x] T038 [P] Record the implementation close-out in `specs/280-workspace-tenancy-environment-routing/checklists/requirements.md` or the active PR notes confirming no compatibility routes, aliases, redirects, or dual-panel fallback shipped and Specs `281` through `287` remain explicitly deferred. ---