feat: complete workspace-first environment routing cutover
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m27s

This commit is contained in:
Ahmed Darrazi 2026-05-07 23:46:45 +02:00
parent 35156a72bc
commit e4db44f606
75 changed files with 466 additions and 248 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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() ?? [],
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -102,7 +102,6 @@ public static function findingDatabaseNotificationMessage(Finding $finding, Mana
actionUrl: FindingResource::getUrl(
'view',
['record' => $finding],
panel: 'tenant',
tenant: $tenant,
),
actionTarget: 'finding_detail',

View File

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

View File

@ -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',
),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
<?php
use App\Filament\Pages\TenantDashboard;
use App\Filament\Pages\WorkspaceOverview;
use App\Http\Controllers\AdminConsentCallbackController;
use App\Http\Controllers\Auth\EntraController;
@ -52,7 +53,15 @@
FilamentAuthenticate::class,
'ensure-workspace-selected',
])
->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');

View File

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
pest()->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();
});

View File

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

View File

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

View File

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

View File

@ -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.')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
<?php
use App\Models\TenantPermission;
use App\Support\Links\RequiredPermissionsLinks;
it('renders required permissions overview with missing-first ordering and feature cards', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
@ -26,7 +27,7 @@
]);
$this->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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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