fix(shell): enforce workspace surface scope and sidebar contract
This commit is contained in:
parent
52bb4a0afc
commit
fa3b5f6d6a
@ -317,11 +317,7 @@ private function tenantFilterOptions(): array
|
||||
|
||||
private function defaultTenantFilter(): ?string
|
||||
{
|
||||
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
||||
|
||||
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
|
||||
? (string) $tenantId
|
||||
: null;
|
||||
return null;
|
||||
}
|
||||
|
||||
private function applyRequestedTenantPrefilter(): void
|
||||
@ -353,7 +349,6 @@ private function hasActiveFilters(): bool
|
||||
|
||||
private function clearWorkspaceFilters(): void
|
||||
{
|
||||
app(WorkspaceContext::class)->clearLastTenantId(request());
|
||||
$this->removeTableFilters();
|
||||
}
|
||||
|
||||
|
||||
@ -278,11 +278,7 @@ private function tenantFilterOptions(): array
|
||||
|
||||
private function defaultTenantFilter(): ?string
|
||||
{
|
||||
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
||||
|
||||
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
|
||||
? (string) $tenantId
|
||||
: null;
|
||||
return null;
|
||||
}
|
||||
|
||||
private function applyRequestedTenantPrefilter(): void
|
||||
@ -317,7 +313,6 @@ private function hasActiveFilters(): bool
|
||||
|
||||
private function clearRegisterFilters(): void
|
||||
{
|
||||
app(WorkspaceContext::class)->clearLastTenantId(request());
|
||||
$this->removeTableFilters();
|
||||
}
|
||||
|
||||
|
||||
@ -15,7 +15,6 @@
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -115,7 +114,6 @@ public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$user = auth()->user();
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
$scopeResolver = app(ManagedEnvironmentAccessScopeResolver::class);
|
||||
|
||||
@ -153,10 +151,6 @@ public static function getEloquentQuery(): Builder
|
||||
});
|
||||
}),
|
||||
)
|
||||
->when(
|
||||
$activeTenant instanceof ManagedEnvironment,
|
||||
fn (Builder $query): Builder => $query->where('managed_environment_id', (int) $activeTenant->getKey()),
|
||||
)
|
||||
->latest('id');
|
||||
}
|
||||
|
||||
@ -276,14 +270,6 @@ public static function table(Table $table): Table
|
||||
SelectFilter::make('managed_environment_id')
|
||||
->label('ManagedEnvironment')
|
||||
->options(function (): array {
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if ($activeTenant instanceof ManagedEnvironment) {
|
||||
return [
|
||||
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
|
||||
];
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
@ -296,21 +282,7 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->all();
|
||||
})
|
||||
->default(function (): ?string {
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if (! $activeTenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $activeTenant->getKey();
|
||||
})
|
||||
->default(null)
|
||||
->searchable(),
|
||||
SelectFilter::make('status')
|
||||
->options(FilterOptionCatalog::alertDeliveryStatuses()),
|
||||
|
||||
@ -891,7 +891,7 @@ public static function table(Table $table): Table
|
||||
->filters([
|
||||
SelectFilter::make('tenant')
|
||||
->label('ManagedEnvironment')
|
||||
->default(static::resolveScopedTenant()?->external_id)
|
||||
->default(static::resolveRequestedTenantExternalId())
|
||||
->options(static::tenantFilterOptions())
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
@ -10,10 +10,8 @@
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
@ -103,12 +101,6 @@ private function deliveriesQueryForViewer(User $user, int $workspaceId): Builder
|
||||
qualifiedEnvironmentColumn: 'alert_deliveries.managed_environment_id',
|
||||
);
|
||||
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if ($activeTenant instanceof ManagedEnvironment) {
|
||||
$query->where('managed_environment_id', (int) $activeTenant->getKey());
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,13 +6,17 @@
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Carbon\CarbonInterval;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class OperationsKpiHeader extends StatsOverviewWidget
|
||||
@ -25,11 +29,22 @@ protected function getPollingInterval(): ?string
|
||||
{
|
||||
$tenant = $this->activeTenant();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
|
||||
}
|
||||
|
||||
$query = $this->scopedOperationRunQuery();
|
||||
|
||||
if (! $query instanceof Builder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
|
||||
return (clone $query)
|
||||
->whereIn('status', [
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
])
|
||||
->exists() ? '10s' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -37,29 +52,24 @@ protected function getPollingInterval(): ?string
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$tenant = $this->activeTenant();
|
||||
$scopeQuery = $this->scopedOperationRunQuery();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
if (! $scopeQuery instanceof Builder) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
$totalRuns30Days = (int) OperationRun::query()
|
||||
->where('managed_environment_id', $tenantId)
|
||||
$totalRuns30Days = (int) (clone $scopeQuery)
|
||||
->where('created_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
$activeRuns = (int) OperationRun::query()
|
||||
->where('managed_environment_id', $tenantId)
|
||||
$activeRuns = (int) (clone $scopeQuery)
|
||||
->whereIn('status', [
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
])
|
||||
->count();
|
||||
|
||||
$failedOrPartial7Days = (int) OperationRun::query()
|
||||
->where('managed_environment_id', $tenantId)
|
||||
$failedOrPartial7Days = (int) (clone $scopeQuery)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereIn('outcome', [
|
||||
OperationRunOutcome::Failed->value,
|
||||
@ -69,8 +79,7 @@ protected function getStats(): array
|
||||
->count();
|
||||
|
||||
/** @var Collection<int, OperationRun> $recentCompletedRuns */
|
||||
$recentCompletedRuns = OperationRun::query()
|
||||
->where('managed_environment_id', $tenantId)
|
||||
$recentCompletedRuns = (clone $scopeQuery)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereNotNull('started_at')
|
||||
->whereNotNull('completed_at')
|
||||
@ -117,6 +126,42 @@ private function activeTenant(): ?ManagedEnvironment
|
||||
return $tenant instanceof ManagedEnvironment ? $tenant : null;
|
||||
}
|
||||
|
||||
private function scopedOperationRunQuery(): ?Builder
|
||||
{
|
||||
$tenant = $this->activeTenant();
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return OperationRun::query()
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('managed_environment_id', (int) $tenant->getKey());
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$user = auth()->user();
|
||||
|
||||
if (! is_int($workspaceId) || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = OperationRun::query()
|
||||
->where('workspace_id', $workspaceId);
|
||||
|
||||
$allowedTenantIds = app(ManagedEnvironmentAccessScopeResolver::class)
|
||||
->allowedManagedEnvironmentIdsForWorkspace($user, $workspaceId);
|
||||
|
||||
if ($allowedTenantIds === null) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where(function (Builder $query) use ($allowedTenantIds): void {
|
||||
$query->whereNull('managed_environment_id');
|
||||
|
||||
if ($allowedTenantIds !== []) {
|
||||
$query->orWhereIn('managed_environment_id', array_values(array_unique(array_map('intval', $allowedTenantIds))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static function formatDurationSeconds(int $seconds): string
|
||||
{
|
||||
if ($seconds <= 0) {
|
||||
|
||||
@ -2,30 +2,16 @@
|
||||
|
||||
namespace App\Support\Middleware;
|
||||
|
||||
use App\Filament\Pages\WorkspaceOverview;
|
||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\NavigationScope;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Navigation\WorkspaceSidebarNavigation;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Tenants\TenantPageCategory;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Closure;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Navigation\NavigationBuilder;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@ -171,134 +157,11 @@ private function configureNavigationForRequest(\Filament\Panel $panel, Request $
|
||||
return;
|
||||
}
|
||||
|
||||
$panel->navigation(function (): NavigationBuilder {
|
||||
return app(NavigationBuilder::class)
|
||||
->item(WorkspaceOverview::navigationItem())
|
||||
->item(
|
||||
NavigationItem::make('Governance inbox')
|
||||
->url(fn (): string => GovernanceInbox::getUrl(panel: 'admin'))
|
||||
->icon('heroicon-o-inbox-stack')
|
||||
->group('Governance')
|
||||
->sort(5),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Customer reviews')
|
||||
->url(fn (): string => CustomerReviewWorkspace::getUrl(panel: 'admin'))
|
||||
->icon('heroicon-o-document-text')
|
||||
->group('Reporting')
|
||||
->sort(44),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Integrations')
|
||||
->url(fn (): string => route('filament.admin.resources.provider-connections.index'))
|
||||
->icon('heroicon-o-link')
|
||||
->group('Settings')
|
||||
->sort(15)
|
||||
->visible(fn (): bool => ProviderConnectionResource::canViewAny()),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Settings')
|
||||
->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin'))
|
||||
->icon('heroicon-o-cog-6-tooth')
|
||||
->group('Settings')
|
||||
->sort(20)
|
||||
->visible(fn (): bool => $this->canViewWorkspaceSettings()),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Manage workspaces')
|
||||
->url(fn (): string => route('filament.admin.resources.workspaces.index'))
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->group('Settings')
|
||||
->sort(10)
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||
|
||||
return WorkspaceMembership::query()
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->whereIn('role', $roles)
|
||||
->exists();
|
||||
}),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Operations')
|
||||
->url(fn (): string => OperationRunLinks::index())
|
||||
->icon('heroicon-o-queue-list')
|
||||
->group('Monitoring')
|
||||
->sort(10),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Alert targets')
|
||||
->url(fn (): string => AlertDestinationResource::getUrl(panel: 'admin'))
|
||||
->icon('heroicon-o-bell-alert')
|
||||
->group('Monitoring')
|
||||
->sort(20)
|
||||
->visible(fn (): bool => AlertDestinationResource::canViewAny()),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Alert rules')
|
||||
->url(fn (): string => AlertRuleResource::getUrl(panel: 'admin'))
|
||||
->icon('heroicon-o-funnel')
|
||||
->group('Monitoring')
|
||||
->sort(21)
|
||||
->visible(fn (): bool => AlertRuleResource::canViewAny()),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Alert deliveries')
|
||||
->url(fn (): string => AlertDeliveryResource::getUrl(panel: 'admin'))
|
||||
->icon('heroicon-o-clock')
|
||||
->group('Monitoring')
|
||||
->sort(22)
|
||||
->visible(fn (): bool => AlertDeliveryResource::canViewAny()),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Alerts')
|
||||
->url(fn (): string => '/admin/alerts')
|
||||
->icon('heroicon-o-bell-alert')
|
||||
->group('Monitoring')
|
||||
->sort(23),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Audit Log')
|
||||
->url(fn (): string => '/admin/audit-log')
|
||||
->icon('heroicon-o-clipboard-document-list')
|
||||
->group('Monitoring')
|
||||
->sort(30),
|
||||
);
|
||||
$panel->navigation(function (WorkspaceSidebarNavigation $navigation): NavigationBuilder {
|
||||
return $navigation->build();
|
||||
});
|
||||
}
|
||||
|
||||
private function canViewWorkspaceSettings(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW);
|
||||
}
|
||||
|
||||
private function isWorkspaceScopedPageWithTenant(string $path): bool
|
||||
{
|
||||
return preg_match('#^/admin/workspaces/[^/]+/environments/[^/]+/(required-permissions|diagnostics|access-scopes)$#', $path) === 1;
|
||||
|
||||
@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Navigation;
|
||||
|
||||
use App\Filament\Pages\Governance\DecisionRegister;
|
||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||
use App\Filament\Pages\Monitoring\Alerts;
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||
use App\Filament\Pages\WorkspaceOverview;
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Navigation\NavigationBuilder;
|
||||
use Filament\Navigation\NavigationGroup;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
|
||||
final class WorkspaceSidebarNavigation
|
||||
{
|
||||
public function build(): NavigationBuilder
|
||||
{
|
||||
return app(NavigationBuilder::class)
|
||||
->item(WorkspaceOverview::navigationItem())
|
||||
->groups([
|
||||
NavigationGroup::make(__('localization.navigation.monitoring'))
|
||||
->items($this->visibleItems([
|
||||
NavigationItem::make(FindingExceptionsQueue::getNavigationLabel())
|
||||
->url(fn (): string => FindingExceptionsQueue::getUrl(panel: 'admin'))
|
||||
->icon(FindingExceptionsQueue::getNavigationIcon())
|
||||
->visible(fn (): bool => FindingExceptionsQueue::canAccess()),
|
||||
NavigationItem::make(__('localization.navigation.operations'))
|
||||
->url(fn (): string => OperationRunLinks::index())
|
||||
->icon('heroicon-o-queue-list')
|
||||
->visible(fn (): bool => true),
|
||||
NavigationItem::make(__('localization.navigation.alerts'))
|
||||
->url(fn (): string => route('filament.admin.alerts'))
|
||||
->icon('heroicon-o-bell-alert')
|
||||
->visible(fn (): bool => Alerts::canAccess())
|
||||
->childItems($this->visibleItems([
|
||||
NavigationItem::make(AlertDestinationResource::getNavigationLabel())
|
||||
->url(fn (): string => AlertDestinationResource::getUrl(panel: 'admin'))
|
||||
->icon(AlertDestinationResource::getNavigationIcon())
|
||||
->visible(fn (): bool => AlertDestinationResource::canViewAny()),
|
||||
NavigationItem::make(AlertRuleResource::getNavigationLabel())
|
||||
->url(fn (): string => AlertRuleResource::getUrl(panel: 'admin'))
|
||||
->icon(AlertRuleResource::getNavigationIcon())
|
||||
->visible(fn (): bool => AlertRuleResource::canViewAny()),
|
||||
NavigationItem::make(AlertDeliveryResource::getNavigationLabel())
|
||||
->url(fn (): string => AlertDeliveryResource::getUrl(panel: 'admin'))
|
||||
->icon(AlertDeliveryResource::getNavigationIcon())
|
||||
->visible(fn (): bool => AlertDeliveryResource::canViewAny()),
|
||||
])),
|
||||
NavigationItem::make(__('localization.navigation.audit_log'))
|
||||
->url(fn (): string => route('admin.monitoring.audit-log'))
|
||||
->icon('heroicon-o-clipboard-document-list'),
|
||||
])),
|
||||
NavigationGroup::make(__('localization.review.reporting'))
|
||||
->items($this->visibleItems([
|
||||
NavigationItem::make(ReviewRegister::getNavigationLabel())
|
||||
->url(fn (): string => ReviewRegister::getUrl(panel: 'admin'))
|
||||
->icon(ReviewRegister::getNavigationIcon()),
|
||||
NavigationItem::make(CustomerReviewWorkspace::getNavigationLabel())
|
||||
->url(fn (): string => CustomerReviewWorkspace::getUrl(panel: 'admin'))
|
||||
->icon(CustomerReviewWorkspace::getNavigationIcon()),
|
||||
])),
|
||||
NavigationGroup::make(__('localization.navigation.settings'))
|
||||
->items($this->visibleItems([
|
||||
NavigationItem::make(__('localization.navigation.manage_workspaces'))
|
||||
->url(fn (): string => route('filament.admin.resources.workspaces.index'))
|
||||
->icon(WorkspaceResource::getNavigationIcon())
|
||||
->visible(fn (): bool => $this->canManageWorkspaces()),
|
||||
NavigationItem::make(__('localization.navigation.integrations'))
|
||||
->url(fn (): string => ProviderConnectionResource::getUrl('index', panel: 'admin'))
|
||||
->icon(ProviderConnectionResource::getNavigationIcon())
|
||||
->visible(fn (): bool => ProviderConnectionResource::canViewAny())
|
||||
->childItems($this->visibleItems([
|
||||
NavigationItem::make(ProviderConnectionResource::getNavigationLabel())
|
||||
->url(fn (): string => ProviderConnectionResource::getUrl('index', panel: 'admin'))
|
||||
->icon(ProviderConnectionResource::getNavigationIcon())
|
||||
->visible(fn (): bool => ProviderConnectionResource::canViewAny()),
|
||||
])),
|
||||
NavigationItem::make(__('localization.navigation.settings'))
|
||||
->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin'))
|
||||
->icon('heroicon-o-cog-6-tooth')
|
||||
->visible(fn (): bool => $this->canViewWorkspaceSettings()),
|
||||
])),
|
||||
NavigationGroup::make(__('localization.navigation.governance'))
|
||||
->items($this->visibleItems([
|
||||
NavigationItem::make(GovernanceInbox::getNavigationLabel())
|
||||
->url(fn (): string => GovernanceInbox::getUrl(panel: 'admin'))
|
||||
->icon(GovernanceInbox::getNavigationIcon()),
|
||||
NavigationItem::make(DecisionRegister::getNavigationLabel())
|
||||
->url(fn (): string => DecisionRegister::getUrl(panel: 'admin'))
|
||||
->icon(DecisionRegister::getNavigationIcon())
|
||||
->visible(fn (): bool => DecisionRegister::canAccess()),
|
||||
])),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, NavigationItem> $items
|
||||
* @return array<int, NavigationItem>
|
||||
*/
|
||||
private function visibleItems(array $items): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$items,
|
||||
static fn (NavigationItem $item): bool => $item->isVisible(),
|
||||
));
|
||||
}
|
||||
|
||||
private function canManageWorkspaces(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||
|
||||
return WorkspaceMembership::query()
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->whereIn('role', $roles)
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function canViewWorkspaceSettings(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW);
|
||||
}
|
||||
}
|
||||
@ -169,6 +169,18 @@ private function buildResolvedContext(?Request $request = null): ResolvedShellCo
|
||||
);
|
||||
}
|
||||
|
||||
if ($pageCategory->forcesTenantlessShellContext()) {
|
||||
return new ResolvedShellContext(
|
||||
workspace: $workspace,
|
||||
tenant: null,
|
||||
pageCategory: $pageCategory,
|
||||
state: 'tenantless_workspace',
|
||||
displayMode: 'tenantless',
|
||||
workspaceSource: $workspaceSource,
|
||||
recoveryReason: $recoveryReason,
|
||||
);
|
||||
}
|
||||
|
||||
$queryHintTenant = $this->resolveValidatedQueryHintTenant($request, $workspace, $pageCategory);
|
||||
|
||||
if ($queryHintTenant['tenant'] instanceof ManagedEnvironment) {
|
||||
|
||||
@ -18,6 +18,7 @@ public static function fromPageCategory(TenantPageCategory $pageCategory): self
|
||||
TenantPageCategory::TenantBound,
|
||||
TenantPageCategory::TenantScopedEvidence => self::AdministrativeManagement,
|
||||
TenantPageCategory::CanonicalWorkspaceRecordViewer => self::CanonicalWorkspaceRecord,
|
||||
TenantPageCategory::WorkspaceWideSurface,
|
||||
TenantPageCategory::WorkspaceScoped,
|
||||
TenantPageCategory::WorkspaceChooserException => self::StandardActiveOperating,
|
||||
};
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
|
||||
enum TenantPageCategory: string
|
||||
{
|
||||
case WorkspaceWideSurface = 'workspace_wide_surface';
|
||||
case WorkspaceScoped = 'workspace_scoped';
|
||||
case WorkspaceChooserException = 'workspace_chooser_exception';
|
||||
case TenantBound = 'tenant_bound';
|
||||
@ -21,7 +22,7 @@ public static function fromRequest(?Request $request = null): self
|
||||
return self::WorkspaceScoped;
|
||||
}
|
||||
|
||||
return self::fromPath('/'.ltrim($request->path(), '/'));
|
||||
return self::fromPath(self::effectivePath($request));
|
||||
}
|
||||
|
||||
public static function fromPath(string $path): self
|
||||
@ -36,6 +37,10 @@ public static function fromPath(string $path): self
|
||||
return self::CanonicalWorkspaceRecordViewer;
|
||||
}
|
||||
|
||||
if (self::isWorkspaceWideSurfacePath($normalizedPath)) {
|
||||
return self::WorkspaceWideSurface;
|
||||
}
|
||||
|
||||
if (
|
||||
str_starts_with($normalizedPath, '/admin/evidence/')
|
||||
&& ! str_starts_with($normalizedPath, '/admin/evidence/overview')
|
||||
@ -57,7 +62,7 @@ public static function fromPath(string $path): self
|
||||
public function allowsQueryTenantHints(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::WorkspaceScoped, self::OnboardingWorkflow => true,
|
||||
self::WorkspaceWideSurface, self::WorkspaceScoped, self::OnboardingWorkflow => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
@ -73,6 +78,7 @@ public function allowsRememberedTenantRestore(): bool
|
||||
public function allowsTenantlessState(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::WorkspaceWideSurface,
|
||||
self::WorkspaceScoped,
|
||||
self::WorkspaceChooserException,
|
||||
self::OnboardingWorkflow,
|
||||
@ -81,6 +87,16 @@ public function allowsTenantlessState(): bool
|
||||
};
|
||||
}
|
||||
|
||||
public function forcesTenantlessShellContext(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::WorkspaceWideSurface,
|
||||
self::WorkspaceChooserException,
|
||||
self::CanonicalWorkspaceRecordViewer => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function requiresExplicitTenant(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
@ -93,4 +109,33 @@ public function lane(): TenantInteractionLane
|
||||
{
|
||||
return TenantInteractionLane::fromPageCategory($this);
|
||||
}
|
||||
|
||||
private static function isWorkspaceWideSurfacePath(string $normalizedPath): bool
|
||||
{
|
||||
if (preg_match('#^/admin/workspaces/[^/]+/(?:overview|operations)(?:/|$)#', $normalizedPath) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return preg_match('#^/admin/(?:alerts|audit-log|evidence/overview|governance/(?:decisions|inbox)|provider-connections|reviews(?:/workspace)?)(?:/|$)#', $normalizedPath) === 1;
|
||||
}
|
||||
|
||||
private static function effectivePath(Request $request): string
|
||||
{
|
||||
$path = '/'.ltrim((string) $request->path(), '/');
|
||||
|
||||
if (! self::isLivewireRequestPath($path) && ! $request->headers->has('x-livewire')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH);
|
||||
|
||||
return is_string($refererPath) && $refererPath !== ''
|
||||
? '/'.ltrim($refererPath, '/')
|
||||
: $path;
|
||||
}
|
||||
|
||||
private static function isLivewireRequestPath(string $path): bool
|
||||
{
|
||||
return preg_match('#^/(?:livewire(?:-[^/]+)?/update|livewire-unit-test-endpoint)(?:/|$)#', $path) === 1;
|
||||
}
|
||||
}
|
||||
|
||||
@ -394,6 +394,7 @@
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-member',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->prefix('/admin/workspaces/{workspace}')
|
||||
->group(function (): void {
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
@ -199,7 +200,7 @@ function alertDeliveryFilterIndicatorLabels($component): array
|
||||
->assertCanNotSeeTableRecords([$failedDelivery]);
|
||||
});
|
||||
|
||||
it('replaces the persisted tenant filter when canonical tenant context changes', function (): void {
|
||||
it('keeps persisted alert delivery filters tenantless when remembered tenant context changes', function (): void {
|
||||
$tenantA = ManagedEnvironment::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
@ -244,11 +245,12 @@ function alertDeliveryFilterIndicatorLabels($component): array
|
||||
(string) $workspaceId => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ListAlertDeliveries::class)
|
||||
$component = Livewire::withHeaders(['referer' => AlertDeliveryResource::getUrl(panel: 'admin')])
|
||||
->test(ListAlertDeliveries::class)
|
||||
->filterTable('status', AlertDelivery::STATUS_SENT);
|
||||
|
||||
expect(data_get(session()->get($component->instance()->getTableFiltersSessionKey()), 'managed_environment_id.value'))
|
||||
->toBe((string) $tenantA->getKey());
|
||||
->toBeNull();
|
||||
expect(data_get(session()->get($component->instance()->getTableFiltersSessionKey()), 'status.value'))
|
||||
->toBe(AlertDelivery::STATUS_SENT);
|
||||
|
||||
@ -256,11 +258,11 @@ function alertDeliveryFilterIndicatorLabels($component): array
|
||||
(string) $workspaceId => (int) $tenantB->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::test(ListAlertDeliveries::class)
|
||||
->assertSet('tableFilters.managed_environment_id.value', (string) $tenantB->getKey())
|
||||
Livewire::withHeaders(['referer' => AlertDeliveryResource::getUrl(panel: 'admin')])
|
||||
->test(ListAlertDeliveries::class)
|
||||
->assertSet('tableFilters.managed_environment_id.value', null)
|
||||
->assertSet('tableFilters.status.value', AlertDelivery::STATUS_SENT)
|
||||
->assertCanSeeTableRecords([$deliveryB])
|
||||
->assertCanNotSeeTableRecords([$deliveryA]);
|
||||
->assertCanSeeTableRecords([$deliveryA, $deliveryB]);
|
||||
});
|
||||
|
||||
it('includes tenantless test deliveries in the list', function (): void {
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('defaults the canonical review register to the remembered tenant when tenant context is available', function (): void {
|
||||
it('keeps the canonical review register unfiltered when remembered tenant context is available', function (): void {
|
||||
$tenantA = ManagedEnvironment::factory()->create(['name' => 'Alpha ManagedEnvironment']);
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
@ -38,14 +38,13 @@
|
||||
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
Livewire::withHeaders(['referer' => ReviewRegister::getUrl(panel: 'admin')])
|
||||
->actingAs($user)
|
||||
->test(ReviewRegister::class)
|
||||
->assertSet('tableFilters.managed_environment_id.value', (string) $tenantB->getKey())
|
||||
->assertCanSeeTableRecords([$reviewB])
|
||||
->assertCanNotSeeTableRecords([$reviewA])
|
||||
->assertSet('tableFilters.managed_environment_id.value', null)
|
||||
->assertCanSeeTableRecords([$reviewA, $reviewB])
|
||||
->assertSee('Publication blocked')
|
||||
->assertSee('Resolve the review blockers before publication')
|
||||
->assertDontSee('Publishable');
|
||||
->assertSee('Resolve the review blockers before publication');
|
||||
});
|
||||
|
||||
it('prefilters the review register from a tenant query parameter and accepts external tenant identifiers', function (): void {
|
||||
@ -76,7 +75,8 @@
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
|
||||
Livewire::withQueryParams(['tenant' => (string) $tenantA->external_id])
|
||||
Livewire::withHeaders(['referer' => ReviewRegister::getUrl(panel: 'admin')])
|
||||
->withQueryParams(['tenant' => (string) $tenantA->external_id])
|
||||
->test(ReviewRegister::class)
|
||||
->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey())
|
||||
->assertCanSeeTableRecords([$reviewA])
|
||||
@ -105,7 +105,9 @@
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(ReviewRegister::class);
|
||||
$component = Livewire::withHeaders(['referer' => ReviewRegister::getUrl(panel: 'admin')])
|
||||
->actingAs($user)
|
||||
->test(ReviewRegister::class);
|
||||
$tenantFilter = $component->instance()->getTable()->getFilters()['managed_environment_id'] ?? null;
|
||||
|
||||
expect($tenantFilter)->not->toBeNull()
|
||||
|
||||
@ -38,7 +38,8 @@
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
Livewire::withHeaders(['referer' => ReviewRegister::getUrl(panel: 'admin')])
|
||||
->actingAs($user)
|
||||
->test(ReviewRegister::class)
|
||||
->assertSee('Outcome')
|
||||
->assertDontSee('Monitoring landing')
|
||||
@ -58,7 +59,8 @@
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
Livewire::withHeaders(['referer' => ReviewRegister::getUrl(panel: 'admin')])
|
||||
->actingAs($user)
|
||||
->test(ReviewRegister::class)
|
||||
->searchTable('no-such-review-row')
|
||||
->assertCanNotSeeTableRecords([$review])
|
||||
@ -67,7 +69,7 @@
|
||||
->assertSee('Clear filters');
|
||||
});
|
||||
|
||||
it('clears the remembered tenant prefilter from the review register', function (): void {
|
||||
it('clears only the page-level tenant filter from the review register', function (): void {
|
||||
$tenantA = ManagedEnvironment::factory()->create(['name' => 'Alpha ManagedEnvironment']);
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
@ -87,8 +89,10 @@
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
$component = Livewire::withHeaders(['referer' => ReviewRegister::getUrl(panel: 'admin')])
|
||||
->withQueryParams(['tenant' => (string) $tenantA->external_id])
|
||||
->test(ReviewRegister::class)
|
||||
->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey())
|
||||
->assertActionVisible('clear_filters')
|
||||
->assertCanSeeTableRecords([$reviewA])
|
||||
->assertCanNotSeeTableRecords([$reviewB]);
|
||||
@ -100,7 +104,7 @@
|
||||
->assertActionHidden('clear_filters')
|
||||
->assertCanSeeTableRecords([$reviewA, $reviewB]);
|
||||
|
||||
expect(app(WorkspaceContext::class)->lastTenantId())->toBeNull();
|
||||
expect(app(WorkspaceContext::class)->lastTenantId())->toBe((int) $tenantA->getKey());
|
||||
});
|
||||
|
||||
it('keeps stale and partial review rows aligned with environment review detail trust', function (): void {
|
||||
@ -157,7 +161,8 @@
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $staleTenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
Livewire::withHeaders(['referer' => ReviewRegister::getUrl(panel: 'admin')])
|
||||
->actingAs($user)
|
||||
->test(ReviewRegister::class)
|
||||
->assertCanSeeTableRecords([$staleReview, $partialReview])
|
||||
->assertSee('Internal only')
|
||||
|
||||
@ -203,7 +203,7 @@ function getAlertDeliveryHeaderAction(Testable $component, string $name): ?Actio
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('keeps persisted alert delivery filters inside the active tenant scope', function (): void {
|
||||
it('keeps persisted alert delivery filters inside the workspace-wide alert delivery scope', function (): void {
|
||||
$tenantA = ManagedEnvironment::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
@ -242,29 +242,30 @@ function getAlertDeliveryHeaderAction(Testable $component, string $name): ?Actio
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenantA, true);
|
||||
|
||||
Livewire::test(ListAlertDeliveries::class)
|
||||
Livewire::withHeaders(['referer' => AlertDeliveryResource::getUrl(panel: 'admin')])
|
||||
->test(ListAlertDeliveries::class)
|
||||
->filterTable('status', AlertDelivery::STATUS_SENT)
|
||||
->assertCanSeeTableRecords([$tenantADelivery])
|
||||
->assertCanNotSeeTableRecords([$tenantBDelivery]);
|
||||
->assertCanSeeTableRecords([$tenantADelivery, $tenantBDelivery]);
|
||||
|
||||
Livewire::test(ListAlertDeliveries::class)
|
||||
Livewire::withHeaders(['referer' => AlertDeliveryResource::getUrl(panel: 'admin')])
|
||||
->test(ListAlertDeliveries::class)
|
||||
->assertSet('tableFilters.status.value', AlertDelivery::STATUS_SENT)
|
||||
->assertCanSeeTableRecords([$tenantADelivery])
|
||||
->assertCanNotSeeTableRecords([$tenantBDelivery]);
|
||||
->assertCanSeeTableRecords([$tenantADelivery, $tenantBDelivery]);
|
||||
});
|
||||
|
||||
it('preselects the tenant filter when a tenant context exists', function (): void {
|
||||
it('does not preselect the tenant filter when a tenant context exists on the workspace-wide alert delivery list', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListAlertDeliveries::class)
|
||||
->assertSet('tableFilters.managed_environment_id.value', (string) $tenant->getKey());
|
||||
Livewire::withHeaders(['referer' => AlertDeliveryResource::getUrl(panel: 'admin')])
|
||||
->test(ListAlertDeliveries::class)
|
||||
->assertSet('tableFilters.managed_environment_id.value', null);
|
||||
});
|
||||
|
||||
it('scopes alert deliveries to the remembered tenant context when filament tenant is absent', function (): void {
|
||||
it('keeps alert deliveries workspace-wide when only remembered tenant context exists', function (): void {
|
||||
$tenantA = ManagedEnvironment::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
@ -306,8 +307,8 @@ function getAlertDeliveryHeaderAction(Testable $component, string $name): ?Actio
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => $workspaceId]);
|
||||
|
||||
Livewire::test(ListAlertDeliveries::class)
|
||||
->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey())
|
||||
->assertCanSeeTableRecords([$tenantADelivery])
|
||||
->assertCanNotSeeTableRecords([$tenantBDelivery]);
|
||||
Livewire::withHeaders(['referer' => AlertDeliveryResource::getUrl(panel: 'admin')])
|
||||
->test(ListAlertDeliveries::class)
|
||||
->assertSet('tableFilters.managed_environment_id.value', null)
|
||||
->assertCanSeeTableRecords([$tenantADelivery, $tenantBDelivery]);
|
||||
});
|
||||
|
||||
@ -24,7 +24,7 @@ function alertsKpiValues($component): array
|
||||
->all();
|
||||
}
|
||||
|
||||
it('filters KPI deliveries by tenant when context is set via lastTenantId fallback only', function (): void {
|
||||
it('shows workspace-wide KPI deliveries when context is set via lastTenantId fallback only', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$workspaceId = (int) $tenant->workspace_id;
|
||||
@ -63,15 +63,15 @@ function alertsKpiValues($component): array
|
||||
(string) $workspaceId => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$values = alertsKpiValues(Livewire::test(AlertsKpiHeader::class));
|
||||
$values = alertsKpiValues(Livewire::withHeaders(['referer' => route('filament.admin.alerts')])->test(AlertsKpiHeader::class));
|
||||
|
||||
expect($values)->toMatchArray([
|
||||
'Deliveries (24h)' => '1',
|
||||
'Deliveries (24h)' => '2',
|
||||
'Failed (7d)' => '0',
|
||||
]);
|
||||
})->group('ops-ux');
|
||||
|
||||
it('filters KPI deliveries by tenant when context is set via Filament setTenant', function (): void {
|
||||
it('shows workspace-wide KPI deliveries when context is set via Filament setTenant', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$workspaceId = (int) $tenant->workspace_id;
|
||||
@ -107,10 +107,10 @@ function alertsKpiValues($component): array
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
||||
|
||||
$values = alertsKpiValues(Livewire::test(AlertsKpiHeader::class));
|
||||
$values = alertsKpiValues(Livewire::withHeaders(['referer' => route('filament.admin.alerts')])->test(AlertsKpiHeader::class));
|
||||
|
||||
expect($values)->toMatchArray([
|
||||
'Deliveries (24h)' => '1',
|
||||
'Deliveries (24h)' => '2',
|
||||
'Failed (7d)' => '0',
|
||||
]);
|
||||
})->group('ops-ux');
|
||||
@ -151,7 +151,7 @@ function alertsKpiValues($component): array
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
||||
|
||||
$values = alertsKpiValues(Livewire::test(AlertsKpiHeader::class));
|
||||
$values = alertsKpiValues(Livewire::withHeaders(['referer' => route('filament.admin.alerts')])->test(AlertsKpiHeader::class));
|
||||
|
||||
expect($values)->toMatchArray([
|
||||
'Deliveries (24h)' => '2',
|
||||
@ -159,7 +159,7 @@ function alertsKpiValues($component): array
|
||||
]);
|
||||
})->group('ops-ux');
|
||||
|
||||
it('prefers the Filament tenant over remembered tenant fallback in KPI scope conflicts', function (): void {
|
||||
it('keeps KPI deliveries workspace-wide when Filament and remembered tenant context conflict', function (): void {
|
||||
[$user, $tenantA] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$workspaceId = (int) $tenantA->workspace_id;
|
||||
@ -198,10 +198,10 @@ function alertsKpiValues($component): array
|
||||
(string) $workspaceId => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
$values = alertsKpiValues(Livewire::test(AlertsKpiHeader::class));
|
||||
$values = alertsKpiValues(Livewire::withHeaders(['referer' => route('filament.admin.alerts')])->test(AlertsKpiHeader::class));
|
||||
|
||||
expect($values)->toMatchArray([
|
||||
'Deliveries (24h)' => '1',
|
||||
'Deliveries (24h)' => '2',
|
||||
'Failed (7d)' => '1',
|
||||
]);
|
||||
})->group('ops-ux');
|
||||
|
||||
@ -18,7 +18,9 @@ function auditLogPageTestComponent(User $user, ?ManagedEnvironment $tenant = nul
|
||||
test()->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
return Livewire::actingAs($user)->test(AuditLogPage::class);
|
||||
return Livewire::withHeaders(['referer' => route('admin.monitoring.audit-log')])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class);
|
||||
}
|
||||
|
||||
function auditLogPageTestRecord(?ManagedEnvironment $tenant, array $attributes = []): AuditLogModel
|
||||
@ -179,7 +181,7 @@ function auditLogPageTestRecord(?ManagedEnvironment $tenant, array $attributes =
|
||||
->assertCanNotSeeTableRecords([$newest, $oldest]);
|
||||
});
|
||||
|
||||
it('preselects the active tenant as the default audit filter', function (): void {
|
||||
it('keeps the audit log unfiltered when an active tenant context exists', function (): void {
|
||||
[$user, $tenantA] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$tenantB = ManagedEnvironment::factory()->create([
|
||||
@ -188,25 +190,24 @@ function auditLogPageTestRecord(?ManagedEnvironment $tenant, array $attributes =
|
||||
|
||||
createUserWithTenant($tenantB, $user, role: 'owner');
|
||||
|
||||
$visible = auditLogPageTestRecord($tenantA, [
|
||||
$tenantARecord = auditLogPageTestRecord($tenantA, [
|
||||
'resource_id' => '201',
|
||||
'summary' => 'ManagedEnvironment A verification completed',
|
||||
'action' => 'verification.completed',
|
||||
]);
|
||||
|
||||
$hidden = auditLogPageTestRecord($tenantB, [
|
||||
$tenantBRecord = auditLogPageTestRecord($tenantB, [
|
||||
'resource_id' => '202',
|
||||
'summary' => 'ManagedEnvironment B verification completed',
|
||||
'action' => 'verification.completed',
|
||||
]);
|
||||
|
||||
auditLogPageTestComponent($user, $tenantA)
|
||||
->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey())
|
||||
->assertCanSeeTableRecords([$visible])
|
||||
->assertCanNotSeeTableRecords([$hidden]);
|
||||
->assertSet('tableFilters.managed_environment_id.value', null)
|
||||
->assertCanSeeTableRecords([$tenantARecord, $tenantBRecord]);
|
||||
});
|
||||
|
||||
it('preselects the remembered tenant as the default audit filter when the filament tenant is absent', function (): void {
|
||||
it('keeps the audit log unfiltered when only a remembered tenant context exists', function (): void {
|
||||
$tenantA = ManagedEnvironment::factory()->create([
|
||||
'name' => 'Phoenicon',
|
||||
'environment' => 'dev',
|
||||
@ -221,13 +222,13 @@ function auditLogPageTestRecord(?ManagedEnvironment $tenant, array $attributes =
|
||||
|
||||
createUserWithTenant($tenantB, $user, role: 'owner');
|
||||
|
||||
$visible = auditLogPageTestRecord($tenantA, [
|
||||
$tenantARecord = auditLogPageTestRecord($tenantA, [
|
||||
'resource_id' => '301',
|
||||
'summary' => 'Phoenicon verification completed',
|
||||
'action' => 'verification.completed',
|
||||
]);
|
||||
|
||||
$hidden = auditLogPageTestRecord($tenantB, [
|
||||
$tenantBRecord = auditLogPageTestRecord($tenantB, [
|
||||
'resource_id' => '302',
|
||||
'summary' => 'YPTW2 verification completed',
|
||||
'action' => 'verification.completed',
|
||||
@ -241,13 +242,14 @@ function auditLogPageTestRecord(?ManagedEnvironment $tenant, array $attributes =
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||
->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey())
|
||||
->assertCanSeeTableRecords([$visible])
|
||||
->assertCanNotSeeTableRecords([$hidden]);
|
||||
Livewire::withHeaders(['referer' => route('admin.monitoring.audit-log')])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertSet('tableFilters.managed_environment_id.value', null)
|
||||
->assertCanSeeTableRecords([$tenantARecord, $tenantBRecord]);
|
||||
});
|
||||
|
||||
it('replaces a stale persisted audit tenant filter when the remembered tenant context changes', function (): void {
|
||||
it('clears a stale persisted audit tenant filter when the workspace shell is tenantless', function (): void {
|
||||
$tenantA = ManagedEnvironment::factory()->create([
|
||||
'name' => 'YPTW2',
|
||||
'environment' => 'dev',
|
||||
@ -282,22 +284,24 @@ function auditLogPageTestRecord(?ManagedEnvironment $tenant, array $attributes =
|
||||
(string) $workspaceId => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||
->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey())
|
||||
->assertCanSeeTableRecords([$tenantARecord])
|
||||
->assertCanNotSeeTableRecords([$tenantBRecord]);
|
||||
$component = Livewire::withHeaders(['referer' => route('admin.monitoring.audit-log')])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertSet('tableFilters.managed_environment_id.value', null)
|
||||
->assertCanSeeTableRecords([$tenantARecord, $tenantBRecord]);
|
||||
|
||||
expect(data_get(session()->get($component->instance()->getTableFiltersSessionKey()), 'managed_environment_id.value'))
|
||||
->toBe((string) $tenantA->getKey());
|
||||
->toBeNull();
|
||||
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $workspaceId => (int) $tenantB->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||
->assertSet('tableFilters.managed_environment_id.value', (string) $tenantB->getKey())
|
||||
->assertCanSeeTableRecords([$tenantBRecord])
|
||||
->assertCanNotSeeTableRecords([$tenantARecord]);
|
||||
Livewire::withHeaders(['referer' => route('admin.monitoring.audit-log')])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertSet('tableFilters.managed_environment_id.value', null)
|
||||
->assertCanSeeTableRecords([$tenantARecord, $tenantBRecord]);
|
||||
});
|
||||
|
||||
it('shows a clear-filters empty state when no audit rows match the current view', function (): void {
|
||||
|
||||
@ -3,7 +3,10 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Pages\Governance\DecisionRegister;
|
||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||
use App\Filament\Pages\InventoryCoverage;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
@ -14,9 +17,18 @@
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\FindingExceptionDecision;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Support\ManagedEnvironmentLinks;
|
||||
use App\Support\Navigation\NavigationScope;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Navigation\NavigationGroup;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Filament\Navigation\NavigationManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@ -39,6 +51,18 @@
|
||||
FindingResource::class,
|
||||
]);
|
||||
|
||||
dataset('workspace surface paths with environment query hints', [
|
||||
'/admin/workspaces/workspace-alpha/operations?tenant=environment-alpha',
|
||||
'/admin/reviews/workspace?tenant=environment-alpha',
|
||||
'/admin/governance/decisions?managed_environment_id=environment-alpha',
|
||||
'/admin/governance/inbox?managed_environment_id=environment-alpha',
|
||||
'/admin/evidence/overview?managed_environment_id=environment-alpha',
|
||||
'/admin/audit-log?tenant=environment-alpha',
|
||||
'/admin/provider-connections?managed_environment_id=environment-alpha',
|
||||
'/admin/alerts?tenant=environment-alpha',
|
||||
'/admin/workspaces/workspace-alpha/overview?tenant=environment-alpha',
|
||||
]);
|
||||
|
||||
function bindNavigationRequestPath(string $path): void
|
||||
{
|
||||
$request = Request::create($path);
|
||||
@ -49,6 +73,53 @@ function bindNavigationRequestPath(string $path): void
|
||||
app()->instance('request', $request);
|
||||
}
|
||||
|
||||
function workspaceSidebarLabelsByGroup(): array
|
||||
{
|
||||
return collect(app(NavigationManager::class)->get())
|
||||
->mapWithKeys(static function (NavigationGroup $group): array {
|
||||
return [
|
||||
$group->getLabel() ?? '' => collect($group->getItems())
|
||||
->map(static fn (NavigationItem $item): string => $item->getLabel())
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
function seedWorkspaceSidebarVisibleDecision(ManagedEnvironment $tenant, User $actor): void
|
||||
{
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$exception = FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $actor->getKey(),
|
||||
'owner_user_id' => (int) $actor->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Workspace sidebar composition contract',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$decision = $exception->decisions()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'actor_user_id' => (int) $actor->getKey(),
|
||||
'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
|
||||
'reason' => 'Visible workspace sidebar decision',
|
||||
'metadata' => [],
|
||||
'decided_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
|
||||
}
|
||||
|
||||
it('hides environment-owned navigation classes on workspace surfaces', function (string $class): void {
|
||||
Filament::setCurrentPanel('admin');
|
||||
bindNavigationRequestPath('/admin/workspaces/workspace-alpha');
|
||||
@ -56,6 +127,119 @@ function bindNavigationRequestPath(string $path): void
|
||||
expect($class::shouldRegisterNavigation())->toBeFalse();
|
||||
})->with('environment visible navigation classes');
|
||||
|
||||
it('keeps workspace surface navigation independent from environment query hints', function (string $path): void {
|
||||
Filament::setCurrentPanel('admin');
|
||||
bindNavigationRequestPath($path);
|
||||
|
||||
expect(NavigationScope::isWorkspaceSurface())->toBeTrue()
|
||||
->and(NavigationScope::isEnvironmentSurface())->toBeFalse();
|
||||
})->with('workspace surface paths with environment query hints');
|
||||
|
||||
it('uses the canonical grouped workspace sidebar on representative workspace-wide surfaces', function (string $surface, callable $urlFactory): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
seedWorkspaceSidebarVisibleDecision($tenant, $user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$workspace = $tenant->workspace()->firstOrFail();
|
||||
$url = $urlFactory($workspace, $tenant);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $workspace->getKey() => (int) $tenant->getKey(),
|
||||
],
|
||||
])
|
||||
->get($url)
|
||||
->assertOk();
|
||||
|
||||
expect(workspaceSidebarLabelsByGroup())->toBe([
|
||||
'' => ['Overview'],
|
||||
'Monitoring' => ['Finding exceptions', 'Operations', 'Alerts', 'Audit Log'],
|
||||
'Reporting' => ['Reviews', 'Customer reviews'],
|
||||
'Settings' => ['Manage workspaces', 'Integrations', 'Settings'],
|
||||
'Governance' => ['Governance inbox', 'Decision register'],
|
||||
]);
|
||||
})->with([
|
||||
'workspace overview' => [
|
||||
'workspace overview',
|
||||
fn ($workspace): string => route('admin.workspace.home', ['workspace' => $workspace]),
|
||||
],
|
||||
'operations' => [
|
||||
'operations',
|
||||
fn ($workspace): string => route('admin.operations.index', ['workspace' => $workspace]),
|
||||
],
|
||||
'customer reviews' => [
|
||||
'customer reviews',
|
||||
fn (): string => CustomerReviewWorkspace::getUrl(panel: 'admin'),
|
||||
],
|
||||
'governance inbox' => [
|
||||
'governance inbox',
|
||||
fn (): string => GovernanceInbox::getUrl(panel: 'admin'),
|
||||
],
|
||||
'decision register' => [
|
||||
'decision register',
|
||||
fn (): string => DecisionRegister::getUrl(panel: 'admin'),
|
||||
],
|
||||
]);
|
||||
|
||||
it('keeps the grouped workspace sidebar when environment query filters are present', function (string $surface, callable $urlFactory): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
seedWorkspaceSidebarVisibleDecision($tenant, $user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$workspace = $tenant->workspace()->firstOrFail();
|
||||
$url = $urlFactory($workspace, $tenant);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $workspace->getKey() => (int) $tenant->getKey(),
|
||||
],
|
||||
])
|
||||
->get($url)
|
||||
->assertOk();
|
||||
|
||||
expect(workspaceSidebarLabelsByGroup())->toBe([
|
||||
'' => ['Overview'],
|
||||
'Monitoring' => ['Finding exceptions', 'Operations', 'Alerts', 'Audit Log'],
|
||||
'Reporting' => ['Reviews', 'Customer reviews'],
|
||||
'Settings' => ['Manage workspaces', 'Integrations', 'Settings'],
|
||||
'Governance' => ['Governance inbox', 'Decision register'],
|
||||
]);
|
||||
})->with([
|
||||
'operations with tenant query' => [
|
||||
'operations',
|
||||
fn ($workspace, ManagedEnvironment $tenant): string => route('admin.operations.index', [
|
||||
'workspace' => $workspace,
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
]),
|
||||
],
|
||||
'customer reviews with tenant query' => [
|
||||
'customer reviews',
|
||||
fn ($workspace, ManagedEnvironment $tenant): string => CustomerReviewWorkspace::getUrl(panel: 'admin', parameters: [
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
]),
|
||||
],
|
||||
'decision register with environment query' => [
|
||||
'decision register',
|
||||
fn ($workspace, ManagedEnvironment $tenant): string => DecisionRegister::getUrl(panel: 'admin', parameters: [
|
||||
'managed_environment_id' => (string) $tenant->getKey(),
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
it('keeps environment navigation on canonical environment routes even when query filters are present', function (): void {
|
||||
Filament::setCurrentPanel('admin');
|
||||
bindNavigationRequestPath('/admin/workspaces/workspace-alpha/environments/environment-alpha/inventory?tenant=other-environment');
|
||||
|
||||
expect(NavigationScope::isEnvironmentSurface())->toBeTrue()
|
||||
->and(NavigationScope::isWorkspaceSurface())->toBeFalse();
|
||||
});
|
||||
|
||||
it('registers environment-owned surfaces only on environment surfaces', function (string $class): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
|
||||
@ -66,7 +66,7 @@
|
||||
->assertDontSee('name="workspace_id"', escape: false);
|
||||
});
|
||||
|
||||
test('workspace-scoped operations honor a valid tenant query hint over remembered tenant context', function () {
|
||||
test('workspace-wide operations keep shell scope tenantless when a valid tenant query filter is present', function () {
|
||||
$rememberedTenant = ManagedEnvironment::factory()->create([
|
||||
'workspace_id' => null,
|
||||
'status' => 'active',
|
||||
@ -95,6 +95,8 @@
|
||||
])
|
||||
->get(route('admin.operations.index', ['workspace' => $workspaceId, 'managed_environment_id' => (int) $hintedTenant->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.environment_scope').': Hinted Topbar ManagedEnvironment')
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': Hinted Topbar ManagedEnvironment')
|
||||
->assertDontSee(__('localization.shell.environment_scope').': Remembered Topbar ManagedEnvironment');
|
||||
});
|
||||
|
||||
@ -129,7 +129,7 @@
|
||||
->assertSee('This tenant is currently onboarding');
|
||||
});
|
||||
|
||||
it('defaults the tenant filter from tenant context and can be cleared', function (): void {
|
||||
it('keeps tenant context out of the operations filter unless an explicit page query is present', function (): void {
|
||||
$tenantA = ManagedEnvironment::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||
|
||||
@ -165,23 +165,18 @@
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
Livewire::withHeaders(['referer' => route('admin.operations.index', ['workspace' => $tenantA->workspace])])
|
||||
->actingAs($user)
|
||||
->test(Operations::class)
|
||||
->assertCanSeeTableRecords([$runA, $runB])
|
||||
->assertSet('tableFilters.managed_environment_id.value', null);
|
||||
|
||||
Livewire::withHeaders(['referer' => route('admin.operations.index', ['workspace' => $tenantA->workspace])])
|
||||
->withQueryParams(['managed_environment_id' => (string) $tenantA->getKey()])
|
||||
->test(Operations::class)
|
||||
->assertCanSeeTableRecords([$runA])
|
||||
->assertCanNotSeeTableRecords([$runB])
|
||||
->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey());
|
||||
|
||||
$component
|
||||
->callAction('operate_hub_show_all_tenants')
|
||||
->assertSet('tableFilters.managed_environment_id.value', null)
|
||||
->assertRedirect(OperationRunLinks::index(allTenants: true));
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Operations::class)
|
||||
->assertSee('TenantA')
|
||||
->assertSee('TenantB');
|
||||
});
|
||||
|
||||
it('shows an explicit back-link when canonical context is present on the operations index', function (): void {
|
||||
|
||||
@ -27,7 +27,7 @@ function operationsKpiValues($component): array
|
||||
->all();
|
||||
}
|
||||
|
||||
it('filters operations KPI stats by remembered tenant when filament tenant is absent', function (): void {
|
||||
it('shows workspace-wide operations KPI stats when remembered tenant context exists', function (): void {
|
||||
$tenantA = ManagedEnvironment::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
@ -68,16 +68,18 @@ function operationsKpiValues($component): array
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
$values = operationsKpiValues(Livewire::test(OperationsKpiHeader::class));
|
||||
$values = operationsKpiValues(Livewire::withHeaders([
|
||||
'referer' => route('admin.operations.index', ['workspace' => $tenantA->workspace]),
|
||||
])->test(OperationsKpiHeader::class));
|
||||
|
||||
expect($values)->toMatchArray([
|
||||
'Total Operations (30 days)' => '2',
|
||||
'Active Operations' => '1',
|
||||
'Total Operations (30 days)' => '3',
|
||||
'Active Operations' => '2',
|
||||
'Failed/Partial (7 days)' => '1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers the filament tenant over remembered tenant in conflicting KPI context', function (): void {
|
||||
it('shows workspace-wide operations KPI stats when filament tenant context exists', function (): void {
|
||||
$tenantA = ManagedEnvironment::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
@ -111,11 +113,13 @@ function operationsKpiValues($component): array
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
$values = operationsKpiValues(Livewire::test(OperationsKpiHeader::class));
|
||||
$values = operationsKpiValues(Livewire::withHeaders([
|
||||
'referer' => route('admin.operations.index', ['workspace' => $tenantA->workspace]),
|
||||
])->test(OperationsKpiHeader::class));
|
||||
|
||||
expect($values)->toMatchArray([
|
||||
'Total Operations (30 days)' => '1',
|
||||
'Active Operations' => '0',
|
||||
'Total Operations (30 days)' => '2',
|
||||
'Active Operations' => '1',
|
||||
'Failed/Partial (7 days)' => '0',
|
||||
]);
|
||||
});
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('defaults Monitoring → Operations list to the active tenant when tenant context is set', function () {
|
||||
it('treats active tenant context as shell-only and filters operations only from explicit page query state', function () {
|
||||
$tenantA = ManagedEnvironment::factory()->create();
|
||||
$tenantB = ManagedEnvironment::factory()->create();
|
||||
|
||||
@ -41,7 +41,9 @@
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
Livewire::withHeaders(['referer' => route('admin.operations.index', ['workspace' => $tenantA->workspace])])
|
||||
->actingAs($user)
|
||||
->withQueryParams(['managed_environment_id' => (string) $tenantA->getKey()])
|
||||
->test(Operations::class)
|
||||
->assertCanSeeTableRecords([$runA])
|
||||
->assertCanNotSeeTableRecords([$runB])
|
||||
@ -52,10 +54,12 @@
|
||||
->get(\App\Support\OperationRunLinks::index())
|
||||
->assertOk()
|
||||
->assertSee('Policy sync')
|
||||
->assertSee($tenantA->name);
|
||||
->assertSee('Inventory sync')
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': '.$tenantA->name);
|
||||
});
|
||||
|
||||
it('defaults Monitoring → Operations list to the remembered tenant when Filament tenant is not available', function () {
|
||||
it('does not default Monitoring → Operations list to the remembered tenant', function () {
|
||||
$tenantA = ManagedEnvironment::factory()->create();
|
||||
$tenantB = ManagedEnvironment::factory()->create();
|
||||
|
||||
@ -88,21 +92,24 @@
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => $workspaceId]);
|
||||
session([WorkspaceContext::SESSION_KEY => $workspaceId]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
Livewire::withHeaders(['referer' => route('admin.operations.index', ['workspace' => $tenantA->workspace])])
|
||||
->actingAs($user)
|
||||
->test(Operations::class)
|
||||
->assertCanSeeTableRecords([$runA])
|
||||
->assertCanNotSeeTableRecords([$runB])
|
||||
->assertSet('tableFilters.managed_environment_id.value', (string) $tenantA->getKey());
|
||||
->assertCanSeeTableRecords([$runA, $runB])
|
||||
->assertSet('tableFilters.managed_environment_id.value', null);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => $workspaceId])
|
||||
->get(\App\Support\OperationRunLinks::index())
|
||||
->assertOk()
|
||||
->assertSee($tenantA->name)
|
||||
->assertSee('Policy sync');
|
||||
->assertSee('Policy sync')
|
||||
->assertSee('Inventory sync')
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': '.$tenantA->name);
|
||||
});
|
||||
|
||||
it('scopes Monitoring → Operations tabs to the active tenant', function () {
|
||||
it('scopes Monitoring → Operations tabs to the workspace unless an explicit page filter is active', function () {
|
||||
$tenantA = ManagedEnvironment::factory()->create();
|
||||
$tenantB = ManagedEnvironment::factory()->create();
|
||||
|
||||
@ -185,19 +192,19 @@
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
Livewire::withHeaders(['referer' => route('admin.operations.index', ['workspace' => $tenantA->workspace])])
|
||||
->actingAs($user)
|
||||
->test(Operations::class)
|
||||
->assertCanSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runPartialA, $runBlockedA, $runFailedA])
|
||||
->assertCanNotSeeTableRecords([$runActiveB, $runFailedB])
|
||||
->assertCanSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB])
|
||||
->set('activeTab', 'active')
|
||||
->assertCanSeeTableRecords([$runActiveA])
|
||||
->assertCanNotSeeTableRecords([$runStaleA, $runSucceededA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB])
|
||||
->assertCanSeeTableRecords([$runActiveA, $runActiveB])
|
||||
->assertCanNotSeeTableRecords([$runStaleA, $runSucceededA, $runPartialA, $runBlockedA, $runFailedA, $runFailedB])
|
||||
->set('activeTab', OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION)
|
||||
->assertCanSeeTableRecords([$runStaleA])
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB])
|
||||
->set('activeTab', OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
|
||||
->assertCanSeeTableRecords([$runPartialA, $runBlockedA, $runFailedA])
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runActiveB, $runFailedB])
|
||||
->assertCanSeeTableRecords([$runPartialA, $runBlockedA, $runFailedA, $runFailedB])
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runActiveB])
|
||||
->set('activeTab', 'succeeded')
|
||||
->assertCanSeeTableRecords([$runSucceededA])
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runStaleA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB])
|
||||
@ -205,8 +212,8 @@
|
||||
->assertCanSeeTableRecords([$runPartialA])
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB])
|
||||
->set('activeTab', 'failed')
|
||||
->assertCanSeeTableRecords([$runFailedA])
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runPartialA, $runBlockedA, $runActiveB, $runFailedB]);
|
||||
->assertCanSeeTableRecords([$runFailedA, $runFailedB])
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runStaleA, $runSucceededA, $runPartialA, $runBlockedA, $runActiveB]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
|
||||
@ -232,7 +232,7 @@
|
||||
->assertDontSee('Accepted risk influences this view');
|
||||
});
|
||||
|
||||
it('defaults the customer review workspace to the remembered tenant when tenant context is available', function (): void {
|
||||
it('keeps the customer review workspace unfiltered when remembered tenant context is available', function (): void {
|
||||
$tenantA = ManagedEnvironment::factory()->create(['name' => 'Alpha ManagedEnvironment']);
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly');
|
||||
|
||||
@ -268,7 +268,8 @@
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(CustomerReviewWorkspace::class)
|
||||
->assertSet('tableFilters.managed_environment_id.value', (string) $tenantB->getKey())
|
||||
->assertSet('tableFilters.managed_environment_id.value', null)
|
||||
->assertCanSeeTableRecords([$tenantA->fresh(), $tenantB->fresh()])
|
||||
->filterTable('managed_environment_id', (string) $tenantB->getKey())
|
||||
->assertCanSeeTableRecords([$tenantB->fresh()])
|
||||
->assertCanNotSeeTableRecords([$tenantA->fresh()]);
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('renders tenant scope label and CTAs when tenant context is active and entitled', function (): void {
|
||||
it('renders workspace scope label when tenant context is active on the workspace operations route', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
@ -24,9 +24,11 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.environment_scope').': '.$tenant->name)
|
||||
->assertSee('Back to '.$tenant->name)
|
||||
->assertSee(__('localization.shell.show_all_environments'));
|
||||
->assertSee(__('localization.shell.all_environments'))
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': '.$tenant->name)
|
||||
->assertDontSee('Back to '.$tenant->name)
|
||||
->assertDontSee(__('localization.shell.show_all_environments'));
|
||||
});
|
||||
|
||||
it('treats stale tenant context as workspace-wide without tenant identity hints', function (): void {
|
||||
|
||||
@ -2,10 +2,15 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Governance\DecisionRegister;
|
||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||
use App\Filament\Pages\EnvironmentDashboard;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@ -41,3 +46,91 @@
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': Rejected Foreign ManagedEnvironment');
|
||||
});
|
||||
|
||||
it('keeps workspace-wide surfaces tenantless when valid environment query filters are present', function (string $surface, callable $urlFactory): void {
|
||||
$rememberedTenant = ManagedEnvironment::factory()->active()->create([
|
||||
'name' => 'Remembered ManagedEnvironment',
|
||||
'external_id' => 'remembered-managed-environment',
|
||||
]);
|
||||
[$user, $rememberedTenant] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner');
|
||||
|
||||
$hintedTenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $rememberedTenant->workspace_id,
|
||||
'name' => 'Hinted ManagedEnvironment',
|
||||
'external_id' => 'hinted-managed-environment',
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $hintedTenant, user: $user, role: 'owner');
|
||||
|
||||
Filament::setTenant($rememberedTenant, true);
|
||||
|
||||
$workspace = $rememberedTenant->workspace()->firstOrFail();
|
||||
$url = $urlFactory($workspace, $hintedTenant);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $workspace->getKey() => (int) $rememberedTenant->getKey(),
|
||||
],
|
||||
])
|
||||
->followingRedirects()
|
||||
->get($url)
|
||||
->assertOk()
|
||||
->assertSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee(__('localization.shell.environment_scope').': Hinted ManagedEnvironment')
|
||||
->assertDontSee(__('localization.shell.environment_scope').': Remembered ManagedEnvironment')
|
||||
->assertDontSee('Back to Hinted ManagedEnvironment')
|
||||
->assertDontSee('Back to Remembered ManagedEnvironment');
|
||||
})->with([
|
||||
'operations' => [
|
||||
'operations',
|
||||
fn ($workspace, ManagedEnvironment $tenant): string => route('admin.operations.index', [
|
||||
'workspace' => $workspace,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
]),
|
||||
],
|
||||
'customer review workspace' => [
|
||||
'customer review workspace',
|
||||
fn ($workspace, ManagedEnvironment $tenant): string => CustomerReviewWorkspace::getUrl(panel: 'admin', parameters: [
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
]),
|
||||
],
|
||||
'decision register' => [
|
||||
'decision register',
|
||||
fn ($workspace, ManagedEnvironment $tenant): string => DecisionRegister::getUrl(panel: 'admin', parameters: [
|
||||
'managed_environment_id' => (string) $tenant->getKey(),
|
||||
]),
|
||||
],
|
||||
'governance inbox' => [
|
||||
'governance inbox',
|
||||
fn ($workspace, ManagedEnvironment $tenant): string => GovernanceInbox::getUrl(panel: 'admin', parameters: [
|
||||
'managed_environment_id' => (string) $tenant->getKey(),
|
||||
]),
|
||||
],
|
||||
'audit log' => [
|
||||
'audit log',
|
||||
fn ($workspace, ManagedEnvironment $tenant): string => route('admin.monitoring.audit-log', [
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
]),
|
||||
],
|
||||
'provider connections' => [
|
||||
'provider connections',
|
||||
fn ($workspace, ManagedEnvironment $tenant): string => ProviderConnectionResource::getUrl('index', [
|
||||
'managed_environment_id' => (string) $tenant->external_id,
|
||||
], panel: 'admin'),
|
||||
],
|
||||
'alerts' => [
|
||||
'alerts',
|
||||
fn ($workspace, ManagedEnvironment $tenant): string => route('filament.admin.alerts', [
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
]),
|
||||
],
|
||||
'workspace overview' => [
|
||||
'workspace overview',
|
||||
fn ($workspace, ManagedEnvironment $tenant): string => route('admin.workspace.home', [
|
||||
'workspace' => $workspace,
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
@ -17,10 +17,16 @@
|
||||
'retired tenant panel route' => ['/admin/t/tenant-123', TenantPageCategory::WorkspaceScoped],
|
||||
'workspace environment detail' => ['/admin/workspaces/acme/environments/tenant-123', TenantPageCategory::TenantBound],
|
||||
'tenant scoped evidence detail' => ['/admin/evidence/123', TenantPageCategory::TenantScopedEvidence],
|
||||
'evidence overview' => ['/admin/evidence/overview', TenantPageCategory::WorkspaceScoped],
|
||||
'evidence overview' => ['/admin/evidence/overview', TenantPageCategory::WorkspaceWideSurface],
|
||||
'customer review workspace' => ['/admin/reviews/workspace', TenantPageCategory::WorkspaceWideSurface],
|
||||
'review register' => ['/admin/reviews', TenantPageCategory::WorkspaceWideSurface],
|
||||
'governance decisions' => ['/admin/governance/decisions', TenantPageCategory::WorkspaceWideSurface],
|
||||
'alerts' => ['/admin/alerts', TenantPageCategory::WorkspaceWideSurface],
|
||||
'provider connections' => ['/admin/provider-connections', TenantPageCategory::WorkspaceWideSurface],
|
||||
'workspace home overview' => ['/admin/workspaces/acme/overview', TenantPageCategory::WorkspaceWideSurface],
|
||||
'onboarding index' => ['/admin/onboarding', TenantPageCategory::OnboardingWorkflow],
|
||||
'onboarding draft' => ['/admin/onboarding/42', TenantPageCategory::OnboardingWorkflow],
|
||||
'operations index' => ['/admin/workspaces/acme/operations', TenantPageCategory::WorkspaceScoped],
|
||||
'operations index' => ['/admin/workspaces/acme/operations', TenantPageCategory::WorkspaceWideSurface],
|
||||
'retired operation run detail' => ['/admin/operations/44', TenantPageCategory::WorkspaceScoped],
|
||||
'operation run detail' => ['/admin/workspaces/acme/operations/44', TenantPageCategory::CanonicalWorkspaceRecordViewer],
|
||||
]);
|
||||
|
||||
189
specs/311-workspace-environment-surface-scope-contract/plan.md
Normal file
189
specs/311-workspace-environment-surface-scope-contract/plan.md
Normal file
@ -0,0 +1,189 @@
|
||||
# Implementation Plan: Workspace / Environment Surface Scope Contract
|
||||
|
||||
**Branch**: `311-workspace-environment-surface-scope-contract` | **Date**: 2026-05-15 | **Spec**: `specs/311-workspace-environment-surface-scope-contract/spec.md`
|
||||
**Input**: Feature specification from `/specs/311-workspace-environment-surface-scope-contract/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Implement the global admin surface scope contract: route scope controls shell/navigation, page filters control data. Explicit workspace-wide surfaces remain tenantless in the shell even with query filters or remembered/Filament tenant context. Canonical environment routes remain environment-bound. Legacy tenant-owned admin lists keep their existing data scoping until a separate route cutover handles them.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4, Laravel 12, Filament v5, Livewire v4
|
||||
**Primary Dependencies**: Existing Filament panel, `TenantPageCategory`, `NavigationScope`, `OperateHubShell`, `CanonicalAdminTenantFilterState`
|
||||
**Storage**: No database changes
|
||||
**Testing**: Pest v4 / Livewire component tests / HTTP feature tests
|
||||
**Validation Lanes**: focused feature/unit tests, Pint dirty, `git diff --check`
|
||||
**Target Platform**: TenantPilot admin panel
|
||||
**Project Type**: Laravel monorepo
|
||||
**Performance Goals**: No extra database work beyond existing widget/list queries
|
||||
**Constraints**: No migrations, no RBAC changes, no Customer Review Workspace product completion, no broad navigation rebuild, no sidebar query magic
|
||||
**Scale/Scope**: Representative workspace-wide surfaces, canonical environment routes, and legacy tenant-owned regression coverage
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: shell scope, topbar labels, sidebar classification, and table/widget filter defaults.
|
||||
- **Native vs custom classification summary**: Filament-native pages/resources/widgets only; no custom styling.
|
||||
- **Shared-family relevance**: global admin navigation shell and workspace-wide monitoring/governance/reporting surfaces.
|
||||
- **State layers in scope**: route path, query filter values, table filter session state, remembered tenant session, Filament tenant context.
|
||||
- **Audience modes in scope**: workspace operator/admin only.
|
||||
- **Decision/diagnostic/raw hierarchy plan**: N/A.
|
||||
- **Raw/support gating plan**: N/A.
|
||||
- **One-primary-action / duplicate-truth control**: prevent duplicate truth by making shell route-owned and filters page-owned.
|
||||
- **Handling modes by drift class or surface**: explicit workspace-wide surfaces become tenantless shell; canonical environment routes remain tenant-bound; legacy tenant-owned lists remain current behavior.
|
||||
- **Repository-signal treatment**: test-first red/green on representative surfaces and parity tests.
|
||||
- **Special surface test profiles**: Livewire tests must set a realistic `referer` when asserting route-derived shell behavior.
|
||||
- **Required tests or manual smoke**: focused Pest/Livewire tests; browser smoke not required because no styling/layout change.
|
||||
- **Exception path and spread control**: future workspace-wide surfaces must be added to `TenantPageCategory` and covered by the surface matrix.
|
||||
- **Active feature PR close-out entry**: Workspace / Environment Surface Scope Contract.
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: admin shell/navigation contract.
|
||||
- **Systems touched**: route taxonomy, shell context, workspace-wide filters, monitoring/alerts widgets.
|
||||
- **Shared abstractions reused**: `TenantPageCategory`, `TenantInteractionLane`, `OperateHubShell`, `NavigationScope`, `CanonicalAdminTenantFilterState`.
|
||||
- **New abstraction introduced? why?**: No new service or registry. Existing enum gains `WorkspaceWideSurface`.
|
||||
- **Why the existing abstraction was sufficient or insufficient**: The existing taxonomy already owns page category; it needed one extra category to separate explicit workspace-wide hubs from legacy workspace-scoped tenant-owned lists.
|
||||
- **Bounded deviation / spread control**: explicit path matching stays centralized in `TenantPageCategory`.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: Yes, Operations list/KPI scope only.
|
||||
- **Central contract reused**: `OperationRunLinks` and existing Operations page remain.
|
||||
- **Delegated UX behaviors**: no start/completion changes.
|
||||
- **Surface-owned behavior kept local**: Operations tabs/table query remain local.
|
||||
- **Queued DB-notification policy**: unchanged.
|
||||
- **Terminal notification path**: unchanged.
|
||||
- **Exception path**: none.
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: provider connections and alerts as workspace-wide/provider-level surfaces.
|
||||
- **Provider-owned seams**: Microsoft-specific inventory/policy/backup legacy tenant-owned lists remain out of this minimal cutover.
|
||||
- **Platform-core seams**: workspace, environment, provider connection, alert delivery, audit log, governance/workspace hubs.
|
||||
- **Neutral platform terms / contracts preserved**: workspace-wide, environment-bound, provider connection, environment filter.
|
||||
- **Retained provider-specific semantics and why**: legacy tenant-owned direct lists retain current tenant data context pending their own route migration.
|
||||
- **Bounded extraction or follow-up path**: 312 Customer Review Workspace v1 Completion follows after this contract; tenant-owned route cutover remains separate.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
- Inventory-first: PASS. No inventory truth changes.
|
||||
- Read/write separation: PASS. Read-only shell/list/widget behavior only.
|
||||
- Graph contract path: PASS. No Graph calls changed.
|
||||
- Deterministic capabilities: PASS. No capability mapping changes.
|
||||
- Workspace isolation: PASS. Workspace query scopes remain enforced.
|
||||
- Tenant isolation: PASS. Environment-bound routes and legacy tenant-owned lists remain protected.
|
||||
- Run observability: PASS. OperationRun data visibility remains entitlement-scoped.
|
||||
- Test governance: PASS. Focused tests plus parity regression; no new heavy family.
|
||||
- Proportionality: PASS. One enum category in an existing taxonomy is narrower than a registry or navigation rebuild.
|
||||
- No premature abstraction: PASS. No new service/interface/framework.
|
||||
- Persisted truth: PASS. No persisted runtime truth.
|
||||
- Behavioral state: PASS. Route category affects shell behavior only.
|
||||
- Shared pattern first: PASS. Existing shell/taxonomy path reused.
|
||||
- Provider boundary: PASS. Platform-core route contract stays provider-neutral.
|
||||
- Filament-native UI: PASS. No assets, no view publishing.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Feature tests for admin shell and Livewire pages/widgets; Unit test for route category classification.
|
||||
- **Affected validation lanes**: focused Feature/Unit lane.
|
||||
- **Why this lane mix is narrowest sufficient proof**: The bug is route/shell/filter behavior, provable by HTTP and Livewire component tests without browser layout verification.
|
||||
- **Narrowest proving command(s)**:
|
||||
- Focused contract and neighboring surface tests under `tests/Feature/...`
|
||||
- `tests/Unit/Tenants/TenantPageCategoryTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- `git diff --check`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: Reuses existing factories/helpers; no new heavy fixture family.
|
||||
- **Expensive defaults or shared helper growth introduced?**: no.
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none.
|
||||
- **Surface-class relief / special coverage rule**: Browser smoke not required; no visual layout/styling change.
|
||||
- **Closing validation and reviewer handoff**: Verify representative workspace-wide surfaces, environment-bound routes, and legacy tenant-owned parity tests.
|
||||
- **Budget / baseline / trend follow-up**: none expected.
|
||||
- **Review-stop questions**: any per-page shell exception, query-driven sidebar, RBAC change, migration, or Customer Review product logic.
|
||||
- **Escalation path**: remaining legacy route taxonomy questions belong to a follow-up route cutover/audit spec.
|
||||
- **Active feature PR close-out entry**: Workspace / Environment Surface Scope Contract.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
apps/platform/app/Support/Tenants/TenantPageCategory.php
|
||||
apps/platform/app/Support/Tenants/TenantInteractionLane.php
|
||||
apps/platform/app/Support/OperateHub/OperateHubShell.php
|
||||
apps/platform/app/Filament/Pages/Reviews/*
|
||||
apps/platform/app/Filament/Resources/AlertDeliveryResource.php
|
||||
apps/platform/app/Filament/Resources/ProviderConnectionResource.php
|
||||
apps/platform/app/Filament/Widgets/Alerts/AlertsKpiHeader.php
|
||||
apps/platform/app/Filament/Widgets/Operations/OperationsKpiHeader.php
|
||||
apps/platform/tests/Feature/**
|
||||
apps/platform/tests/Unit/Tenants/TenantPageCategoryTest.php
|
||||
specs/311-workspace-environment-surface-scope-contract/
|
||||
```
|
||||
|
||||
**Structure Decision**: Extend existing taxonomy and tests in place. Do not introduce a new surface registry.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|---|---|---|
|
||||
| Add enum category | Existing `WorkspaceScoped` combined explicit workspace hubs with legacy tenant-owned lists | Forcing all `WorkspaceScoped` tenantless regressed legacy tenant-owned resource data scoping; local page exceptions would spread |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Workspace hubs can display environment context in the shell while also showing page-level environment filters.
|
||||
- **Existing structure is insufficient because**: `WorkspaceScoped` was too broad for both workspace hubs and legacy tenant-owned direct lists.
|
||||
- **Narrowest correct implementation**: Add `WorkspaceWideSurface` to the existing category enum and force tenantless shell only there.
|
||||
- **Ownership cost created**: Future workspace-wide routes must be added centrally with tests.
|
||||
- **Alternative intentionally rejected**: CRW-only patch, `?tenant` sidebar switching, broad nav rewrite, or converting all legacy tenant-owned routes in this branch.
|
||||
- **Release truth**: Current platform shell contract.
|
||||
|
||||
## Phase 0: Read-Only Findings
|
||||
|
||||
- Operations already behaved correctly because it had explicit all-environments/page-filter semantics.
|
||||
- Customer Review Workspace, Review Register, Audit Log, Alerts, and Alert Delivery could inherit remembered/Filament tenant through shell or filter defaults.
|
||||
- Provider Connections defaulted its list filter from the resolved scoped tenant instead of explicit query state.
|
||||
- Legacy tenant-owned resource pages still rely on remembered tenant data context; this branch preserves them and protects them with parity tests.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `TenantPageCategory::fromRequest()` now uses Livewire referer paths for Livewire update requests so shell resolution matches browser route scope.
|
||||
- `WorkspaceWideSurface` covers explicit workspace-wide admin surfaces, including Operations, Reviews workspace/register, Governance Inbox/Decision Register, Evidence Overview, Audit Log, Provider Connections, Alerts, and Workspace Overview.
|
||||
- `OperateHubShell` returns tenantless workspace context before query/Filament/remembered tenant resolution for `WorkspaceWideSurface`.
|
||||
- Customer Review Workspace and Review Register no longer default their environment filters from remembered tenant and no longer clear global last-tenant state when clearing page filters.
|
||||
- Alert Delivery list and Alerts KPI are workspace-wide and no longer use shell tenant as data default.
|
||||
- Operations KPI now counts the workspace entitlement scope when the shell is tenantless.
|
||||
- Provider Connection list filter defaults only from explicit request state, not remembered/global context.
|
||||
|
||||
## Validation Plan
|
||||
|
||||
Run the focused contract and neighboring tests, then Pint and whitespace checks:
|
||||
|
||||
```bash
|
||||
cd apps/platform
|
||||
./vendor/bin/sail artisan test --compact <focused files>
|
||||
./vendor/bin/sail bin pint --dirty --format agent
|
||||
cd ../..
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Implementation Close-Out
|
||||
|
||||
- **Changed runtime areas**: route taxonomy, shell resolution, workspace-wide page filter defaults, Operations KPI, Alerts KPI/list, Provider Connections filter default.
|
||||
- **Changed tests**: focused shell/navigation, Operations, Customer Review Workspace, Review Register, Audit Log, Alerts, Provider Connections, tenant-owned parity, and route category coverage.
|
||||
- **Validation completed**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact ...` for the focused 311 selection: 172 passed, 703 assertions.
|
||||
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`: pass.
|
||||
- `git diff --check`: pass.
|
||||
- **No migrations**: confirmed.
|
||||
- **No RBAC changes**: confirmed.
|
||||
- **No Customer Review Workspace product logic**: confirmed; only shell/filter-scope behavior changed.
|
||||
- **No asset changes**: confirmed.
|
||||
- **Follow-up**: 312 can continue Customer Review Workspace v1 Completion against this shell contract.
|
||||
|
||||
## Filament v5 Output Contract
|
||||
|
||||
1. **Livewire v4.0+ compliance**: This app uses Livewire v4 with Filament v5; tests mount Filament Livewire pages/widgets.
|
||||
2. **Provider registration**: No panel provider registration changes. Laravel 11+/12 provider registration remains in `apps/platform/bootstrap/providers.php`.
|
||||
3. **Global search**: No globally searchable resource behavior changed. Provider Connection remains globally searchable disabled; Alert Delivery changes do not enable search.
|
||||
4. **Destructive actions**: No destructive actions added or changed.
|
||||
5. **Assets**: No assets added or changed; `filament:assets` deployment process unchanged.
|
||||
6. **Testing**: HTTP feature tests, Livewire component tests, and route category unit tests cover the contract.
|
||||
134
specs/311-workspace-environment-surface-scope-contract/spec.md
Normal file
134
specs/311-workspace-environment-surface-scope-contract/spec.md
Normal file
@ -0,0 +1,134 @@
|
||||
# Feature Specification: Workspace / Environment Surface Scope Contract
|
||||
|
||||
**Feature Branch**: `311-workspace-environment-surface-scope-contract`
|
||||
**Created**: 2026-05-15
|
||||
**Status**: Draft
|
||||
**Input**: User decision: split former mixed 311 work so 311 delivers the global workspace/environment navigation-scope contract and 312 delivers Customer Review Workspace v1 completion.
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Workspace-wide admin surfaces can inherit global or remembered environment context, causing topbar/sidebar/breadcrumb to imply an environment-bound route while the page is actually workspace-wide with an environment filter.
|
||||
- **Today's failure**: Customer Review Workspace can show `workspace > environment` in the shell and an active environment table filter at the same time. The same pattern can recur in Operations, Decision Register, Governance Inbox, Evidence Overview, Audit Log, Provider Connections, and Alerts if scope is solved page-by-page.
|
||||
- **User-visible improvement**: Operators can distinguish route context from data filters: workspace-wide pages keep workspace navigation, and environment filters remain page-level controls.
|
||||
- **Smallest enterprise-capable version**: A central route-taxonomy contract plus focused tests for representative workspace-wide, environment-bound, and legacy tenant-owned surfaces.
|
||||
- **Explicit non-goals**: No Customer Review Workspace product completion, Review Pack changes, RBAC changes, migrations, new tables, broad navigation rebuild, billing, localization, artifact lifecycle, PSA, or AI work.
|
||||
- **Permanent complexity imported**: One narrow route-category classification for explicit workspace-wide admin surfaces.
|
||||
- **Why now**: Continuing Customer Review Workspace v1 would otherwise encode a shell/sidebar exception into one page and repeat the bug on the next workspace hub.
|
||||
- **Why not local**: A local CRW fix would not protect Decision Register, Governance Inbox, Audit Log, Provider Connections, Alerts, or future workspace-wide hubs.
|
||||
- **Approval class**: Core Enterprise.
|
||||
- **Red flags triggered**: Cross-cutting taxonomy/classification. Covered by proportionality review below.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: platform admin shell, route taxonomy, topbar/sidebar scope, and page-level environment filter defaults.
|
||||
- **Primary Routes**: `/admin/workspaces/{workspace}/operations`, `/admin/reviews/workspace`, `/admin/reviews`, `/admin/governance/decisions`, `/admin/governance/inbox`, `/admin/evidence/overview`, `/admin/audit-log`, `/admin/provider-connections`, `/admin/alerts`, `/admin/workspaces/{workspace}/overview`, and `/admin/workspaces/{workspace}/environments/{environment}/...`.
|
||||
- **Data Ownership**: No ownership model changes. Workspace-wide surfaces aggregate workspace-authorized data; environment-bound routes remain canonical environment context.
|
||||
- **RBAC**: No RBAC capability changes. Existing server-side authorization remains the security boundary.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Explicit workspace-wide surfaces must not default their page filter from Filament tenant or remembered tenant context. Query parameters may set page-level filters when the page already supports that filter.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace and environment entitlement checks continue to apply. UI shell scope is not authorization.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
|
||||
|
||||
- **Cross-cutting feature?**: yes, admin shell and navigation scope.
|
||||
- **Interaction class(es)**: topbar context, sidebar registration, page-level filters, header KPI/list widgets.
|
||||
- **Systems touched**: `TenantPageCategory`, `TenantInteractionLane`, `OperateHubShell`, Operations KPI, Alert Delivery list/KPI, Provider Connection filter default, Review Register, Customer Review Workspace, and focused tests.
|
||||
- **Existing pattern(s) to extend**: existing `TenantPageCategory`, `NavigationScope`, `OperateHubShell`, and `CanonicalAdminTenantFilterState`.
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: reuse route taxonomy and shell context resolution; no new service or registry.
|
||||
- **Why the existing shared path is sufficient or insufficient**: Existing taxonomy distinguishes tenant-bound and workspace-scoped routes but did not distinguish explicit workspace-wide hubs from legacy tenant-owned admin lists.
|
||||
- **Allowed deviation and why**: Add one route-category case for explicit workspace-wide surfaces. This is narrower than a new contract registry.
|
||||
- **Consistency impact**: Query parameters must not alter navigation scope. Page filters must stay visible as filters.
|
||||
- **Review focus**: Verify no per-page hardcoded shell exception and no `?tenant` sidebar magic.
|
||||
|
||||
## OperationRun UX Impact *(mandatory)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: Operations list and KPI shell/filter behavior only.
|
||||
- **Shared OperationRun UX contract/layer reused**: Existing Operations page and `OperationRunLinks` remain canonical.
|
||||
- **Delegated start/completion UX behaviors**: unchanged.
|
||||
- **Local surface-owned behavior that remains**: Operations table query and tabs remain page-owned.
|
||||
- **Queued DB-notification policy**: unchanged.
|
||||
- **Terminal notification path**: unchanged.
|
||||
- **Exception required?**: none.
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes, provider-neutral admin surface terminology.
|
||||
- **Boundary classification**: platform-core shell/navigation.
|
||||
- **Seams affected**: provider connections and alerts as workspace-owned/provider-level surfaces.
|
||||
- **Neutral platform terms preserved or introduced**: Use workspace, environment, provider connection, target scope.
|
||||
- **Provider-specific semantics retained and why**: Microsoft/Intune resource pages outside the explicit workspace-wide surface set retain current tenant-owned behavior until their route cutover is separately specified.
|
||||
- **Why this does not deepen provider coupling accidentally**: Route-scope classification is platform-owned and provider-neutral.
|
||||
- **Follow-up path**: remaining legacy tenant-owned direct admin lists stay covered by existing route-audit specs, not by this minimal 311 branch.
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory)*
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---:|---|---|---|---:|---|
|
||||
| Admin shell topbar/sidebar scope | yes | Filament/native hooks | global shell/navigation | route category, shell context | no | No styling or asset changes |
|
||||
| Operations / Review / Governance / Audit / Alerts filters | yes | Filament tables/widgets | workspace-wide filters | table filter defaults | no | Existing filters remain |
|
||||
| Environment-bound canonical routes | no behavioral expansion | Filament resources/pages | canonical environment context | route category only | no | Must remain environment-bound |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no new persisted truth. Route path remains the source of route scope.
|
||||
- **New persisted entity/table/artifact?**: no.
|
||||
- **New abstraction?**: no new service. One enum category is added to an existing taxonomy.
|
||||
- **New enum/state/reason family?**: yes, `WorkspaceWideSurface` in the existing `TenantPageCategory`.
|
||||
- **New cross-domain UI framework/taxonomy?**: no framework. Narrow taxonomy hardening only.
|
||||
- **Current operator problem**: The shell can show an environment context while the page is workspace-wide and separately filtered.
|
||||
- **Existing structure is insufficient because**: `WorkspaceScoped` covers both explicit workspace hubs and legacy tenant-owned admin lists, so forcing all of it tenantless breaks legacy data surfaces while not forcing any of it leaves CRW/Alerts/Audit ambiguous.
|
||||
- **Narrowest correct implementation**: Classify only explicit workspace-wide surface routes centrally and make `OperateHubShell` tenantless for that category.
|
||||
- **Ownership cost**: Future workspace-wide surfaces must be added to the taxonomy and tests.
|
||||
- **Alternative intentionally rejected**: Hardcoding `/admin/reviews/workspace` in `OperateHubShell`, switching sidebar based on `?tenant`, or broad-rebuilding all navigation.
|
||||
- **Release truth**: Current-release platform shell behavior.
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
Pre-production compatibility allows route taxonomy hardening without migration shims. Legacy tenant-owned direct admin lists are not rewritten in this slice.
|
||||
|
||||
## Global Admin Surface Scope Contract
|
||||
|
||||
1. Route scope determines sidebar, topbar, and breadcrumb.
|
||||
2. Query parameters such as `tenant`, `environment`, `managed_environment_id`, and `tenant_scope` never change navigation scope.
|
||||
3. Workspace-wide pages remain workspace-wide even when an environment filter is active.
|
||||
4. Environment-bound navigation is allowed only on canonical `/admin/workspaces/{workspace}/environments/{environment}/...` routes.
|
||||
5. Topbar must reflect route scope.
|
||||
6. Page-level filters must appear as filters, not global context.
|
||||
7. Remembered or last selected environment must not auto-bind workspace-wide hubs.
|
||||
8. Workspace-wide pages need explicit all-environments or filtered-by-environment semantics.
|
||||
9. Environment-owned detail links should use canonical environment-bound URLs when the detail is environment context.
|
||||
10. Filtered empty states must mention active filters.
|
||||
11. Direct URL and RBAC remain server-side protected; UI context is not authorization.
|
||||
12. No sidebar magic based on `?tenant`.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Workspace-wide shell stays workspace-wide (Priority: P1)
|
||||
|
||||
As an operator on a workspace hub, I want the topbar/sidebar to stay at workspace scope even when I arrive with an environment query filter.
|
||||
|
||||
**Independent Test**: Visit representative workspace-wide routes with `tenant` or `managed_environment_id` query parameters and assert no environment shell scope appears.
|
||||
|
||||
### User Story 2 - Environment-bound routes keep environment context (Priority: P1)
|
||||
|
||||
As an operator on a canonical environment route, I want the shell to show the selected environment and environment navigation.
|
||||
|
||||
**Independent Test**: Visit `/admin/workspaces/{workspace}/environments/{environment}/...` and assert environment route classification remains environment-bound.
|
||||
|
||||
### User Story 3 - Legacy tenant-owned lists do not regress (Priority: P1)
|
||||
|
||||
As an operator using legacy tenant-owned admin lists, I want existing data scoping to remain intact until those surfaces are explicitly cut over.
|
||||
|
||||
**Independent Test**: Existing tenant-owned resource parity tests still pass while explicit workspace-wide surfaces use the new shell contract.
|
||||
|
||||
## Anti-Patterns To Avoid
|
||||
|
||||
- `?tenant` switches sidebar.
|
||||
- Global selected environment on workspace-wide hubs.
|
||||
- Double semantics: topbar environment plus table environment filter.
|
||||
- Customer-safe detail forced onto a workspace-only route.
|
||||
- New per-page shell exceptions.
|
||||
@ -0,0 +1,81 @@
|
||||
# Tasks: Workspace / Environment Surface Scope Contract
|
||||
|
||||
**Input**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/311-workspace-environment-surface-scope-contract/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/311-workspace-environment-surface-scope-contract/plan.md`
|
||||
**Prerequisites**: Current branch is `311-workspace-environment-surface-scope-contract`; Customer Review Workspace product completion is deferred to 312.
|
||||
**Scope**: Global admin shell/navigation/filter contract only.
|
||||
|
||||
**Tests**: Pest/Livewire feature tests and route-category unit test.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane assignment is named and is the narrowest sufficient proof: focused Feature/Unit lane.
|
||||
- [x] New or changed tests stay in existing surface families.
|
||||
- [x] No new shared test helper, seed, factory, or heavy family is introduced.
|
||||
- [x] Browser smoke is not required because no visual layout/styling changed.
|
||||
- [x] Legacy tenant-owned resource parity remains covered to prevent over-broad scope regression.
|
||||
|
||||
## Format: `[ID] [P?] [Story?] Description with file path`
|
||||
|
||||
## Phase 1: Branch and WIP Split
|
||||
|
||||
- [x] T001 Save dirty Customer Review Workspace WIP patch to `/tmp/311-customer-review-workspace-wip.patch`.
|
||||
- [x] T002 Stash dirty WIP, including untracked files, before switching branches.
|
||||
- [x] T003 Switch from the mixed branch to `platform-dev`, pull, and create `311-workspace-environment-surface-scope-contract`.
|
||||
- [x] T004 Confirm no commits are made during implementation.
|
||||
|
||||
## Phase 2: Contract Tests First
|
||||
|
||||
- [x] T005 [US1] Add workspace-wide query-independence cases to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/PanelNavigationSegregationTest.php`.
|
||||
- [x] T006 [US1] Add representative shell-contract coverage to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php`.
|
||||
- [x] T007 [US1] Update Operations topbar/filter tests in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Monitoring/OperationsKpiHeaderTenantContextTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php`.
|
||||
- [x] T008 [US1] Update Customer Review Workspace and Review Register filter tests in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewRegisterPrefilterTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewRegisterTest.php`.
|
||||
- [x] T009 [US1] Update Audit Log and Alerts workspace-wide tests in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/AuditLogPageTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php`.
|
||||
- [x] T010 [US2] Add route-category coverage to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Tenants/TenantPageCategoryTest.php`.
|
||||
- [x] T011 [US3] Keep `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/TenantOwnedResourceScopeParityTest.php` green as a legacy tenant-owned regression guard.
|
||||
|
||||
## Phase 3: Runtime Contract
|
||||
|
||||
- [x] T012 [US1] Add `WorkspaceWideSurface` classification to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Tenants/TenantPageCategory.php`.
|
||||
- [x] T013 [US1] Make `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Tenants/TenantPageCategory.php` resolve Livewire update/request paths from referer when needed.
|
||||
- [x] T014 [US1] Update `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Tenants/TenantInteractionLane.php` for the new route category.
|
||||
- [x] T015 [US1] Update `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/OperateHub/OperateHubShell.php` so `WorkspaceWideSurface` resolves tenantless before query, Filament tenant, or remembered tenant hints.
|
||||
- [x] T016 [US1] Remove remembered-tenant default filters and global-context clearing from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`.
|
||||
- [x] T017 [US1] Make Provider Connection list filtering query-driven only in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ProviderConnectionResource.php`.
|
||||
- [x] T018 [US1] Make Alert Delivery list and Alerts KPI workspace-wide in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/AlertDeliveryResource.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Widgets/Alerts/AlertsKpiHeader.php`.
|
||||
- [x] T019 [US1] Make Operations KPI use workspace entitlement scope when shell is tenantless in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Widgets/Operations/OperationsKpiHeader.php`.
|
||||
|
||||
## Phase 4: Spec Artifacts
|
||||
|
||||
- [x] T020 Create `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/311-workspace-environment-surface-scope-contract/spec.md`.
|
||||
- [x] T021 Create `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/311-workspace-environment-surface-scope-contract/plan.md`.
|
||||
- [x] T022 Create `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/311-workspace-environment-surface-scope-contract/tasks.md`.
|
||||
|
||||
## Phase 5: Validation and Close-Out
|
||||
|
||||
- [x] T023 Run the focused 311 Feature/Unit test selection from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform`.
|
||||
- [x] T024 Run `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||
- [x] T025 Run `git diff --check` from `/Users/ahmeddarrazi/Documents/projects/wt-plattform`.
|
||||
- [x] T026 Record final changed files, validation, no-migration/no-RBAC/no-CRW-product-logic close-out.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- T001-T004 before runtime edits.
|
||||
- T005-T011 before or alongside T012-T019.
|
||||
- T012-T019 before final validation.
|
||||
- T020-T022 before final close-out because runtime files changed.
|
||||
- T023-T026 last.
|
||||
|
||||
## Parallel Work Examples
|
||||
|
||||
- T005-T011 can be maintained by surface family after the contract is known.
|
||||
- T016-T019 are disjoint runtime surface updates after T012-T015.
|
||||
- T023 can run before T024/T025; T024 and T025 are final formatting/whitespace checks.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
1. Preserve 312 WIP by patch/stash.
|
||||
2. Establish route-scope tests first.
|
||||
3. Harden central taxonomy and shell.
|
||||
4. Remove remembered/global tenant defaults from explicit workspace-wide surfaces.
|
||||
5. Preserve legacy tenant-owned list parity.
|
||||
6. Validate focused suite, format, and whitespace.
|
||||
Loading…
Reference in New Issue
Block a user