feat: complete workspace-first environment routing cutover #340
@ -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'],
|
||||
],
|
||||
);
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@ -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() ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,7 +102,6 @@ public static function findingDatabaseNotificationMessage(Finding $finding, Mana
|
||||
actionUrl: FindingResource::getUrl(
|
||||
'view',
|
||||
['record' => $finding],
|
||||
panel: 'tenant',
|
||||
tenant: $tenant,
|
||||
),
|
||||
actionTarget: 'finding_detail',
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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',
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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.')
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 ---
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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());
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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());
|
||||
});
|
||||
|
||||
@ -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]));
|
||||
});
|
||||
|
||||
@ -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()));
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -68,3 +68,13 @@ ## Review Outcome
|
||||
- **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.
|
||||
|
||||
## 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.
|
||||
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user