From e1afda2fafc9f232db994a537ea37fc12e903bce Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 9 Mar 2026 22:52:00 +0100 Subject: [PATCH] feat: add workspace admin home overview --- .github/agents/copilot-instructions.md | 3 +- app/Filament/Pages/ChooseWorkspace.php | 8 +- app/Filament/Pages/WorkspaceOverview.php | 76 +++ .../Widgets/Dashboard/DashboardKpis.php | 8 +- .../Widgets/Dashboard/NeedsAttention.php | 2 - .../Widgets/Dashboard/RecentDriftFindings.php | 2 + .../Workspace/WorkspaceNeedsAttention.php | 58 +++ .../Workspace/WorkspaceRecentOperations.php | 66 +++ .../Workspace/WorkspaceSummaryStats.php | 64 +++ .../Middleware/EnsureWorkspaceSelected.php | 43 +- app/Providers/Filament/AdminPanelProvider.php | 3 + .../EnsureFilamentTenantSelected.php | 4 +- .../Workspaces/WorkspaceOverviewBuilder.php | 476 ++++++++++++++++++ .../Workspaces/WorkspaceRedirectResolver.php | 4 +- .../pages/workspace-overview.blade.php | 102 ++++ .../filament/partials/context-bar.blade.php | 247 +++++---- .../dashboard/baseline-compare-now.blade.php | 103 ++-- .../dashboard/needs-attention.blade.php | 4 +- .../workspace-needs-attention.blade.php | 47 ++ .../workspace-recent-operations.blade.php | 61 +++ routes/web.php | 29 +- .../checklists/requirements.md | 35 ++ .../workspace-home-routing.openapi.yaml | 272 ++++++++++ specs/129-workspace-admin-home/data-model.md | 203 ++++++++ specs/129-workspace-admin-home/plan.md | 238 +++++++++ specs/129-workspace-admin-home/quickstart.md | 70 +++ specs/129-workspace-admin-home/research.md | 73 +++ specs/129-workspace-admin-home/spec.md | 180 +++++++ specs/129-workspace-admin-home/tasks.md | 205 ++++++++ ...oChooseTenantWhenWorkspaceSelectedTest.php | 26 +- ...oseWorkspaceWhenMultipleWorkspacesTest.php | 13 +- .../Filament/TenantDashboardDbOnlyTest.php | 8 +- .../Filament/WorkspaceOverviewAccessTest.php | 26 + .../WorkspaceOverviewAuthorizationTest.php | 24 + .../Filament/WorkspaceOverviewContentTest.php | 33 ++ .../WorkspaceOverviewEmptyStatesTest.php | 29 ++ .../Filament/WorkspaceOverviewLandingTest.php | 45 ++ .../WorkspaceOverviewNavigationTest.php | 20 + .../WorkspaceOverviewOperationsTest.php | 42 ++ ...kspaceOverviewPermissionVisibilityTest.php | 26 + .../Guards/AdminWorkspaceRoutesGuardTest.php | 8 + .../Monitoring/HeaderContextBarTest.php | 4 +- tests/Feature/OpsUx/OperateHubShellTest.php | 6 +- .../EnsureWorkspaceSelectedMiddlewareTest.php | 4 +- 44 files changed, 2759 insertions(+), 241 deletions(-) create mode 100644 app/Filament/Pages/WorkspaceOverview.php create mode 100644 app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php create mode 100644 app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php create mode 100644 app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php create mode 100644 app/Support/Workspaces/WorkspaceOverviewBuilder.php create mode 100644 resources/views/filament/pages/workspace-overview.blade.php create mode 100644 resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php create mode 100644 resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php create mode 100644 specs/129-workspace-admin-home/checklists/requirements.md create mode 100644 specs/129-workspace-admin-home/contracts/workspace-home-routing.openapi.yaml create mode 100644 specs/129-workspace-admin-home/data-model.md create mode 100644 specs/129-workspace-admin-home/plan.md create mode 100644 specs/129-workspace-admin-home/quickstart.md create mode 100644 specs/129-workspace-admin-home/research.md create mode 100644 specs/129-workspace-admin-home/spec.md create mode 100644 specs/129-workspace-admin-home/tasks.md create mode 100644 tests/Feature/Filament/WorkspaceOverviewAccessTest.php create mode 100644 tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php create mode 100644 tests/Feature/Filament/WorkspaceOverviewContentTest.php create mode 100644 tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php create mode 100644 tests/Feature/Filament/WorkspaceOverviewLandingTest.php create mode 100644 tests/Feature/Filament/WorkspaceOverviewNavigationTest.php create mode 100644 tests/Feature/Filament/WorkspaceOverviewOperationsTest.php create mode 100644 tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index eed8956..146af7c 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -57,6 +57,7 @@ ## Active Technologies - PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Microsoft Graph provider stack (127-rbac-inventory-backup) - PostgreSQL for tenant-owned inventory, backup items, versions, verification outcomes, and operation runs (127-rbac-inventory-backup) - PostgreSQL via Laravel Sail (128-rbac-baseline-compare) +- PostgreSQL via Laravel Sail plus session-backed workspace and tenant contex (129-workspace-admin-home) - PHP 8.4.15 (feat/005-bulk-operations) @@ -76,8 +77,8 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 129-workspace-admin-home: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4 - 128-rbac-baseline-compare: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4 - 127-rbac-inventory-backup: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Microsoft Graph provider stack -- 126-filter-ux-standardization: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer`, existing `TagBadgeCatalog` / `TagBadgeRenderer`, existing Filament resource tables diff --git a/app/Filament/Pages/ChooseWorkspace.php b/app/Filament/Pages/ChooseWorkspace.php index e4dee0a..019838b 100644 --- a/app/Filament/Pages/ChooseWorkspace.php +++ b/app/Filament/Pages/ChooseWorkspace.php @@ -132,7 +132,9 @@ public function selectWorkspace(int $workspaceId): void /** @var WorkspaceRedirectResolver $resolver */ $resolver = app(WorkspaceRedirectResolver::class); - $this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user)); + $redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user); + + $this->redirect($redirectTarget); } /** @@ -173,6 +175,8 @@ public function createWorkspace(array $data): void /** @var WorkspaceRedirectResolver $resolver */ $resolver = app(WorkspaceRedirectResolver::class); - $this->redirect($intendedUrl ?: $resolver->resolve($workspace, $user)); + $redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user); + + $this->redirect($redirectTarget); } } diff --git a/app/Filament/Pages/WorkspaceOverview.php b/app/Filament/Pages/WorkspaceOverview.php new file mode 100644 index 0000000..138b689 --- /dev/null +++ b/app/Filament/Pages/WorkspaceOverview.php @@ -0,0 +1,76 @@ + + */ + public array $overview = []; + + public function mount(WorkspaceOverviewBuilder $builder): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId)) { + $this->redirect('/admin/choose-workspace'); + + return; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + abort(404); + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $workspace)) { + abort(404); + } + + $this->overview = $builder->build($workspace, $user); + } + + public static function navigationItem(): NavigationItem + { + return NavigationItem::make('Overview') + ->url(fn (): string => route('admin.home')) + ->icon('heroicon-o-home') + ->sort(-100) + ->isActiveWhen(fn (): bool => request()->routeIs('admin.home')); + } +} diff --git a/app/Filament/Widgets/Dashboard/DashboardKpis.php b/app/Filament/Widgets/Dashboard/DashboardKpis.php index 5f177e4..29e6c37 100644 --- a/app/Filament/Widgets/Dashboard/DashboardKpis.php +++ b/app/Filament/Widgets/Dashboard/DashboardKpis.php @@ -15,8 +15,6 @@ class DashboardKpis extends StatsOverviewWidget { - protected static bool $isLazy = false; - protected int|string|array $columnSpan = 'full'; protected function getPollingInterval(): ?string @@ -74,14 +72,18 @@ protected function getStats(): array return [ Stat::make('Open drift findings', $openDriftFindings) + ->description('across all policy types') ->url(FindingResource::getUrl('index', tenant: $tenant)), Stat::make('High severity drift', $highSeverityDriftFindings) + ->description('requiring immediate review') ->color($highSeverityDriftFindings > 0 ? 'danger' : 'gray') ->url(FindingResource::getUrl('index', tenant: $tenant)), Stat::make('Active operations', $activeRuns) + ->description('backup, sync & compare runs') ->color($activeRuns > 0 ? 'warning' : 'gray') ->url(route('admin.operations.index')), - Stat::make('Inventory active', $inventoryActiveRuns) + Stat::make('Inventory syncs running', $inventoryActiveRuns) + ->description('active inventory sync jobs') ->color($inventoryActiveRuns > 0 ? 'warning' : 'gray') ->url(route('admin.operations.index')), ]; diff --git a/app/Filament/Widgets/Dashboard/NeedsAttention.php b/app/Filament/Widgets/Dashboard/NeedsAttention.php index 5e9b995..acee901 100644 --- a/app/Filament/Widgets/Dashboard/NeedsAttention.php +++ b/app/Filament/Widgets/Dashboard/NeedsAttention.php @@ -16,8 +16,6 @@ class NeedsAttention extends Widget { - protected static bool $isLazy = false; - protected string $view = 'filament.widgets.dashboard.needs-attention'; /** diff --git a/app/Filament/Widgets/Dashboard/RecentDriftFindings.php b/app/Filament/Widgets/Dashboard/RecentDriftFindings.php index 3c5eb58..365256a 100644 --- a/app/Filament/Widgets/Dashboard/RecentDriftFindings.php +++ b/app/Filament/Widgets/Dashboard/RecentDriftFindings.php @@ -20,6 +20,8 @@ class RecentDriftFindings extends TableWidget { + protected int|string|array $columnSpan = 'full'; + public function table(Table $table): Table { $tenant = Filament::getTenant(); diff --git a/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php b/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php new file mode 100644 index 0000000..f10e023 --- /dev/null +++ b/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php @@ -0,0 +1,58 @@ + + */ + public array $items = []; + + /** + * @var array{ + * title: string, + * body: string, + * action_label: string, + * action_url: string + * } + */ + public array $emptyState = []; + + /** + * @param array $items + * @param array{ + * title: string, + * body: string, + * action_label: string, + * action_url: string + * } $emptyState + */ + public function mount(array $items = [], array $emptyState = []): void + { + $this->items = $items; + $this->emptyState = $emptyState; + } +} diff --git a/app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php b/app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php new file mode 100644 index 0000000..d75c1a6 --- /dev/null +++ b/app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php @@ -0,0 +1,66 @@ + + */ + public array $operations = []; + + /** + * @var array{ + * title: string, + * body: string, + * action_label: string, + * action_url: string + * } + */ + public array $emptyState = []; + + /** + * @param array $operations + * @param array{ + * title: string, + * body: string, + * action_label: string, + * action_url: string + * } $emptyState + */ + public function mount(array $operations = [], array $emptyState = []): void + { + $this->operations = $operations; + $this->emptyState = $emptyState; + } +} diff --git a/app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php b/app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php new file mode 100644 index 0000000..691caec --- /dev/null +++ b/app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php @@ -0,0 +1,64 @@ + + */ + public array $metrics = []; + + /** + * @param array $metrics + */ + public function mount(array $metrics = []): void + { + $this->metrics = $metrics; + } + + /** + * @return array + */ + protected function getStats(): array + { + return collect($this->metrics) + ->map(function (array $metric): Stat { + $stat = Stat::make($metric['label'], $metric['value']) + ->description($metric['description']) + ->color($metric['color']); + + if ($metric['destination_url'] !== null) { + $stat->url($metric['destination_url']); + } + + return $stat; + }) + ->all(); + } +} diff --git a/app/Http/Middleware/EnsureWorkspaceSelected.php b/app/Http/Middleware/EnsureWorkspaceSelected.php index e9fdfa7..5d55931 100644 --- a/app/Http/Middleware/EnsureWorkspaceSelected.php +++ b/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -15,6 +15,7 @@ use Closure; use Filament\Notifications\Notification; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Symfony\Component\HttpFoundation\Response; class EnsureWorkspaceSelected @@ -82,9 +83,12 @@ public function handle(Request $request, Closure $next): Response return $next($request); } - // Stale session — clear and warn. $this->clearStaleSession($context, $user, $request, $workspace); + if ($workspace instanceof Workspace && empty($workspace->archived_at)) { + abort(404); + } + return $this->redirectToChooser(); } @@ -96,6 +100,10 @@ public function handle(Request $request, Closure $next): Response ->select('workspace_memberships.*') ->get(); + if ($this->isChooserFirstPath($path)) { + return $this->redirectToWorkspaceSelection($request, $user, $selectableMemberships); + } + // --- Step 5: single membership auto-resume --- if ($selectableMemberships->count() === 1) { /** @var WorkspaceMembership $membership */ @@ -152,16 +160,7 @@ public function handle(Request $request, Closure $next): Response } // --- Step 7: fallback to chooser --- - if ($selectableMemberships->isNotEmpty()) { - WorkspaceIntendedUrl::storeFromRequest($request); - } - - $canCreate = $user->can('create', Workspace::class); - $target = ($selectableMemberships->isNotEmpty() || $canCreate) - ? '/admin/choose-workspace' - : '/admin/no-access'; - - return new \Illuminate\Http\Response('', 302, ['Location' => $target]); + return $this->redirectToWorkspaceSelection($request, $user, $selectableMemberships); } private function isWorkspaceOptionalPath(Request $request, string $path): bool @@ -186,11 +185,33 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool return preg_match('#^/admin/operations/[^/]+$#', $path) === 1; } + private function isChooserFirstPath(string $path): bool + { + return in_array($path, ['/admin', '/admin/choose-tenant'], true); + } + private function redirectToChooser(): Response { return new \Illuminate\Http\Response('', 302, ['Location' => '/admin/choose-workspace']); } + /** + * @param Collection $selectableMemberships + */ + private function redirectToWorkspaceSelection(Request $request, User $user, Collection $selectableMemberships): Response + { + if ($selectableMemberships->isNotEmpty()) { + WorkspaceIntendedUrl::storeFromRequest($request); + } + + $canCreate = $user->can('create', Workspace::class); + $target = ($selectableMemberships->isNotEmpty() || $canCreate) + ? '/admin/choose-workspace' + : '/admin/no-access'; + + return new \Illuminate\Http\Response('', 302, ['Location' => $target]); + } + private function redirectViaTenantBranching(Workspace $workspace, User $user): Response { /** @var WorkspaceRedirectResolver $resolver */ diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 9a25368..928427f 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -9,6 +9,7 @@ use App\Filament\Pages\NoAccess; use App\Filament\Pages\Settings\WorkspaceSettings; use App\Filament\Pages\TenantRequiredPermissions; +use App\Filament\Pages\WorkspaceOverview; use App\Filament\Resources\AlertDeliveryResource; use App\Filament\Resources\AlertDestinationResource; use App\Filament\Resources\AlertRuleResource; @@ -56,6 +57,7 @@ public function panel(Panel $panel): Panel ->brandName('TenantPilot') ->brandLogo(fn () => view('filament.admin.logo')) ->brandLogoHeight('2rem') + ->homeUrl(fn (): string => route('admin.home')) ->favicon(asset('favicon.ico')) ->authenticatedRoutes(function (Panel $panel): void { ChooseWorkspace::registerRoutes($panel); @@ -66,6 +68,7 @@ public function panel(Panel $panel): Panel 'primary' => Color::Indigo, ]) ->navigationItems([ + WorkspaceOverview::navigationItem(), NavigationItem::make('Integrations') ->url(fn (): string => route('filament.admin.resources.provider-connections.index')) ->icon('heroicon-o-link') diff --git a/app/Support/Middleware/EnsureFilamentTenantSelected.php b/app/Support/Middleware/EnsureFilamentTenantSelected.php index 29f1cca..13b64dd 100644 --- a/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -2,6 +2,7 @@ namespace App\Support\Middleware; +use App\Filament\Pages\WorkspaceOverview; use App\Filament\Resources\AlertDeliveryResource; use App\Filament\Resources\AlertDestinationResource; use App\Filament\Resources\AlertRuleResource; @@ -149,7 +150,7 @@ public function handle(Request $request, Closure $next): Response str_starts_with($path, '/admin/w/') || str_starts_with($path, '/admin/workspaces') || str_starts_with($path, '/admin/operations') - || in_array($path, ['/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding', '/admin/settings/workspace'], true) + || in_array($path, ['/admin', '/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding', '/admin/settings/workspace'], true) ) { $this->configureNavigationForRequest($panel); @@ -187,6 +188,7 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void $panel->navigation(function (): NavigationBuilder { return app(NavigationBuilder::class) + ->item(WorkspaceOverview::navigationItem()) ->item( NavigationItem::make('Manage workspaces') ->url(fn (): string => route('filament.admin.resources.workspaces.index')) diff --git a/app/Support/Workspaces/WorkspaceOverviewBuilder.php b/app/Support/Workspaces/WorkspaceOverviewBuilder.php new file mode 100644 index 0000000..41ecce8 --- /dev/null +++ b/app/Support/Workspaces/WorkspaceOverviewBuilder.php @@ -0,0 +1,476 @@ +, + * attention_items: list, + * attention_empty_state: array{ + * title: string, + * body: string, + * action_label: string, + * action_url: string + * }, + * recent_operations: list, + * recent_operations_empty_state: array{ + * title: string, + * body: string, + * action_label: string, + * action_url: string + * }, + * quick_actions: list, + * zero_tenant_state: ?array{ + * title: string, + * body: string, + * action_label: string, + * action_url: string + * } + * } + */ + public function build(Workspace $workspace, User $user): array + { + $accessibleTenants = $this->accessibleTenants($workspace, $user); + $accessibleTenantIds = $accessibleTenants + ->pluck('id') + ->map(static fn (mixed $id): int => (int) $id) + ->all(); + + $canViewAlerts = $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::ALERTS_VIEW); + + $recentOperations = $this->recentOperations((int) $workspace->getKey(), $accessibleTenantIds); + $attentionItems = $this->attentionItems((int) $workspace->getKey(), $accessibleTenantIds, $canViewAlerts); + $quickActions = $this->quickActions($workspace, $accessibleTenants->count(), $canViewAlerts, $user); + + $zeroTenantState = null; + + if ($accessibleTenants->isEmpty()) { + $fallbackAction = collect($quickActions) + ->first(fn (array $action): bool => in_array($action['key'], ['manage_workspaces', 'switch_workspace'], true)); + + $zeroTenantState = [ + 'title' => 'No accessible tenants in this workspace', + 'body' => 'You can still review workspace-wide operations or switch to another workspace while tenant access is being set up.', + 'action_label' => is_array($fallbackAction) ? $fallbackAction['label'] : 'Switch workspace', + 'action_url' => is_array($fallbackAction) ? $fallbackAction['url'] : $this->switchWorkspaceUrl(), + ]; + } + + return [ + 'workspace' => [ + 'id' => (int) $workspace->getKey(), + 'name' => (string) $workspace->name, + 'slug' => filled($workspace->slug) ? (string) $workspace->slug : null, + ], + 'accessible_tenant_count' => $accessibleTenants->count(), + 'summary_metrics' => $this->summaryMetrics( + workspaceId: (int) $workspace->getKey(), + accessibleTenantCount: $accessibleTenants->count(), + accessibleTenantIds: $accessibleTenantIds, + canViewAlerts: $canViewAlerts, + needsAttentionCount: count($attentionItems), + ), + 'attention_items' => $attentionItems, + 'attention_empty_state' => [ + 'title' => 'Nothing urgent in your current scope', + 'body' => 'Recent operations and alert deliveries look healthy right now.', + 'action_label' => $canViewAlerts ? 'Open alerts' : 'Open operations', + 'action_url' => $canViewAlerts ? '/admin/alerts' : route('admin.operations.index'), + ], + 'recent_operations' => $recentOperations, + 'recent_operations_empty_state' => [ + 'title' => 'No recent operations yet', + 'body' => 'Workspace-wide activity will show up here once syncs, evaluations, or restores start running.', + 'action_label' => 'Open operations', + 'action_url' => route('admin.operations.index'), + ], + 'quick_actions' => $quickActions, + 'zero_tenant_state' => $zeroTenantState, + ]; + } + + /** + * @return Collection + */ + private function accessibleTenants(Workspace $workspace, User $user): Collection + { + return Tenant::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('status', 'active') + ->whereIn('id', $user->tenantMemberships()->select('tenant_id')) + ->orderBy('name') + ->get(['id', 'name', 'external_id', 'workspace_id']); + } + + /** + * @param array $accessibleTenantIds + * @return list + */ + private function summaryMetrics( + int $workspaceId, + int $accessibleTenantCount, + array $accessibleTenantIds, + bool $canViewAlerts, + int $needsAttentionCount, + ): array { + $activeOperationsCount = (int) $this->scopeToAuthorizedTenants( + OperationRun::query(), + $workspaceId, + $accessibleTenantIds, + ) + ->whereIn('status', [ + OperationRunStatus::Queued->value, + OperationRunStatus::Running->value, + ]) + ->count(); + + $metrics = [ + [ + 'key' => 'accessible_tenants', + 'label' => 'Accessible tenants', + 'value' => $accessibleTenantCount, + 'description' => $accessibleTenantCount > 0 + ? 'Tenant drill-down stays explicit from this workspace home.' + : 'No tenant memberships are available in this workspace yet.', + 'destination_url' => $accessibleTenantCount > 0 ? ChooseTenant::getUrl(panel: 'admin') : null, + 'color' => 'primary', + ], + [ + 'key' => 'active_operations', + 'label' => 'Active operations', + 'value' => $activeOperationsCount, + 'description' => 'Workspace-wide runs that are still queued or in progress.', + 'destination_url' => route('admin.operations.index'), + 'color' => $activeOperationsCount > 0 ? 'warning' : 'gray', + ], + ]; + + if ($canViewAlerts) { + $failedAlertDeliveriesCount = (int) $this->scopeToAuthorizedTenants( + AlertDelivery::query(), + $workspaceId, + $accessibleTenantIds, + ) + ->where('created_at', '>=', now()->subDays(7)) + ->where('status', AlertDelivery::STATUS_FAILED) + ->count(); + + $metrics[] = [ + 'key' => 'alerts', + 'label' => 'Alert failures (7d)', + 'value' => $failedAlertDeliveriesCount, + 'description' => 'Failed alert deliveries in the last 7 days.', + 'destination_url' => AlertDeliveryResource::getUrl(panel: 'admin'), + 'color' => $failedAlertDeliveriesCount > 0 ? 'danger' : 'gray', + ]; + } + + $metrics[] = [ + 'key' => 'needs_attention', + 'label' => 'Needs attention', + 'value' => $needsAttentionCount, + 'description' => 'Urgent workspace-safe items surfaced below.', + 'destination_url' => $needsAttentionCount > 0 ? route('admin.operations.index') : null, + 'color' => $needsAttentionCount > 0 ? 'warning' : 'gray', + ]; + + return $metrics; + } + + /** + * @param array $accessibleTenantIds + * @return list + */ + private function attentionItems(int $workspaceId, array $accessibleTenantIds, bool $canViewAlerts): array + { + $items = []; + + $latestFailedRun = $this->scopeToAuthorizedTenants( + OperationRun::query()->with('tenant'), + $workspaceId, + $accessibleTenantIds, + ) + ->where('status', OperationRunStatus::Completed->value) + ->whereIn('outcome', [ + OperationRunOutcome::Failed->value, + OperationRunOutcome::PartiallySucceeded->value, + ]) + ->latest('created_at') + ->first(); + + if ($latestFailedRun instanceof OperationRun) { + $items[] = [ + 'title' => OperationCatalog::label((string) $latestFailedRun->type).' needs review', + 'body' => 'Latest outcome: '.BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $latestFailedRun->outcome)->label.'.', + 'url' => route('admin.operations.view', ['run' => (int) $latestFailedRun->getKey()]), + 'badge' => 'Operations', + 'badge_color' => $latestFailedRun->outcome === OperationRunOutcome::Failed->value ? 'danger' : 'warning', + ]; + } + + $activeRunsCount = (int) $this->scopeToAuthorizedTenants( + OperationRun::query(), + $workspaceId, + $accessibleTenantIds, + ) + ->whereIn('status', [ + OperationRunStatus::Queued->value, + OperationRunStatus::Running->value, + ]) + ->count(); + + if ($activeRunsCount > 0) { + $items[] = [ + 'title' => 'Operations are still running', + 'body' => $activeRunsCount.' workspace run(s) are active right now.', + 'url' => route('admin.operations.index'), + 'badge' => 'Operations', + 'badge_color' => 'warning', + ]; + } + + if ($canViewAlerts) { + $failedAlertDeliveriesCount = (int) $this->scopeToAuthorizedTenants( + AlertDelivery::query(), + $workspaceId, + $accessibleTenantIds, + ) + ->where('created_at', '>=', now()->subDays(7)) + ->where('status', AlertDelivery::STATUS_FAILED) + ->count(); + + if ($failedAlertDeliveriesCount > 0) { + $items[] = [ + 'title' => 'Alert deliveries failed', + 'body' => $failedAlertDeliveriesCount.' alert delivery attempt(s) failed in the last 7 days.', + 'url' => AlertDeliveryResource::getUrl(panel: 'admin'), + 'badge' => 'Alerts', + 'badge_color' => 'danger', + ]; + } + } + + return array_slice($items, 0, 5); + } + + /** + * @param array $accessibleTenantIds + * @return list + */ + private function recentOperations(int $workspaceId, array $accessibleTenantIds): array + { + $statusSpec = BadgeRenderer::label(BadgeDomain::OperationRunStatus); + $statusColorSpec = BadgeRenderer::color(BadgeDomain::OperationRunStatus); + $outcomeSpec = BadgeRenderer::label(BadgeDomain::OperationRunOutcome); + $outcomeColorSpec = BadgeRenderer::color(BadgeDomain::OperationRunOutcome); + + return $this->scopeToAuthorizedTenants( + OperationRun::query()->with('tenant'), + $workspaceId, + $accessibleTenantIds, + ) + ->latest('created_at') + ->limit(5) + ->get() + ->map(function (OperationRun $run) use ($statusSpec, $statusColorSpec, $outcomeSpec, $outcomeColorSpec): array { + return [ + 'id' => (int) $run->getKey(), + 'title' => OperationCatalog::label((string) $run->type), + 'tenant_label' => $run->tenant instanceof Tenant ? (string) $run->tenant->name : null, + 'status_label' => $statusSpec($run->status), + 'status_color' => $statusColorSpec($run->status), + 'outcome_label' => $outcomeSpec($run->outcome), + 'outcome_color' => $outcomeColorSpec($run->outcome), + 'started_at' => $run->created_at?->diffForHumans() ?? 'just now', + 'url' => route('admin.operations.view', ['run' => (int) $run->getKey()]), + ]; + }) + ->all(); + } + + /** + * @param array $accessibleTenantIds + */ + private function scopeToAuthorizedTenants(Builder $query, int $workspaceId, array $accessibleTenantIds): Builder + { + return $query + ->where('workspace_id', $workspaceId) + ->where(function (Builder $query) use ($accessibleTenantIds): void { + $query->whereNull('tenant_id'); + + if ($accessibleTenantIds !== []) { + $query->orWhereIn('tenant_id', $accessibleTenantIds); + } + }); + } + + /** + * @return list + */ + private function quickActions(Workspace $workspace, int $accessibleTenantCount, bool $canViewAlerts, User $user): array + { + $actions = [ + [ + 'key' => 'choose_tenant', + 'label' => 'Choose tenant', + 'description' => 'Deliberately enter tenant context from this workspace.', + 'url' => ChooseTenant::getUrl(panel: 'admin'), + 'icon' => 'heroicon-o-building-office-2', + 'color' => 'primary', + 'visible' => $accessibleTenantCount > 0, + ], + [ + 'key' => 'operations', + 'label' => 'Open operations', + 'description' => 'Review current and recent workspace-wide runs.', + 'url' => route('admin.operations.index'), + 'icon' => 'heroicon-o-queue-list', + 'color' => 'gray', + 'visible' => true, + ], + [ + 'key' => 'alerts', + 'label' => 'Open alerts', + 'description' => 'Inspect alert overview, rules, and deliveries.', + 'url' => '/admin/alerts', + 'icon' => 'heroicon-o-bell-alert', + 'color' => 'gray', + 'visible' => $canViewAlerts, + ], + [ + 'key' => 'switch_workspace', + 'label' => 'Switch workspace', + 'description' => 'Change the active workspace context.', + 'url' => $this->switchWorkspaceUrl(), + 'icon' => 'heroicon-o-arrows-right-left', + 'color' => 'gray', + 'visible' => true, + ], + [ + 'key' => 'manage_workspaces', + 'label' => 'Manage workspaces', + 'description' => 'Open workspace management and memberships.', + 'url' => route('filament.admin.resources.workspaces.index'), + 'icon' => 'heroicon-o-squares-2x2', + 'color' => 'gray', + 'visible' => $this->canManageWorkspaces($workspace, $user), + ], + ]; + + return collect($actions) + ->filter(fn (array $action): bool => (bool) $action['visible']) + ->map(function (array $action): array { + unset($action['visible']); + + return $action; + }) + ->values() + ->all(); + } + + private function canManageWorkspaces(Workspace $workspace, User $user): bool + { + if ($this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE)) { + return true; + } + + $roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE); + + return $user->workspaceMemberships() + ->whereIn('role', $roles) + ->exists(); + } + + private function switchWorkspaceUrl(): string + { + return route('filament.admin.pages.choose-workspace').'?choose=1'; + } +} diff --git a/app/Support/Workspaces/WorkspaceRedirectResolver.php b/app/Support/Workspaces/WorkspaceRedirectResolver.php index f83a60f..bfee999 100644 --- a/app/Support/Workspaces/WorkspaceRedirectResolver.php +++ b/app/Support/Workspaces/WorkspaceRedirectResolver.php @@ -11,9 +11,9 @@ use App\Models\Workspace; /** - * Resolves the redirect URL after a workspace is set. + * Resolves the explicit post-selection destination after a workspace is set. * - * Tenant-count branching (FR-009): + * Tenant-count branching stays available for chooser-driven flows: * - 0 tenants → Managed Tenants index * - 1 tenant → Tenant Dashboard directly * - >1 tenants → Choose Tenant page diff --git a/resources/views/filament/pages/workspace-overview.blade.php b/resources/views/filament/pages/workspace-overview.blade.php new file mode 100644 index 0000000..4447ac7 --- /dev/null +++ b/resources/views/filament/pages/workspace-overview.blade.php @@ -0,0 +1,102 @@ + + @php + $workspace = $overview['workspace'] ?? ['name' => 'Workspace']; + $quickActions = $overview['quick_actions'] ?? []; + $zeroTenantState = $overview['zero_tenant_state'] ?? null; + @endphp + +
+ +
+
+ + + Workspace overview + + + @if (filled($workspace['slug'] ?? null)) + + {{ $workspace['slug'] }} + + @endif +
+ +

+ {{ $workspace['name'] ?? 'Workspace' }} +

+ +

+ This home stays workspace-scoped even when you were previously working in a tenant. Tenant drill-down remains explicit so the overview never silently narrows itself. +

+
+
+ + @if ($quickActions !== []) +
+ @foreach ($quickActions as $action) + +
+
+ +
+ +
+
+ {{ $action['label'] }} +
+
+ {{ $action['description'] }} +
+
+
+
+ @endforeach +
+ @endif + + @if (is_array($zeroTenantState)) +
+
+
+

+ {{ $zeroTenantState['title'] }} +

+

+ {{ $zeroTenantState['body'] }} +

+
+ + + {{ $zeroTenantState['action_label'] }} + +
+
+ @endif + +
+ @livewire(\App\Filament\Widgets\Workspace\WorkspaceSummaryStats::class, [ + 'metrics' => $overview['summary_metrics'] ?? [], + ], key('workspace-overview-summary-' . ($workspace['id'] ?? 'none'))) + +
+ @livewire(\App\Filament\Widgets\Workspace\WorkspaceNeedsAttention::class, [ + 'items' => $overview['attention_items'] ?? [], + 'emptyState' => $overview['attention_empty_state'] ?? [], + ], key('workspace-overview-attention-' . ($workspace['id'] ?? 'none'))) + + @livewire(\App\Filament\Widgets\Workspace\WorkspaceRecentOperations::class, [ + 'operations' => $overview['recent_operations'] ?? [], + 'emptyState' => $overview['recent_operations_empty_state'] ?? [], + ], key('workspace-overview-operations-' . ($workspace['id'] ?? 'none'))) +
+
+
+
diff --git a/resources/views/filament/partials/context-bar.blade.php b/resources/views/filament/partials/context-bar.blade.php index f1a0377..4f5f751 100644 --- a/resources/views/filament/partials/context-bar.blade.php +++ b/resources/views/filament/partials/context-bar.blade.php @@ -70,125 +70,158 @@ $canClearTenantContext = $hasAnyFilamentTenantContext || $lastTenantId !== null; @endphp -
- +@php + $tenantLabel = $currentTenantName ?? 'All tenants'; + $workspaceLabel = $workspace?->name ?? 'Select workspace'; + $hasActiveTenant = $currentTenantName !== null; + $workspaceUrl = $workspace + ? route('admin.home') + : ChooseWorkspace::getUrl(panel: 'admin'); + $tenantTriggerLabel = $workspace ? $tenantLabel : 'Select tenant'; +@endphp + +
+ {{-- Workspace label: standalone link --}} + + + {{ $workspaceLabel }} + + + @if ($workspace) + + @endif + + {{-- Dropdown trigger: tenant label + chevron --}} + - - {{ $workspace?->name ?? 'Select workspace' }} - + + {{ $tenantTriggerLabel }} + + + + - - Switch workspace - - - - -
- - @if (! $workspace) -
- - Select tenant - - -
Choose a workspace first.
-
- @elseif ($isTenantScopedRoute) - - {{ $currentTenantName ?? 'Tenant' }} - - @else - - - - {{ $currentTenantName ?? 'Select tenant' }} - - - - -
-
- Tenant context - @if ($canSeeAllWorkspaceTenants) - · all workspace tenants - @endif +
+ {{-- Workspace section --}} +
+
+ Workspace
- @if ($tenants->isEmpty()) -
- {{ $canSeeAllWorkspaceTenants ? 'No tenants exist in this workspace.' : 'No tenants you can access in this workspace.' }} +
+
+ + {{ $workspaceLabel }}
- @else -
- -
- @foreach ($tenants as $tenant) -
+ + Switch workspace + +
+ + + + Workspace Home + +
+ + @if ($workspace) +
+ + {{-- Tenant section --}} +
+
+
+ Tenant scope +
+ + @if ($canSeeAllWorkspaceTenants) + all visible + @endif +
+ + @if ($isTenantScopedRoute) +
+ + {{ $currentTenantName ?? 'Tenant' }} + locked +
+ @else + @if ($tenants->isEmpty()) +
+ {{ $canSeeAllWorkspaceTenants ? 'No tenants in this workspace.' : 'No accessible tenants.' }} +
+ @else + + +
+ @foreach ($tenants as $tenant) + @php + $isActive = $currentTenantName !== null && $tenant->getFilamentName() === $currentTenantName; + @endphp + + + @csrf + + + + + @endforeach +
+ + @if ($canClearTenantContext) +
@csrf - -
- @endforeach -
- - @if ($canClearTenantContext) -
- @csrf - - - Clear tenant context - -
+ @endif @endif - -
- Switching tenants is explicit. Canonical monitoring URLs do not change tenant context. -
-
- @endif -
- - - @endif + @endif +
+ @else +
+ Choose a workspace first. +
+ @endif +
+ +
diff --git a/resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php b/resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php index 5e4089e..65babbe 100644 --- a/resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php +++ b/resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php @@ -1,12 +1,11 @@ -
-
-
Baseline Governance
- @if ($landingUrl) + + @if ($landingUrl) + Details - @endif -
+ + @endif @if (! $hasAssignment)
@@ -17,52 +16,54 @@
@else - {{-- Profile + last compared --}} -
-
- Baseline: {{ $profileName }} +
+ {{-- Profile + last compared --}} +
+
+ Baseline: {{ $profileName }} +
+ @if ($lastComparedAt) +
{{ $lastComparedAt }}
+ @endif
- @if ($lastComparedAt) -
{{ $lastComparedAt }}
+ + {{-- Findings summary --}} + @if ($findingsCount > 0) + {{-- Critical banner (inline) --}} + @if ($highCount > 0) +
+ + + {{ $highCount }} high-severity {{ Str::plural('finding', $highCount) }} + +
+ @endif + +
+
+ + {{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }} + + + @if ($mediumCount > 0) + + {{ $mediumCount }} medium + + @endif + + @if ($lowCount > 0) + + {{ $lowCount }} low + + @endif +
+
+ @else +
+ + No open drift — baseline compliant +
@endif
- - {{-- Findings summary --}} - @if ($findingsCount > 0) - {{-- Critical banner (inline) --}} - @if ($highCount > 0) -
- - - {{ $highCount }} high-severity {{ Str::plural('finding', $highCount) }} - -
- @endif - -
-
- - {{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }} - - - @if ($mediumCount > 0) - - {{ $mediumCount }} medium - - @endif - - @if ($lowCount > 0) - - {{ $lowCount }} low - - @endif -
-
- @else -
- - No open drift — baseline compliant -
- @endif @endif -
+ diff --git a/resources/views/filament/widgets/dashboard/needs-attention.blade.php b/resources/views/filament/widgets/dashboard/needs-attention.blade.php index de0026b..3e99860 100644 --- a/resources/views/filament/widgets/dashboard/needs-attention.blade.php +++ b/resources/views/filament/widgets/dashboard/needs-attention.blade.php @@ -2,9 +2,8 @@ @if ($pollingInterval) wire:poll.{{ $pollingInterval }} @endif - class="flex flex-col gap-4" > -
Needs Attention
+ @if (count($items) === 0)
@@ -52,4 +51,5 @@ class="rounded-lg bg-gray-50 p-4 text-left transition hover:bg-gray-100 dark:bg- @endforeach
@endif +
diff --git a/resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php b/resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php new file mode 100644 index 0000000..f045063 --- /dev/null +++ b/resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php @@ -0,0 +1,47 @@ + + @if ($items === []) +
+
+

+ {{ $emptyState['title'] ?? 'Nothing urgent in your current scope' }} +

+ +

+ {{ $emptyState['body'] ?? 'Recent operations and alert deliveries look healthy right now.' }} +

+
+ + @if (filled($emptyState['action_url'] ?? null)) +
+ + {{ $emptyState['action_label'] ?? 'Open operations' }} + +
+ @endif +
+ @else + + @endif +
diff --git a/resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php b/resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php new file mode 100644 index 0000000..96e71e4 --- /dev/null +++ b/resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php @@ -0,0 +1,61 @@ + + @if ($operations === []) +
+
+

+ {{ $emptyState['title'] ?? 'No recent operations yet' }} +

+ +

+ {{ $emptyState['body'] ?? 'Workspace-wide activity will show up here once syncs, evaluations, or restores start running.' }} +

+
+ + @if (filled($emptyState['action_url'] ?? null)) +
+ + {{ $emptyState['action_label'] ?? 'Open operations' }} + +
+ @endif +
+ @else + + @endif +
diff --git a/routes/web.php b/routes/web.php index 7790e2d..b2413b9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ get('/admin', function (Request $request) { - $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request); - - $user = $request->user(); - - if (! $user instanceof User) { - return redirect()->to('/admin/choose-workspace'); - } - - if ($workspaceId === null) { - return redirect()->to('/admin/choose-workspace'); - } - - $workspace = Workspace::query()->whereKey($workspaceId)->first(); - - if (! $workspace instanceof Workspace) { - return redirect()->to('/admin/choose-workspace'); - } - - /** @var WorkspaceRedirectResolver $resolver */ - $resolver = app(WorkspaceRedirectResolver::class); - - return redirect()->to($resolver->resolve($workspace, $user)); - }) + ->get('/admin', WorkspaceOverview::class) ->name('admin.home'); Route::get('/admin/rbac/start', [RbacDelegatedAuthController::class, 'start']) diff --git a/specs/129-workspace-admin-home/checklists/requirements.md b/specs/129-workspace-admin-home/checklists/requirements.md new file mode 100644 index 0000000..cd33134 --- /dev/null +++ b/specs/129-workspace-admin-home/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Workspace Home & Admin Landing + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-09 +**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/129-workspace-admin-home/spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation completed against the final draft on 2026-03-09. +- The spec stays at the product and behavior level while still documenting required authorization semantics, canonical route meaning, and bounded overview behavior. \ No newline at end of file diff --git a/specs/129-workspace-admin-home/contracts/workspace-home-routing.openapi.yaml b/specs/129-workspace-admin-home/contracts/workspace-home-routing.openapi.yaml new file mode 100644 index 0000000..9ea4450 --- /dev/null +++ b/specs/129-workspace-admin-home/contracts/workspace-home-routing.openapi.yaml @@ -0,0 +1,272 @@ +openapi: 3.1.0 +info: + title: Workspace Home Routing Contract + version: 1.0.0 + description: >- + Behavioral contract for the canonical workspace-level home, adjacent chooser + routes, and the explicit downstream tenant destination introduced or relied on + by Spec 129. These routes are HTML/admin-panel endpoints, but they are modeled + here so landing and redirect semantics stay explicit. +servers: + - url: https://tenantpilot.test +paths: + /admin: + get: + summary: Open the canonical workspace home + operationId: openWorkspaceHome + tags: + - Workspace Home + responses: + '200': + description: Workspace overview page rendered for an authenticated user with a selected workspace and valid membership + content: + text/html: + schema: + $ref: '#/components/schemas/WorkspaceHomePage' + '302': + description: Redirect to the canonical workspace chooser because no workspace is selected + headers: + Location: + schema: + type: string + enum: + - /admin/choose-workspace + '404': + description: Actor is not entitled to the workspace plane or active workspace scope + /admin/choose-workspace: + get: + summary: Open the workspace selection flow + operationId: openChooseWorkspace + tags: + - Workspace Home + responses: + '200': + description: Workspace chooser rendered + content: + text/html: + schema: + $ref: '#/components/schemas/ChooseWorkspacePage' + '404': + description: Actor cannot access the admin workspace plane + /admin/choose-tenant: + get: + summary: Open the explicit tenant drill-down selector + operationId: openChooseTenant + tags: + - Workspace Home + responses: + '200': + description: Tenant chooser rendered for the active workspace + content: + text/html: + schema: + $ref: '#/components/schemas/ChooseTenantPage' + '302': + description: Redirect to workspace chooser when no workspace is selected + headers: + Location: + schema: + type: string + enum: + - /admin/choose-workspace + '404': + description: Actor is not entitled to the workspace or tenant scope + /admin/t/{tenant}: + get: + summary: Open the explicit tenant-context destination after deliberate tenant choice + operationId: openTenantDashboard + tags: + - Workspace Home + parameters: + - name: tenant + in: path + required: true + schema: + type: string + description: Canonical tenant route key selected from the chooser flow + responses: + '200': + description: Tenant-context destination rendered for a tenant the actor is entitled to access within the active workspace + '302': + description: Redirect to workspace chooser when no workspace is selected for the tenant-context request + headers: + Location: + schema: + type: string + enum: + - /admin/choose-workspace + '404': + description: Actor is not entitled to the workspace or selected tenant scope + /admin/switch-workspace: + post: + summary: Switch the active workspace context + operationId: switchWorkspaceContext + tags: + - Workspace Home + responses: + '302': + description: Workspace context changed, then redirected to the appropriate explicit post-selection destination + '403': + description: Actor is authenticated but not allowed to mutate workspace context through this action + '404': + description: Workspace target is outside actor membership scope +components: + schemas: + WorkspaceHomePage: + type: object + required: + - page + - workspace + - tenantRequired + - navigation + properties: + page: + type: string + const: workspace-home + workspace: + $ref: '#/components/schemas/WorkspaceContext' + tenantRequired: + type: boolean + const: false + summaryMetrics: + type: array + items: + $ref: '#/components/schemas/SummaryMetric' + attentionItems: + type: array + items: + $ref: '#/components/schemas/AttentionItem' + recentOperations: + type: array + items: + $ref: '#/components/schemas/RecentOperationItem' + quickActions: + type: array + items: + $ref: '#/components/schemas/QuickAction' + navigation: + type: object + required: + - overviewVisible + - brandLogoTarget + properties: + overviewVisible: + type: boolean + brandLogoTarget: + type: string + const: /admin + ChooseWorkspacePage: + type: object + required: + - page + properties: + page: + type: string + const: choose-workspace + ChooseTenantPage: + type: object + required: + - page + - workspace + properties: + page: + type: string + const: choose-tenant + workspace: + $ref: '#/components/schemas/WorkspaceContext' + WorkspaceContext: + type: object + required: + - id + - name + properties: + id: + type: integer + name: + type: string + metadata: + type: object + additionalProperties: true + SummaryMetric: + type: object + required: + - key + - label + - value + properties: + key: + type: string + label: + type: string + value: + type: integer + destinationUrl: + type: + - string + - 'null' + AttentionItem: + type: object + required: + - title + - destinationUrl + properties: + title: + type: string + subtitle: + type: + - string + - 'null' + severity: + type: + - string + - 'null' + destinationUrl: + type: string + RecentOperationItem: + type: object + required: + - operationRunId + - title + - status + - destinationUrl + - createdAt + properties: + operationRunId: + type: integer + title: + type: string + status: + type: string + outcome: + type: + - string + - 'null' + tenantLabel: + type: + - string + - 'null' + destinationUrl: + type: string + createdAt: + type: string + format: date-time + QuickAction: + type: object + required: + - key + - label + - url + - kind + properties: + key: + type: string + label: + type: string + url: + type: string + kind: + type: string + enum: + - context + - navigation + - administration \ No newline at end of file diff --git a/specs/129-workspace-admin-home/data-model.md b/specs/129-workspace-admin-home/data-model.md new file mode 100644 index 0000000..e21ed66 --- /dev/null +++ b/specs/129-workspace-admin-home/data-model.md @@ -0,0 +1,203 @@ +# Data Model: Workspace Home & Admin Landing + +**Feature**: 129-workspace-admin-home | **Date**: 2026-03-09 + +## Overview + +This feature introduces no new database tables. It adds a new workspace-level read model and UI composition layer over existing workspace-owned and tenant-owned records. + +The design relies on existing persisted entities plus a few computed overview concepts: + +1. a canonical workspace overview page, +2. capability-safe summary metrics, +3. bounded recent and needs-attention entries, +4. quick actions to existing canonical destinations. + +## Existing Persistent Entities + +### Workspace + +| Attribute | Type | Notes | +|-----------|------|-------| +| `id` | int | Primary workspace identity | +| `name` | string | Primary visible workspace label on the overview | +| `slug` | string nullable | Used in some workspace routes | +| `archived_at` | timestamp nullable | Archived workspaces must not be selectable | + +**Relationships**: +- has many `WorkspaceMembership` +- has many `Tenant` + +**Validation / usage rules**: +- A workspace overview may render only when the workspace is currently selected and the actor remains a valid member. +- Archived or stale workspace context must resolve back through the chooser flow. + +### WorkspaceMembership + +| Attribute | Type | Notes | +|-----------|------|-------| +| `workspace_id` | int | Workspace isolation boundary | +| `user_id` | int | Actor membership | +| `role` | string | Drives capability resolution | + +**Usage rules**: +- Non-members must receive 404 deny-as-not-found for workspace home access. +- Role and capability checks determine which overview surfaces and quick actions may appear. + +### Tenant + +| Attribute | Type | Notes | +|-----------|------|-------| +| `id` | int | Internal identity | +| `external_id` | string | Route key for tenant destinations | +| `workspace_id` | int | Tenant belongs to exactly one workspace | +| `name` | string | Used on chooser and downstream destinations | +| `status` | string | Only active, accessible tenants count toward overview metrics | + +**Usage rules**: +- The workspace overview never requires a selected tenant. +- Tenant counts and tenant-linked actions must reflect only the current user's accessible tenant subset within the active workspace. + +### OperationRun + +| Attribute | Type | Notes | +|-----------|------|-------| +| `workspace_id` | int | Workspace-bound operational scope | +| `tenant_id` | int nullable | Nullable for some workspace-context monitoring cases | +| `status` | string | Used for active/recent operations summaries | +| `outcome` | string nullable | Used for needs-attention or recent-failure signals | +| `created_at` / `updated_at` | timestamps | Used for recency ordering | + +**Usage rules**: +- The overview may show only a bounded, capability-safe subset of operation runs. +- The workspace home must not create, mutate, or transition `OperationRun` records. + +### Alert / Finding Surfaces + +| Concept | Notes | +|--------|-------| +| Alerts | May contribute an alert summary count or urgent list items if a safe workspace-scoped aggregate exists | +| Findings | May contribute one needs-attention metric or list entries if already available safely and cheaply | + +**Usage rules**: +- The overview must not leak existence of unauthorized alerts or findings through counts or list rows. +- These surfaces are optional at render time: if safe aggregation is unavailable, they should be hidden or degrade to empty state. + +## New Computed Read Models + +### WorkspaceOverviewSurface + +| Field | Type | Description | +|------|------|-------------| +| `workspace` | Workspace | Active workspace context shown on the page | +| `tenant_context_active` | bool | Informational only; must not change page scope | +| `summary_metrics` | list | Small KPI set shown at top of page | +| `attention_items` | list | Bounded urgent items | +| `recent_operations` | list | Bounded latest relevant operations | +| `quick_actions` | list | Existing canonical destinations permitted for the actor | + +**Rules**: +- Must render without a selected tenant. +- Must not imply tenant context when `tenant_context_active` is false. +- Must remain complete even when most sub-surfaces are empty. + +### WorkspaceSummaryMetric + +| Field | Type | Description | +|------|------|-------------| +| `key` | string enum | `accessible_tenants`, `active_operations`, `alerts`, `needs_attention` | +| `label` | string | UI label | +| `value` | int | Capability-safe aggregate value | +| `destination_url` | string nullable | Canonical existing route when drill-down is allowed | +| `visible` | bool | Hidden when aggregate is not safe or not authorized | + +**Rules**: +- Value must be cheap to compute and workspace-bounded. +- No metric may reveal unauthorized tenant-owned data. + +### WorkspaceAttentionItem + +| Field | Type | Description | +|------|------|-------------| +| `kind` | string enum | `failed_operation`, `high_alert`, `high_finding`, or equivalent supported type | +| `title` | string | Human-readable urgent label | +| `subtitle` | string nullable | Cheap contextual metadata | +| `severity` | string nullable | Rendered through existing badge semantics where applicable | +| `destination_url` | string | Canonical route | +| `occurred_at` | datetime | Ordering field | + +**Rules**: +- Results must be bounded and ordered by recency or severity. +- Empty state is valid and must render intentionally. + +### WorkspaceRecentOperationItem + +| Field | Type | Description | +|------|------|-------------| +| `operation_run_id` | int | Canonical run identity | +| `title` | string | Operation label | +| `status` | string | Existing operation status | +| `outcome` | string nullable | Existing outcome label | +| `tenant_label` | string nullable | Optional, only when safe to show | +| `destination_url` | string | Canonical run detail or operations destination | +| `created_at` | datetime | Recency ordering | + +**Rules**: +- Must not spoof tenant context if no tenant is selected. +- Result set must be capped. + +### WorkspaceQuickAction + +| Field | Type | Description | +|------|------|-------------| +| `key` | string enum | `choose_tenant`, `operations`, `alerts`, `switch_workspace`, `manage_workspaces` | +| `label` | string | Visible CTA text | +| `url` | string | Canonical existing destination | +| `visible` | bool | Capability-aware rendering flag | +| `kind` | string enum | `context`, `navigation`, `administration` | + +**Rules**: +- `switch_workspace` and `manage_workspaces` must remain separate entries. +- No quick action may introduce a new workflow in this feature. + +## State and Transition Notes + +### Workspace access state + +```text +No selected workspace + -> redirect to choose-workspace + +Selected workspace + valid membership + -> render workspace overview at /admin + +Selected workspace + stale membership + -> if the actor still has at least one valid workspace membership, clear stale session and redirect to choose-workspace + -> if the actor is no longer entitled to the active workspace scope, return 404 deny-as-not-found +``` + +### Tenant context behavior + +```text +Selected workspace + no selected tenant + -> render workspace overview normally + +Selected workspace + selected tenant + -> still render workspace overview at /admin + -> tenant-specific work remains explicit through choose-tenant or /admin/t/{tenant} +``` + +## Validation Rules + +| Rule | Result | +|------|--------| +| `/admin` means workspace home, not tenant branching | Required | +| Workspace home must render without tenant context | Required | +| Non-members receive 404 semantics | Required | +| In-scope unauthorized actions or destinations remain hidden or 403 at target | Required | +| Overview metrics and lists are capability-safe and bounded | Required | +| Empty states remain intentional and complete | Required | + +## Schema Impact + +No schema migration is expected for this feature. \ No newline at end of file diff --git a/specs/129-workspace-admin-home/plan.md b/specs/129-workspace-admin-home/plan.md new file mode 100644 index 0000000..b07e113 --- /dev/null +++ b/specs/129-workspace-admin-home/plan.md @@ -0,0 +1,238 @@ +# Implementation Plan: Workspace Home & Admin Landing + +**Branch**: `129-workspace-admin-home` | **Date**: 2026-03-09 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/129-workspace-admin-home/spec.md` + +## Summary + +Introduce a real workspace-level home at `/admin` inside the existing admin panel by replacing the current redirect-only landing behavior with a dedicated Filament overview page. Keep workspace selection as the only precondition, preserve explicit tenant drill-down through `/admin/choose-tenant` and `/admin/t/{tenant}`, reuse existing workspace-safe monitoring and management destinations for quick actions, and implement bounded capability-safe overview widgets so the admin panel becomes the canonical workspace entry point without adding a new business panel. + +## Technical Context + +**Language/Version**: PHP 8.4.15 / Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4.0+, Tailwind CSS v4 +**Storage**: PostgreSQL via Laravel Sail plus session-backed workspace and tenant context +**Testing**: Pest v4 feature tests on PHPUnit 12 +**Target Platform**: Laravel Sail web application with admin panel at `/admin` and tenant panel at `/admin/t/{tenant}` +**Project Type**: Laravel monolith / Filament web application +**Performance Goals**: `/admin` remains DB-only at render time, overview queries are bounded and eager-loaded, and no uncontrolled polling is introduced +**Constraints**: Keep the existing admin panel as the workspace panel; preserve workspace chooser gating; keep tenant selection explicit; avoid schema changes; avoid broad middleware refactors; enforce 404 for non-members and 403 for in-scope capability denial +**Scale/Scope**: One new workspace overview page, a small set of workspace-safe widgets or overview sections, landing and navigation semantic changes, and focused workspace-routing and authorization regression tests + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: PASS — no inventory or snapshot semantics change; the feature is a workspace-context view and routing correction. +- Read/write separation: PASS — no new write flow is introduced; the workspace home is read-only and links to existing destinations. +- Graph contract path: PASS — no Microsoft Graph calls are added. +- Deterministic capabilities: PASS — overview surfaces can be derived from existing workspace membership and canonical capability checks; no raw capability strings are needed. +- RBAC-UX planes and isolation: PASS — the feature stays in the `/admin` workspace plane, keeps `/system` separate, and must maintain 404 deny-as-not-found for non-members and 403 for in-scope capability denial on protected targets. +- Workspace isolation: PASS — `/admin` remains workspace-gated through `EnsureWorkspaceSelected`, and overview access remains workspace-member only. +- RBAC-UX destructive confirmation: PASS / N/A — the overview itself introduces no destructive action. +- RBAC-UX global search: PASS — no new searchable resource is added; global-search semantics remain unchanged. +- Tenant isolation: PASS — the overview stays tenantless by URL and must only surface tenant-owned aggregates through capability-safe, workspace-bounded query paths. +- Run observability: PASS / N/A — the workspace home creates no `OperationRun`; it only reads existing operational data. +- Ops-UX 3-surface feedback: PASS / N/A — no new operation lifecycle is introduced. +- Ops-UX lifecycle and summary counts: PASS / N/A — no `OperationRun` state transition or summary-count producer is added. +- Ops-UX guards and system runs: PASS / N/A — existing operations behavior is unaffected. +- Automation: PASS / N/A — no queued or scheduled workflow changes are required. +- Data minimization: PASS — overview data remains DB-derived, bounded, and free of secrets or raw payload logging. +- Badge semantics (BADGE-001): PASS — any reused alert, finding, or operations badges must come from the existing badge catalog and renderer. +- Filament UI Action Surface Contract: PASS — the new workspace overview is a view-style page with inspection and navigation affordances only; it introduces no bulk or destructive mutation surface. +- Filament UI UX-001: PASS — the page will be built as a sectioned workspace overview using cards or widgets with intentional empty states, not as a bare redirect or naked control surface. +- Filament v5 / Livewire v4 compliance: PASS — the feature stays inside the existing Filament v5 and Livewire v4 admin panel. +- Provider registration (`bootstrap/providers.php`): PASS — no new panel provider is introduced; the existing admin panel provider remains registered in `bootstrap/providers.php`. +- Global search resource rule: PASS — no new Resource is added, so the Edit/View global-search rule does not change. +- Asset strategy: PASS — no new heavy or shared asset bundle is required; current deployment behavior, including `php artisan filament:assets`, remains unchanged. +## Project Structure + +### Documentation (this feature) + +```text +specs/129-workspace-admin-home/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── workspace-home-routing.openapi.yaml +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root) +```text +app/ +├── Filament/ +│ ├── Pages/ +│ │ ├── ChooseWorkspace.php # reference-only gating flow +│ │ ├── ChooseTenant.php # reference-only explicit tenant drill-down +│ │ └── WorkspaceOverview.php # NEW canonical workspace home +│ ├── Pages/ +│ │ └── Monitoring/ +│ │ ├── Operations.php # reference-only canonical destination +│ │ └── Alerts.php # reference-only canonical destination +│ └── Widgets/ +│ ├── Dashboard/ # reference patterns for dashboard cards/lists +│ └── Workspace/ # NEW workspace-safe overview widgets if split from page +├── Providers/ +│ └── Filament/ +│ └── AdminPanelProvider.php # MODIFY — home/nav/logo semantics +├── Support/ +│ ├── Middleware/ +│ │ └── EnsureFilamentTenantSelected.php # MODIFY if workspace-safe nav handling needs expansion +│ └── Workspaces/ +│ ├── WorkspaceContext.php # reference-only current workspace state +│ └── WorkspaceRedirectResolver.php # MODIFY or narrow to explicit post-selection flows only +routes/ +└── web.php # MODIFY — `/admin` route semantics +resources/ +└── views/ + └── filament/ + ├── admin/ # reference-only brand logo view + └── pages/ # NEW workspace overview Blade view if page uses custom layout +tests/ +└── Feature/ + ├── Filament/ + │ ├── AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php # MODIFY or replace legacy expectations + │ ├── LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php # MODIFY for new `/admin` home behavior + │ └── WorkspaceOverview*Test.php # NEW focused landing/visibility tests + ├── Monitoring/ # reference-only operations/alerts access tests + └── Guards/ # reference-only workspace/scope guard tests +``` + +**Structure Decision**: Keep the feature inside the existing Laravel/Filament monolith. The implementation is a focused workspace-plane home page and routing change backed by small workspace-safe overview widgets or sections, existing monitoring/workspace destinations, and targeted Pest feature tests. + +## Complexity Tracking + +> No Constitution Check violations. No justifications needed. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| — | — | — | + +## Phase 0 — Research (DONE) + +Output: +- `specs/129-workspace-admin-home/research.md` + +Key findings captured: +- `/admin` is currently a manual route closure that delegates to `WorkspaceRedirectResolver`, so the admin panel has no durable home page today. +- `EnsureWorkspaceSelected` should remain the workspace-selection gate, but direct `/admin` entry without workspace context must become chooser-first while tenant branching stops being the default meaning of `/admin`. +- Existing operations and alerts pages are already workspace-safe by route shape and middleware exceptions, which makes them suitable canonical quick-action targets. +- The admin panel already uses the brand logo view and primary navigation wiring needed to make the workspace home the canonical top-level destination. + +## Phase 1 — Design & Contracts (DONE) + +Outputs: +- `specs/129-workspace-admin-home/data-model.md` +- `specs/129-workspace-admin-home/contracts/workspace-home-routing.openapi.yaml` +- `specs/129-workspace-admin-home/quickstart.md` + +Design highlights: +- Add a dedicated `WorkspaceOverview` Filament page at `/admin` instead of treating `/admin` as a redirect shim. +- Preserve workspace selection gating through `EnsureWorkspaceSelected`, but constrain direct `/admin` requests without workspace context to chooser-first behavior and keep `WorkspaceRedirectResolver` for explicit post-selection or legacy flows, not canonical admin-home access. +- Implement overview content as capability-safe, bounded workspace metrics and lists that link only to existing canonical destinations. +- Keep “Switch workspace” and “Manage workspaces” distinct in both navigation semantics and authorization behavior. + +## Phase 1 — Agent Context Update (DONE) + +Run: +- `.specify/scripts/bash/update-agent-context.sh copilot` + +## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`) + +### Step 1 — Introduce the canonical workspace overview page + +Goal: implement FR-129-01 through FR-129-07 and FR-129-18 through FR-129-20. + +Changes: +- Create a new admin-panel page class for the workspace overview and register it so `/admin` becomes a real page, not a redirect-only closure. +- Render a calm workspace-level layout with heading, workspace context, structured cards or sections, and explicit empty states. +- Ensure the page is clearly workspace-scoped and makes no tenant context claim when no tenant is active. + +Tests: +- Add focused feature coverage proving `/admin` renders the workspace overview when a workspace is selected. +- Add page-access tests proving non-members receive 404 semantics and valid workspace members can access the page without a selected tenant, even when they lack most workspace capabilities. + +### Step 2 — Rewire admin landing and preserve chooser gating + +Goal: implement FR-129-02 through FR-129-05, FR-129-15, and FR-129-16. + +Changes: +- Update `/admin` route and panel-home semantics so the workspace overview is the canonical admin landing target. +- Preserve `EnsureWorkspaceSelected` as the workspace-selection precondition for admin entry while updating direct `/admin` without workspace context to use chooser-first semantics. +- Update `EnsureWorkspaceSelected` and narrow `WorkspaceRedirectResolver` usage so chooser-driven and explicit post-selection flows can still branch deliberately without defining normal `/admin` behavior or bypassing chooser-first semantics on direct `/admin` access. +- Ensure admin-panel brand-logo clicks and future workspace-return links resolve back to `/admin`. + +Tests: +- Replace or update legacy tests that currently expect `/admin` to redirect to choose-tenant, tenant dashboard, or managed-tenants index. +- Add coverage for direct `/admin` without an established workspace context continuing to choose-workspace, including single-membership and last-used-workspace auto-resume edge cases. +- Add coverage for login or post-auth admin entry landing on workspace home once workspace context exists. + +### Step 3 — Register primary navigation and preserve workspace-safe panel chrome + +Goal: implement FR-129-06, FR-129-13, and FR-129-14. + +Changes: +- Add a stable `Overview` navigation item as the first or first relevant item in admin navigation. +- Keep “Switch workspace” as a context action and “Manage workspaces” as an authorized admin destination. +- Adjust workspace-safe navigation building where needed so the overview remains visible and coherent when no tenant is selected. + +Tests: +- Add response-level or page-level assertions proving `Overview` is visible in admin navigation. +- Add coverage proving “Manage workspaces” appears only for authorized users while “Switch workspace” remains a separate context action. + +### Step 4 — Implement workspace-safe summary, attention, and recent-operations surfaces + +Goal: implement FR-129-08 through FR-129-12, FR-129-17, FR-129-21, FR-129-22, FR-129-23, FR-129-24, and FR-129-25. + +Changes: +- Create a minimal workspace KPI surface for accessible tenants, active operations, alerts, and one needs-attention metric only when each can be computed safely and cheaply. +- Add a bounded recent-operations surface reusing canonical operations destinations. +- Add a bounded needs-attention surface based on existing alert, finding, or operation failure signals. +- Add capability-aware quick actions to choose tenant, open operations, open alerts, switch workspace, and manage workspaces when authorized. +- Keep list lengths capped and explicitly disable polling by default or set a documented conservative interval only for a lightweight surface that justifies it. + +Tests: +- Add feature or widget tests proving low-permission users do not see unauthorized counts or shortcuts while retaining page access as workspace members. +- Add empty-state coverage for no tenants, no recent operations, and no urgent items. +- Add assertions that overview lists are bounded, that no tenant is required for page render, and that overview widgets do not introduce uncontrolled polling by default. + +### Step 5 — Verify route semantics and contain regressions + +Goal: implement the acceptance criteria around stable canonical meaning and regression safety. + +Changes: +- Audit every call site currently relying on `WorkspaceRedirectResolver` and classify it as “keep explicit branching” versus “use workspace home instead.” +- Ensure tenant selection still occurs only where tenant context is actually required. +- Confirm existing operations and alerts routes remain canonical destinations from the workspace home. + +Tests: +- Add regression coverage proving normal `/admin` access no longer silently redirects into tenant context. +- Preserve or extend chooser and tenant-selection tests so explicit drill-down still works after the home change. + +### Step 6 — Polish layout and finalize verification + +Goal: meet the UX-001 layout expectations and page-quality requirements in the spec. + +Changes: +- Review section hierarchy, spacing, labels, empty-state copy, quick-action grouping, and badge usage against UX-001. +- Ensure overview content remains enterprise-style, calm, and clearly workspace-level. + +Tests: +- Run focused Pest coverage for landing, authorization, low-data, and nav semantics. +- Run Pint on dirty files through Sail during implementation. + +## Constitution Check (Post-Design) + +Re-check result: PASS. + +- Livewire v4.0+ compliance: preserved because the design remains inside the existing Filament v5 / Livewire v4 admin panel. +- Provider registration location: unchanged; the existing `App\Providers\Filament\AdminPanelProvider` remains registered in `bootstrap/providers.php`. +- Globally searchable resources: unchanged; no new Resource is introduced. +- Destructive actions: unchanged; the workspace overview adds navigation and inspection affordances only. +- Asset strategy: unchanged; no new heavy assets are introduced, and current deploy-time `php artisan filament:assets` behavior remains sufficient. +- Testing plan: add or update focused Pest feature coverage for `/admin` landing semantics, workspace selection gate preservation, admin navigation visibility, capability-safe overview rendering, low-permission/empty-state behavior, and regression protection against silent tenant redirects. diff --git a/specs/129-workspace-admin-home/quickstart.md b/specs/129-workspace-admin-home/quickstart.md new file mode 100644 index 0000000..e2a6294 --- /dev/null +++ b/specs/129-workspace-admin-home/quickstart.md @@ -0,0 +1,70 @@ +# Quickstart: Workspace Home & Admin Landing + +**Feature**: 129-workspace-admin-home | **Date**: 2026-03-09 + +## Scope + +This feature turns `/admin` into the canonical workspace-level home by: + +- adding a real workspace overview page, +- preserving workspace selection as the only precondition, +- stopping normal `/admin` access from silently redirecting into tenant context, +- exposing a primary `Overview` navigation item, +- adding bounded workspace-safe summary, attention, and recent-operation surfaces, +- keeping `Switch workspace` and `Manage workspaces` distinct. + +## Implementation order + +1. Create the new workspace overview page under the existing admin panel. +2. Update `/admin` landing behavior so selected-workspace requests render the overview instead of delegating to tenant branching. +3. Constrain direct `/admin` requests without workspace context to chooser-first behavior in the workspace-selection middleware. +4. Register `Overview` as the first relevant admin navigation item and confirm brand-logo navigation resolves back to `/admin`. +5. Add capability-safe summary cards and bounded recent or needs-attention surfaces using existing canonical destinations. +6. Add quick actions for choose tenant, operations, alerts, switch workspace, and manage workspaces when authorized. +7. Update or replace legacy tests that currently assert `/admin` redirects to tenant chooser or tenant dashboard. +8. Run focused Sail-based tests. +9. Run Pint on dirty files. + +## Reference files + +- [app/Providers/Filament/AdminPanelProvider.php](../../../app/Providers/Filament/AdminPanelProvider.php) +- [routes/web.php](../../../routes/web.php) +- [app/Http/Middleware/EnsureWorkspaceSelected.php](../../../app/Http/Middleware/EnsureWorkspaceSelected.php) +- [app/Support/Workspaces/WorkspaceRedirectResolver.php](../../../app/Support/Workspaces/WorkspaceRedirectResolver.php) +- [app/Filament/Pages/ChooseWorkspace.php](../../../app/Filament/Pages/ChooseWorkspace.php) +- [app/Filament/Pages/ChooseTenant.php](../../../app/Filament/Pages/ChooseTenant.php) +- [app/Support/Middleware/EnsureFilamentTenantSelected.php](../../../app/Support/Middleware/EnsureFilamentTenantSelected.php) +- [tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php](../../../tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php) +- [tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php](../../../tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php) +- [tests/Feature/Filament/WorkspaceOverviewLandingTest.php](../../../tests/Feature/Filament/WorkspaceOverviewLandingTest.php) +- [tests/Feature/Filament/WorkspaceOverviewNavigationTest.php](../../../tests/Feature/Filament/WorkspaceOverviewNavigationTest.php) +- [tests/Feature/Filament/WorkspaceOverviewContentTest.php](../../../tests/Feature/Filament/WorkspaceOverviewContentTest.php) +- [tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php](../../../tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php) +- [tests/Feature/Filament/WorkspaceOverviewOperationsTest.php](../../../tests/Feature/Filament/WorkspaceOverviewOperationsTest.php) +- [tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php](../../../tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php) +- [tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php](../../../tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php) + +## Suggested validation commands + +```bash +vendor/bin/sail artisan test --compact tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewLandingTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewNavigationTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewContentTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewOperationsTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/ChooseTenantRequiresWorkspaceTest.php +vendor/bin/sail artisan test --compact tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php +vendor/bin/sail bin pint --dirty --format agent +``` + +## Expected outcome + +- `/admin` is a real workspace-level home whenever a workspace is selected. +- Direct `/admin` access without workspace context goes to choose-workspace instead of silently auto-resuming into tenant context. +- The admin brand logo and primary navigation lead back to the workspace home. +- Workspace home content is capability-aware, tenantless by default, bounded, and non-polling by default. +- Legacy tenant-forcing assumptions are contained to explicit chooser or tenant-required flows. \ No newline at end of file diff --git a/specs/129-workspace-admin-home/research.md b/specs/129-workspace-admin-home/research.md new file mode 100644 index 0000000..ff161fa --- /dev/null +++ b/specs/129-workspace-admin-home/research.md @@ -0,0 +1,73 @@ +# Research: Workspace Home & Admin Landing + +**Feature**: 129-workspace-admin-home | **Date**: 2026-03-09 + +## R1: Canonical meaning of `/admin` + +**Decision**: Make `/admin` render a dedicated workspace overview page whenever a workspace is already selected, and require chooser-first semantics for direct `/admin` access when no workspace is established. + +**Rationale**: The current implementation treats `/admin` as a manual redirect shim in `routes/web.php`, immediately delegating to `WorkspaceRedirectResolver`. That behavior conflicts directly with the feature requirement that `/admin` be a stable, tenantless workspace home. The existing `EnsureWorkspaceSelected` middleware currently auto-resumes a single or last-used workspace, so direct `/admin` entry needs a targeted adjustment: keep workspace gating, but stop auto-resume from bypassing chooser-first semantics on the canonical home route. + +**Alternatives considered**: +- Keep `/admin` as a redirect closure and add a separate `/admin/overview` page: rejected because it would preserve the current architectural confusion and fail the requirement that `/admin` itself be the canonical workspace home. +- Continue branching on tenant count through `WorkspaceRedirectResolver`: rejected because it keeps tenant context as the default destination and prevents `/admin` from being a durable return target. + +## R2: Where the workspace home should live + +**Decision**: Add the workspace home as a new page inside the existing admin Filament panel rather than introducing a new panel or standalone Blade route. + +**Rationale**: The existing admin panel provider already owns the `/admin` path, navigation, brand logo, workspace-aware panel chrome, and authenticated route registration. A Filament page keeps the feature aligned with Filament v5 and Livewire v4 conventions, preserves brand-logo semantics, and allows the overview to participate in panel navigation and authorization consistently. + +**Alternatives considered**: +- Create a third “workspace panel”: rejected because the spec explicitly forbids a new business panel and the current admin panel already represents workspace-level context. +- Build a standalone Laravel route and Blade view outside Filament: rejected because it would bypass existing panel navigation, page authorization patterns, and brand/home semantics. + +## R3: How to keep overview data capability-safe + +**Decision**: Treat workspace membership as the page-entry rule for the workspace overview, then derive every metric, list, and quick action from capability checks and suppress any surface whose safe aggregate or canonical destination cannot be authorized. + +**Rationale**: The constitution requires deny-as-not-found for non-members and forbids aggregate leakage across unauthorized tenant scope. The workspace home also has an explicit low-permission requirement, so a valid workspace member must still be able to open the page even if most surfaces collapse to empty or hidden states. Capabilities therefore belong on sub-surfaces and destinations, not on page entry for valid members. + +**Alternatives considered**: +- Show broad workspace totals first and rely on destination pages to enforce authorization later: rejected because summary counts themselves can leak unauthorized scope. +- Reuse tenant-bound dashboard widgets unchanged: rejected because tenant-bound widgets may assume `Filament::getTenant()` or imply tenant context when none is active. + +## R4: Workspace-safe destinations and quick-action strategy + +**Decision**: Reuse existing canonical destinations for operations, alerts, tenant selection, workspace switching, and workspace management instead of introducing any new workflow in the home page. + +**Rationale**: The codebase already contains workspace-safe routes and pages for operations and alerts under `/admin`, explicit chooser pages for workspace and tenant selection, and a dedicated workspace management resource. Reusing those destinations keeps the home page lightweight, preserves semantic separation between “Switch workspace” and “Manage workspaces,” and avoids scope creep. + +**Alternatives considered**: +- Add inline workflow actions directly on the home page: rejected because the spec excludes new workflows and the action surface should remain primarily navigational. +- Link all quick actions through tenant-aware destinations even when no tenant is selected: rejected because it would reintroduce implicit tenant forcing. + +## R5: Handling existing redirect helpers without breaking flows + +**Decision**: Narrow `WorkspaceRedirectResolver` so it remains the chooser-driven and explicit post-selection branching helper, but stop using it as the canonical meaning of normal admin-home access and stop direct `/admin` requests without workspace context from bypassing the chooser through middleware auto-resume. + +**Rationale**: The resolver is still needed for flows like selecting a workspace from the chooser or other explicit “continue into work” branches. Removing it entirely would create unnecessary regression risk. The real change is semantic: `/admin` should no longer call the resolver by default after workspace selection has already been established, and direct admin-home entry without workspace context should route through chooser-first semantics instead of auto-resuming silently. + +**Alternatives considered**: +- Delete the resolver and rewrite all workspace post-selection behavior around the new home: rejected because it broadens scope and risks breaking explicit chooser flows that still benefit from tenant-count branching. +- Leave every existing call site untouched: rejected because the `/admin` landing route itself must stop forcing tenant branching. + +## R6: Polling and performance stance for the first workspace home + +**Decision**: Default to no polling on the workspace overview and use only bounded DB-backed queries with explicit result caps for recent or urgent lists. + +**Rationale**: The spec explicitly forbids uncontrolled heavy polling and broad workspace-wide aggregation. Existing monitoring surfaces already provide deeper operational visibility where live state matters more. The workspace home should focus on fast orientation, not live dashboard behavior. + +**Alternatives considered**: +- Reuse existing dashboard widgets with their current polling behavior: rejected because polling assumptions may be tenant-bound or too chatty for a workspace home. +- Add high-frequency refresh to keep the overview “live”: rejected because it creates avoidable load and is unnecessary for the feature’s orientation goal. + +## R7: Testing strategy for the new canonical home + +**Decision**: Use focused Pest feature tests at the HTTP and page level to cover `/admin` landing semantics, chooser preservation, workspace-safe rendering, navigation visibility, and low-permission behavior. + +**Rationale**: The existing codebase already has feature tests around `/admin`, workspace selection, and chooser behavior. The home change is primarily a route, page, and authorization semantic shift, so focused feature coverage is the lowest-cost and most stable regression guard. + +**Alternatives considered**: +- Rely only on existing redirect tests and manually inspect the UI: rejected because the legacy redirect tests currently encode the wrong behavior for this spec. +- Jump directly to browser tests: rejected because the key risks are routing and authorization semantics, which are already well covered by feature-test infrastructure. \ No newline at end of file diff --git a/specs/129-workspace-admin-home/spec.md b/specs/129-workspace-admin-home/spec.md new file mode 100644 index 0000000..db276d6 --- /dev/null +++ b/specs/129-workspace-admin-home/spec.md @@ -0,0 +1,180 @@ +# Feature Specification: Workspace Home & Admin Landing + +**Feature Branch**: `129-workspace-admin-home` +**Created**: 2026-03-09 +**Status**: Draft +**Input**: User description: "Spec 129 — Workspace Home & Admin Landing: Introduce a real workspace home at /admin and make the admin panel the canonical workspace-level entry point" + +## Spec Scope Fields + +- **Scope**: canonical-view +- **Primary Routes**: + - `/admin` as workspace-level home + - `/admin/choose-workspace` as workspace selection gate + - `/admin/choose-tenant` as explicit tenant drill-down entry + - `/admin/t/{tenant}` as tenant dashboard destination after deliberate tenant choice +- **Data Ownership**: + - Workspace-owned: workspace context, workspace-scoped navigation semantics, workspace overview aggregates, workspace-level recent activity surfaces + - Tenant-owned but workspace-filtered: recent operations, alerts, findings, and tenant counts surfaced only as permitted workspace-scoped summaries + - No new business data domain is introduced; this feature only adds a canonical workspace presentation layer over existing authorized data +- **RBAC**: + - Workspace membership remains the access prerequisite for the admin panel home + - Overview cards, counts, lists, and quick actions are further constrained by the user's existing workspace or tenant capabilities + - Non-members or users outside the active workspace receive 404 deny-as-not-found behavior + - In-scope members lacking a required capability for a linked action or data surface receive 403 on the protected target and do not see unauthorized shortcuts or aggregates on the overview itself + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: The workspace home remains workspace-scoped even if a tenant was selected earlier in the session. It must not silently prefilter itself into one tenant or impersonate tenant context. Tenant-aware links may preserve deliberate drill-down targets, but the overview itself stays tenantless. +- **Explicit entitlement checks preventing cross-tenant leakage**: Every aggregate or list on the workspace home must be derived from capability-aware query paths limited to the active workspace and the user's authorized tenant subset. Cross-plane and cross-workspace access remains deny-as-not-found. + +## User Scenarios & Testing + +### User Story 1 - Land on a real workspace home (Priority: P1) + +As a workspace user, I want `/admin` to open a meaningful workspace overview so I always have a clear starting point and return target that is not tied to a tenant. + +**Why this priority**: The feature only delivers value if the admin panel stops acting like a redirect shim and becomes a durable workspace-level home. + +**Independent Test**: Can be fully tested by opening `/admin` with a selected workspace and verifying that the user lands on a workspace overview page instead of being pushed into a tenant dashboard. + +**Acceptance Scenarios**: + +1. **Given** a signed-in user with an already selected workspace, **When** they open `/admin`, **Then** they see the workspace overview page. +2. **Given** a signed-in user with an already selected workspace, **When** they click the admin-panel brand logo, **Then** they return to the workspace overview page. +3. **Given** a signed-in user with no selected tenant but a selected workspace, **When** they open `/admin`, **Then** the workspace overview still renders fully without requiring tenant selection. + +--- + +### User Story 2 - Reorient and continue work from workspace context (Priority: P2) + +As a workspace user, I want the workspace home to show current context, important workspace-wide signals, and safe next actions so I can decide where to go next without guessing. + +**Why this priority**: After landing semantics are fixed, the page must still be useful enough to justify becoming the canonical workspace home. + +**Independent Test**: Can be fully tested by rendering the workspace home for a user with normal access and verifying that it shows workspace identity, bounded summary surfaces, recent operational visibility, and quick actions to existing destinations. + +**Acceptance Scenarios**: + +1. **Given** a user with authorized access to multiple workspace surfaces, **When** the workspace home loads, **Then** it shows workspace identity, summary cards, at least one recent or needs-attention surface, and quick actions to existing flows. +2. **Given** a workspace with little or no activity, **When** the workspace home loads, **Then** it shows intentional empty states instead of blank or broken sections. +3. **Given** a workspace where no tenant is active, **When** the workspace home loads, **Then** it still presents workspace-scoped orientation and tenant-selection as a deliberate next step rather than an automatic redirect. + +--- + +### User Story 3 - See only permitted workspace surfaces (Priority: P3) + +As a minimally privileged workspace user, I want the workspace home to expose only the data and actions I am allowed to use so that the page remains safe, accurate, and non-misleading. + +**Why this priority**: The overview becomes the canonical home, so it must uphold authorization boundaries and avoid leaking data through aggregates or shortcuts. + +**Independent Test**: Can be fully tested by loading the workspace home under low-permission and non-member scenarios and verifying both safe rendering and deny-as-not-found behavior where required. + +**Acceptance Scenarios**: + +1. **Given** a workspace member with limited permissions, **When** they open the workspace home, **Then** unauthorized cards, counts, and shortcuts are hidden or replaced by safe empty states. +2. **Given** a user outside the active workspace, **When** they attempt to access `/admin`, **Then** access is denied with workspace-scope not-found semantics. +3. **Given** a user who may see some workspace-level content but not administrative workspace management, **When** the workspace home renders, **Then** “Switch workspace” may appear while “Manage workspaces” remains absent. + +### Edge Cases + +- A user with workspace membership but zero accessible tenants must still see a complete workspace home with tenant-related surfaces hidden or empty and a clear next action. +- A workspace with no recent operations, no urgent issues, and no alerts must still render a composed overview with intentional empty states. +- If a session is restored with a previously selected workspace, direct open of `/admin` must land on workspace home immediately rather than replaying an old tenant redirect path. +- Legacy redirect helpers that assume `/admin` means “enter tenant context now” must no longer force a tenant dashboard during normal admin-home access. +- If a user loses authorization between sessions, `/admin` must not reveal workspace identity or counts before authorization is re-evaluated. + +## Requirements + +**Constitution alignment (required):** This feature introduces no Microsoft Graph calls, no new write workflow, and no new queued or scheduled operation. It is a presentation and routing semantics feature over existing authorized workspace and tenant-scoped data. No new `OperationRun` or `AuditLog` contract is required beyond existing behavior for any linked destinations. + +**Constitution alignment (OPS-UX):** Not applicable. The workspace home does not create or mutate `OperationRun` records. Existing operation data may be summarized or linked, but status transitions, progress handling, and terminal notifications remain owned by the existing operations implementation. + +**Constitution alignment (RBAC-UX):** This feature changes authorization behavior in the admin workspace plane at `/admin` and consumes tenant-context data only as capability-safe summaries. Non-members or users outside the workspace remain 404 deny-as-not-found. In-scope members missing a capability for a protected destination or action receive 403 at that destination, while the overview must suppress unauthorized links and aggregates. Authorization remains server-side through existing Gates, Policies, and capability registry lookups. No raw capability strings or role-string checks may be introduced. If the overview links to any destructive flow in the future, that target must already enforce confirmation; this feature itself introduces no destructive action. + +**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication-handshake or monitoring exception behavior is changed. + +**Constitution alignment (BADGE-001):** If the overview reuses status-like badges for alerts, findings, or operations, it must consume the existing centralized badge semantics rather than introducing page-local mappings. Tests should verify any newly surfaced badge state remains consistent with existing platform meaning. + +**Constitution alignment (Filament Action Surfaces):** This feature adds or modifies Filament page and panel chrome behavior, so the UI Action Matrix below applies. The Action Surface Contract remains satisfied because the page primarily exposes navigation and inspection affordances, introduces no destructive mutation, and keeps all actionable destinations behind their existing authorization rules. + +**Constitution alignment (UX-001 — Layout & Information Architecture):** The new workspace home must comply with UX-001 using structured sections or cards for all content, a calm enterprise layout, explicit empty states, and no naked controls. It is a view-style overview surface, not a disabled edit form. Any reused tables or lists must keep search, sort, or filtering expectations only where they are core to the reused destination rather than overloaded into the home screen. + +### Functional Requirements + +- **FR-129-01 Canonical workspace home**: The system must expose a canonical workspace-level home at `/admin` inside the existing admin panel. +- **FR-129-02 Workspace selection gate preservation**: If no workspace is established for the current request, direct open of `/admin` must continue through the canonical workspace selection flow rather than auto-branching into tenant context. +- **FR-129-03 Selected-workspace landing**: If a workspace is selected, opening `/admin` must render the workspace overview instead of redirecting to a tenant dashboard. +- **FR-129-04 No implicit tenant requirement**: The workspace overview must render and remain useful without an active tenant selection. +- **FR-129-05 Stable landing semantics**: Direct open of `/admin`, post-login landing into the admin panel, brand-logo navigation in the admin panel, and future explicit “Back to workspace” targets must all resolve to the workspace overview when a workspace is already selected. +- **FR-129-06 Primary navigation entry**: The admin panel must register a visible, stable primary navigation entry for the workspace home using an obvious label such as “Overview,” and it must appear as the first or first relevant workspace-level item. +- **FR-129-07 Workspace identity visibility**: The workspace overview must clearly show the active workspace identity, including workspace name and any cheap, already-available secondary metadata judged useful. +- **FR-129-08 Capability-safe overview rendering**: Every card, count, list item, widget, and quick action on the workspace home must be derived from data or routes the current user is authorized to access. +- **FR-129-09 No unauthorized aggregate leakage**: The overview must not show counts, summaries, or existence hints derived from tenants, alerts, findings, or operations outside the user's authorized scope. +- **FR-129-10 Summary KPI set**: The workspace home must expose a focused set of high-value, workspace-scoped summary cards that can include accessible tenant count, active operations count, alert summary count, and one needs-attention count, but only when each metric is safe and inexpensive to compute. +- **FR-129-11 Bounded needs-attention surface**: The workspace home must expose a bounded “Needs attention” surface for urgent, permitted workspace-level items and degrade cleanly to an empty state when none exist. +- **FR-129-12 Bounded recent operations surface**: The workspace home must expose a bounded recent operations surface showing only relevant, permitted operations with links to canonical destinations. +- **FR-129-13 Quick actions to existing flows**: The workspace home must provide quick actions to existing destinations such as choose tenant, operations, alerts, switch workspace, and manage workspaces when authorized. +- **FR-129-14 Switch versus manage separation**: The workspace home must keep “Switch workspace” distinct from “Manage workspaces” in label, purpose, and authorization behavior. +- **FR-129-15 Brand-logo behavior**: In the admin panel, brand-logo navigation must point to `/admin`, which now resolves to the workspace overview. +- **FR-129-16 Redirect helper alignment**: Existing admin-panel redirect helpers, home resolvers, and entry-point logic must be updated or constrained so `/admin` is no longer treated as an automatic jump into tenant context. +- **FR-129-17 Intentional empty states**: The workspace home must include useful empty states for no accessible tenants, no recent operations, no alerts or findings requiring attention, and minimal-permission users. +- **FR-129-18 No cross-scope deception**: The workspace home must make it visually clear that it is a workspace-level surface and must not imply an active tenant context when none is selected. +- **FR-129-19 Explicit authorization**: Access to the workspace overview page itself must be protected by explicit admin-panel and workspace membership authorization, not only by navigation visibility. Capability checks must gate sub-surfaces, aggregates, and linked actions, not page entry for an otherwise valid workspace member. +- **FR-129-20 Canonical return target**: The workspace overview must become the canonical destination for future workspace-return flows. +- **FR-129-21 Performance discipline**: Workspace-home queries must be bounded, eager-loaded where needed, and cheap enough for normal page load without uncontrolled polling or broad cross-workspace aggregation. +- **FR-129-22 Conservative refresh behavior**: If any live refresh is used, it must be conservative, justified, and limited to lightweight surfaces; default behavior should avoid polling entirely. +- **FR-129-23 Canonical links only**: Every clickable summary or action on the workspace home must route to an existing canonical destination instead of introducing ad-hoc alternate paths. +- **FR-129-24 Low-permission resilience**: A minimally privileged workspace member must still receive a meaningful workspace home, even if most data surfaces collapse to empty or hidden states. +- **FR-129-25 Zero-tenant resilience**: A workspace with zero accessible tenants must still present a complete workspace home and allow permitted workspace-level actions such as switching workspace. + +### Non-Goals + +- Introducing a new third business panel +- Performing tenant-panel navigation cleanup in this feature +- Moving or removing monitoring from tenant surfaces +- Hardening all resource discovery or global `canAccess()` behavior +- Refactoring middleware beyond what is minimally required to restore correct workspace-home landing semantics +- Introducing new backend operational features, reporting engines, or trend analytics +- Adding heavy charts, broad portfolio analytics, or high-frequency live dashboards + +### Assumptions + +- The existing admin panel is the correct workspace-level panel and remains the only authenticated workspace panel in this scope. +- Current workspace selection and tenant selection flows already exist and remain canonical at `/admin/choose-workspace` and `/admin/choose-tenant`. +- Existing operations, alerts, and findings destinations already provide the canonical drill-down pages that overview cards and actions should link to. +- Some workspaces or users will have sparse data, so empty states are expected and must be treated as first-class outcomes. + +### Dependencies + +- Existing workspace selection flow and session handling +- Existing admin panel branding and home-resolution configuration +- Existing authorized data sources for tenant counts, operations, alerts, and findings +- Existing workspace and tenant authorization rules plus capability registry +- Existing operations, alerts, and workspaces destinations used by overview quick actions + +## UI Action Matrix + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Workspace Overview Page | Existing admin panel `/admin` home | None required beyond existing page chrome | KPI cards and bounded lists link to canonical destinations | Quick actions such as Choose Tenant and Open Operations | None | Exactly one CTA per empty section, tailored to the section | None required | N/A | No new audit event | This is a view-style overview page. It introduces no destructive action and no inline mutation. | +| Admin Panel Navigation | Existing admin panel primary navigation and brand logo | None | Primary `Overview` nav item and brand-logo home affordance | None | None | N/A | N/A | N/A | No | Navigation semantics change only: the canonical home becomes workspace overview. | +| Workspace Management Shortcut | Workspace overview quick actions | `Manage Workspaces` only when authorized | Linked action only | None | None | `Manage Workspaces` may serve as the single CTA for relevant empty states when permitted | None | N/A | Existing destination rules apply | The action remains distinct from `Switch Workspace` and must not appear for unauthorized users. | + +### Key Entities + +- **Workspace Overview Surface**: The canonical workspace-level home that communicates current workspace identity, top signals, and next actions without requiring a selected tenant. +- **Workspace Summary Metric**: A capability-safe aggregate that summarizes permitted workspace-wide information such as tenant count, active operations, or alerts. +- **Workspace Attention Item**: A bounded urgent item surfaced on the workspace home, ordered by recency or severity and linked to its canonical destination. +- **Workspace Quick Action**: A workspace-scoped shortcut to an existing flow such as choosing a tenant, opening operations, switching workspace, or managing workspaces when authorized. + +## Success Criteria + +### Measurable Outcomes + +- **SC-129-01 Stable home semantics**: In acceptance testing, 100% of standard admin entry paths with a selected workspace resolve to the workspace overview instead of a tenant dashboard. +- **SC-129-02 Workspace selection continuity**: In acceptance testing, 100% of direct `/admin` visits without an established workspace context continue to the canonical workspace selection flow. +- **SC-129-03 Tenantless usability**: Users with a selected workspace and no selected tenant can successfully reach at least one valid next action from the workspace home without encountering a forced tenant redirect. +- **SC-129-04 Authorization safety**: In negative authorization tests, unauthorized users see zero unauthorized counts, links, or actions on the workspace home. +- **SC-129-05 Low-data resilience**: In empty-data test scenarios, the workspace home still renders a complete page with intentional empty states and at least one valid next step. +- **SC-129-06 Query discipline**: All overview list surfaces are capped to bounded result sizes, and no uncontrolled polling is introduced by default. diff --git a/specs/129-workspace-admin-home/tasks.md b/specs/129-workspace-admin-home/tasks.md new file mode 100644 index 0000000..0a234a4 --- /dev/null +++ b/specs/129-workspace-admin-home/tasks.md @@ -0,0 +1,205 @@ +# Tasks: Workspace Home & Admin Landing (129) + +**Input**: Design documents from `specs/129-workspace-admin-home/` (`spec.md`, `plan.md`, `research.md`, `data-model.md`, `contracts/`, `quickstart.md`) +**Prerequisites**: `specs/129-workspace-admin-home/plan.md` (required), `specs/129-workspace-admin-home/spec.md` (required for user stories) + +**Tests**: REQUIRED (Pest) for all runtime behavior changes in this repo. +**Operations**: No new `OperationRun` flow is introduced; this feature only reads existing operational data for workspace-safe overview surfaces. +**RBAC**: Preserve admin workspace-plane isolation, deny-as-not-found 404 for non-members, 403 for in-scope capability denial on protected targets, and canonical capability-registry usage only. +**Filament UI**: This feature adds a new Filament page and modifies admin panel navigation and home semantics; implement the overview as a view-style, sectioned workspace surface with explicit empty states and no destructive actions. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Reconfirm the exact landing, navigation, and reusable surface seams before changing `/admin` semantics. + +- [X] T001 Review current admin home, panel config, and workspace redirect call sites in `routes/web.php`, `app/Providers/Filament/AdminPanelProvider.php`, and `app/Support/Workspaces/WorkspaceRedirectResolver.php` +- [X] T002 [P] Review existing workspace-safe page patterns and reusable overview candidates in `app/Filament/Pages/Monitoring/Operations.php`, `app/Filament/Pages/Monitoring/Alerts.php`, and `app/Filament/Widgets/Dashboard/` +- [X] T003 [P] Review legacy `/admin` landing and chooser tests in `tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php`, `tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php`, and `tests/Feature/Filament/ChooseTenantRequiresWorkspaceTest.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Create the shared workspace-home shell and supporting seams that every user story depends on. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [X] T004 Create the workspace overview page shell in `app/Filament/Pages/WorkspaceOverview.php` and `resources/views/filament/pages/workspace-overview.blade.php` +- [X] T005 Create the shared workspace overview data builder in `app/Support/Workspaces/WorkspaceOverviewBuilder.php` +- [X] T006 [P] Scaffold workspace overview widgets in `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php`, `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, and `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php` +- [X] T007 [P] Add foundational page access and render smoke coverage for workspace members versus non-members in `tests/Feature/Filament/WorkspaceOverviewAccessTest.php` + +**Checkpoint**: The repo has a concrete workspace overview page shell, shared builder seam, and widget scaffolding ready for landing, content, and authorization work. + +--- + +## Phase 3: User Story 1 - Land on a real workspace home (Priority: P1) 🎯 MVP + +**Goal**: `/admin` becomes a stable workspace-level landing page for authenticated users with a selected workspace, without requiring tenant context. + +**Independent Test**: Open `/admin` with a selected workspace and verify the workspace overview renders instead of redirecting to tenant context; open `/admin` without a selected workspace and verify the chooser flow still applies. + +### Tests for User Story 1 + +- [X] T008 [P] [US1] Update selected-workspace landing coverage in `tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php` and add canonical home assertions in `tests/Feature/Filament/WorkspaceOverviewLandingTest.php` +- [X] T009 [P] [US1] Add login, brand-logo, and chooser-first no-workspace landing coverage in `tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php`, `tests/Feature/Filament/WorkspaceOverviewNavigationTest.php`, and `tests/Feature/Filament/WorkspaceOverviewLandingTest.php` + +### Implementation for User Story 1 + +- [X] T010 [US1] Rewire `/admin` to render the workspace overview and constrain direct no-workspace entry to chooser-first behavior in `routes/web.php` and `app/Http/Middleware/EnsureWorkspaceSelected.php` +- [X] T011 [US1] Register `WorkspaceOverview` as the admin-panel home and primary overview page in `app/Providers/Filament/AdminPanelProvider.php` and `app/Filament/Pages/WorkspaceOverview.php` +- [X] T012 [US1] Narrow workspace post-selection branching so chooser-driven flows can still use tenant branching without defining normal `/admin` behavior in `app/Support/Workspaces/WorkspaceRedirectResolver.php` and `app/Filament/Pages/ChooseWorkspace.php` +- [X] T013 [US1] Align tenantless admin navigation behavior for the new workspace home in `app/Support/Middleware/EnsureFilamentTenantSelected.php` + +**Checkpoint**: User Story 1 is complete when `/admin` is a real workspace home for selected workspaces, chooser gating still works, and brand-logo navigation resolves back to the workspace overview. + +--- + +## Phase 4: User Story 2 - Reorient and continue work from workspace context (Priority: P2) + +**Goal**: The workspace home shows useful workspace-scoped context, summary signals, recent operational visibility, and quick actions without forcing a tenant. + +**Independent Test**: Render the workspace home for a normal workspace member and verify it shows workspace identity, bounded summary metrics, a recent or needs-attention surface, quick actions to existing flows, and intentional empty states when data is sparse. + +### Tests for User Story 2 + +- [X] T014 [P] [US2] Add workspace overview content and empty-state coverage in `tests/Feature/Filament/WorkspaceOverviewContentTest.php` and `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php` +- [X] T015 [P] [US2] Add bounded recent-operations, quick-action, and no-uncontrolled-polling coverage in `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php` + +### Implementation for User Story 2 + +- [X] T016 [US2] Implement workspace identity header, section layout, and quick-action presentation in `app/Filament/Pages/WorkspaceOverview.php` and `resources/views/filament/pages/workspace-overview.blade.php` +- [X] T017 [US2] Extend `WorkspaceOverviewBuilder` to assemble bounded summary metrics, recent operations, attention items, and quick actions in `app/Support/Workspaces/WorkspaceOverviewBuilder.php` +- [X] T018 [P] [US2] Implement the workspace summary KPI widget with the minimum target set and polling disabled by default in `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php` +- [X] T019 [P] [US2] Implement the bounded needs-attention widget with polling disabled by default in `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php` +- [X] T020 [P] [US2] Implement the bounded recent-operations widget with polling disabled by default in `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php` +- [X] T021 [US2] Wire overview widgets, empty-state copy, and canonical links into the workspace home page in `app/Filament/Pages/WorkspaceOverview.php` and `resources/views/filament/pages/workspace-overview.blade.php` + +**Checkpoint**: User Story 2 is complete when the workspace home feels useful and tenantless, surfaces only bounded workspace-scoped signals, and degrades gracefully for low-data workspaces. + +--- + +## Phase 5: User Story 3 - See only permitted workspace surfaces (Priority: P3) + +**Goal**: The workspace home remains safe for minimally privileged users, hides unauthorized aggregates and actions, and preserves clear 404 versus 403 semantics. + +**Independent Test**: Load the workspace home as a low-permission member and as a non-member, and verify safe rendering, hidden unauthorized surfaces, distinct switch-versus-manage behavior, and workspace-scope not-found semantics. + +### Tests for User Story 3 + +- [X] T022 [P] [US3] Add authorization and non-member access coverage in `tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php` and `tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php` +- [X] T023 [P] [US3] Add low-permission visibility coverage for hidden counts and manage-versus-switch actions in `tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php` + +### Implementation for User Story 3 + +- [X] T024 [US3] Enforce membership-based page access and capability-safe surface gating in `app/Filament/Pages/WorkspaceOverview.php` and `app/Support/Workspaces/WorkspaceOverviewBuilder.php` +- [X] T025 [US3] Gate overview navigation and workspace-management shortcuts with canonical capability checks in `app/Providers/Filament/AdminPanelProvider.php` and `app/Support/Middleware/EnsureFilamentTenantSelected.php` +- [X] T026 [US3] Harden overview aggregates and list queries against tenant leakage and unauthorized counts in `app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php`, and `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php` + +**Checkpoint**: User Story 3 is complete when the workspace home remains useful for low-permission members, hides unauthorized signals and actions, and preserves deny-as-not-found semantics for non-members. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final regression protection, formatting, and manual verification across all stories. + +- [X] T027 [P] Add final regression coverage that normal `/admin` access no longer silently redirects into tenant context and that direct no-workspace admin entry stays chooser-first in `tests/Feature/Filament/WorkspaceOverviewLandingTest.php`, `tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php`, and `tests/Feature/Filament/ChooseTenantRequiresWorkspaceTest.php` +- [X] T028 Run focused Pest verification from `specs/129-workspace-admin-home/quickstart.md` +- [X] T029 Run formatting for changed files with `vendor/bin/sail bin pint --dirty --format agent` +- [ ] T030 Validate the manual QA scenarios in `specs/129-workspace-admin-home/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies; can start immediately. +- **Foundational (Phase 2)**: Depends on Setup; blocks all user stories. +- **User Story 1 (Phase 3)**: Depends on Foundational completion. +- **User Story 2 (Phase 4)**: Depends on Foundational completion and benefits from User Story 1 because canonical `/admin` landing makes the overview reachable through the primary flow. +- **User Story 3 (Phase 5)**: Depends on Foundational completion and should land after User Story 2 because capability-safe gating applies to real overview content. +- **Polish (Phase 6)**: Depends on all desired user stories being complete. + +### User Story Dependencies + +- **User Story 1 (P1)**: First deliverable and MVP. No dependency on other user stories. +- **User Story 2 (P2)**: Depends on the overview shell from the Foundational phase and works best after US1 establishes canonical landing semantics. +- **User Story 3 (P3)**: Depends on the overview shell and real content surfaces from US2 so authorization and visibility rules can be verified against actual widgets and quick actions. + +### Within Each User Story + +- Tests should be added before or alongside implementation and must fail before the story is considered complete. +- Route and panel-home behavior should be correct before quick actions and content widgets are treated as done. +- Builder and page wiring should exist before widget output is considered stable. +- Authorization hardening should be completed before final regression verification. + +### Parallel Opportunities + +- Setup review tasks `T002` and `T003` can run in parallel. +- In Foundational, `T006` and `T007` can run in parallel after the page and builder seam are defined. +- In US1, `T008` and `T009` can run in parallel. +- In US2, `T014` and `T015` can run in parallel, then widget tasks `T018`, `T019`, and `T020` can run in parallel after `T017` defines builder outputs. +- In US3, `T022` and `T023` can run in parallel. + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch US1 test work in parallel: +T008 tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php + tests/Feature/Filament/WorkspaceOverviewLandingTest.php +T009 tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php + tests/Feature/Filament/WorkspaceOverviewNavigationTest.php +``` + +## Parallel Example: User Story 2 + +```bash +# Launch US2 test work in parallel: +T014 tests/Feature/Filament/WorkspaceOverviewContentTest.php + tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php +T015 tests/Feature/Filament/WorkspaceOverviewOperationsTest.php + +# Launch US2 widget work in parallel after builder output is defined: +T018 app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php +T019 app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php +T020 app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php +``` + +## Parallel Example: User Story 3 + +```bash +# Launch US3 authorization tests in parallel: +T022 tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php + tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php +T023 tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Validate `/admin` landing, chooser preservation, and brand-logo return behavior independently. + +### Incremental Delivery + +1. Ship US1 to establish `/admin` as the canonical workspace home. +2. Add US2 to make the home useful with summary, attention, recent operations, and quick actions. +3. Add US3 to harden capability-safe rendering and low-permission behavior. + +### Suggested MVP Scope + +- MVP = Phases 1 through 3, then run the focused landing tests for `/admin`, chooser preservation, and navigation semantics. + +--- + +## Format Validation + +- Every task follows the checklist format `- [ ] T### [P?] [US?] Description with file path`. +- Setup, Foundational, and Polish phases intentionally omit story labels. +- User story phases use `[US1]`, `[US2]`, and `[US3]` labels. +- Parallel markers are used only on tasks that can proceed independently without conflicting incomplete prerequisites. diff --git a/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php b/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php index ec65bd9..f203eed 100644 --- a/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php +++ b/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use App\Filament\Pages\TenantDashboard; use App\Models\Tenant; use App\Models\TenantMembership; use App\Models\User; @@ -13,7 +12,7 @@ uses(RefreshDatabase::class); -it('redirects /admin to managed tenants index when a workspace is selected and has no tenants', function (): void { +it('renders the workspace overview when a workspace is selected and has no tenants', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -24,17 +23,16 @@ 'role' => 'owner', ]); - $response = $this + $this ->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) - ->get('/admin'); - - $response->assertRedirect(); - $location = $response->headers->get('Location'); - expect($location)->toContain('managed-tenants'); + ->get('/admin') + ->assertOk() + ->assertSee('Workspace overview') + ->assertSee('No accessible tenants in this workspace'); }); -it('redirects /admin to choose-tenant when a workspace is selected and has multiple tenants', function (): void { +it('renders the workspace overview when a workspace is selected and has multiple tenants', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -65,10 +63,12 @@ ->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->get('/admin') - ->assertRedirect('/admin/choose-tenant'); + ->assertOk() + ->assertSee('Workspace overview') + ->assertSee('Choose tenant'); }); -it('redirects /admin to the tenant dashboard when a workspace is selected and has exactly one tenant', function (): void { +it('renders the workspace overview when a workspace is selected and has exactly one tenant', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -97,5 +97,7 @@ ->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->get('/admin') - ->assertRedirect(TenantDashboard::getUrl(tenant: $tenant)); + ->assertOk() + ->assertSee('Workspace overview') + ->assertSee('Choose tenant'); }); diff --git a/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php b/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php index b8b1fd6..38b7616 100644 --- a/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php +++ b/tests/Feature/Filament/LoginRedirectsToChooseWorkspaceWhenMultipleWorkspacesTest.php @@ -14,7 +14,7 @@ Http::preventStrayRequests(); }); -it('auto-resumes to last used workspace when user has multiple workspaces and last_workspace_id is set', function (): void { +it('keeps direct /admin entry chooser-first when the user has a remembered workspace', function (): void { $user = User::factory()->create(); $workspaceA = Workspace::factory()->create(['name' => 'Workspace A']); @@ -34,14 +34,9 @@ $user->forceFill(['last_workspace_id' => (int) $workspaceA->getKey()])->save(); - $response = $this->actingAs($user) - ->get('/admin'); - - // Middleware step 6: auto-resumes to last used workspace and redirects - // via tenant branching (workspaceA has 0 tenants → managed-tenants). - $response->assertRedirect(); - $location = $response->headers->get('Location'); - expect($location)->toContain('managed-tenants'); + $this->actingAs($user) + ->get('/admin') + ->assertRedirect(route('filament.admin.pages.choose-workspace')); }); it('redirects to choose-workspace when user has multiple workspaces and no last_workspace_id', function (): void { diff --git a/tests/Feature/Filament/TenantDashboardDbOnlyTest.php b/tests/Feature/Filament/TenantDashboardDbOnlyTest.php index 924dc14..ced223f 100644 --- a/tests/Feature/Filament/TenantDashboardDbOnlyTest.php +++ b/tests/Feature/Filament/TenantDashboardDbOnlyTest.php @@ -32,10 +32,10 @@ assertNoOutboundHttp(function () use ($tenant): void { $this->get(TenantDashboard::getUrl(tenant: $tenant)) ->assertOk() - ->assertSee('/admin/workspaces', false) - ->assertSee('Needs Attention'); - // RecentOperations and RecentDriftFindings are lazy-loaded widgets - // and will not appear in the initial server-rendered HTML. + ->assertSee('/admin/choose-workspace', false); + // NeedsAttention, RecentOperations and RecentDriftFindings are + // lazy-loaded widgets and will not appear in the initial + // server-rendered HTML. }); Bus::assertNothingDispatched(); diff --git a/tests/Feature/Filament/WorkspaceOverviewAccessTest.php b/tests/Feature/Filament/WorkspaceOverviewAccessTest.php new file mode 100644 index 0000000..a9392d4 --- /dev/null +++ b/tests/Feature/Filament/WorkspaceOverviewAccessTest.php @@ -0,0 +1,26 @@ +create(); + $workspace = Workspace::factory()->create(['name' => 'Northwind Workspace']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'readonly', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin') + ->assertOk() + ->assertSee('Workspace overview') + ->assertSee('Northwind Workspace'); +}); diff --git a/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php b/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php new file mode 100644 index 0000000..1182ffb --- /dev/null +++ b/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php @@ -0,0 +1,24 @@ +create(); + $workspace = Workspace::factory()->create(['name' => 'Hidden Workspace']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) Workspace::factory()->create()->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin') + ->assertNotFound(); +}); diff --git a/tests/Feature/Filament/WorkspaceOverviewContentTest.php b/tests/Feature/Filament/WorkspaceOverviewContentTest.php new file mode 100644 index 0000000..a940b6b --- /dev/null +++ b/tests/Feature/Filament/WorkspaceOverviewContentTest.php @@ -0,0 +1,33 @@ +create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'inventory_sync', + 'status' => OperationRunStatus::Running->value, + 'outcome' => 'pending', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin') + ->assertOk() + ->assertSee('Workspace overview') + ->assertSee('Accessible tenants') + ->assertSee('Active operations') + ->assertSee('Needs attention') + ->assertSee('Recent operations') + ->assertSee('Choose tenant') + ->assertSee('Open operations') + ->assertSee('Open alerts') + ->assertSee('Inventory sync'); +}); diff --git a/tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php b/tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php new file mode 100644 index 0000000..36a5ebf --- /dev/null +++ b/tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php @@ -0,0 +1,29 @@ +create(); + $workspace = Workspace::factory()->create(['name' => 'Low Data Workspace']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'readonly', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin') + ->assertOk() + ->assertSee('No accessible tenants in this workspace') + ->assertSee('Nothing urgent in your current scope') + ->assertSee('No recent operations yet') + ->assertSee('Switch workspace') + ->assertDontSee('Choose tenant'); +}); diff --git a/tests/Feature/Filament/WorkspaceOverviewLandingTest.php b/tests/Feature/Filament/WorkspaceOverviewLandingTest.php new file mode 100644 index 0000000..1db52e7 --- /dev/null +++ b/tests/Feature/Filament/WorkspaceOverviewLandingTest.php @@ -0,0 +1,45 @@ +create(['name' => 'Contoso Workspace']); + [$user] = createUserWithTenant( + tenant: \App\Models\Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $workspace->getKey(), + ]), + role: 'owner', + workspaceRole: 'owner', + ); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get('/admin') + ->assertOk() + ->assertSee('Workspace overview') + ->assertSee('Contoso Workspace') + ->assertSee('Choose tenant'); +}); + +it('sends direct /admin visits without workspace context through the chooser even for a single membership', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save(); + + $this->actingAs($user) + ->get('/admin') + ->assertRedirect(route('filament.admin.pages.choose-workspace')); +}); diff --git a/tests/Feature/Filament/WorkspaceOverviewNavigationTest.php b/tests/Feature/Filament/WorkspaceOverviewNavigationTest.php new file mode 100644 index 0000000..b81986a --- /dev/null +++ b/tests/Feature/Filament/WorkspaceOverviewNavigationTest.php @@ -0,0 +1,20 @@ +actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get('/admin') + ->assertOk() + ->assertSee('Overview') + ->assertSee('Switch workspace'); + + expect(Filament::getPanel('admin')->getHomeUrl())->toBe(route('admin.home')); + expect((string) $response->getContent())->toContain('href="'.route('admin.home').'"'); +}); diff --git a/tests/Feature/Filament/WorkspaceOverviewOperationsTest.php b/tests/Feature/Filament/WorkspaceOverviewOperationsTest.php new file mode 100644 index 0000000..c8608e2 --- /dev/null +++ b/tests/Feature/Filament/WorkspaceOverviewOperationsTest.php @@ -0,0 +1,42 @@ +create(['status' => 'active']); + [$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner', workspaceRole: 'readonly'); + + $tenantB = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $tenantA->workspace_id, + 'name' => 'Forbidden Tenant', + ]); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'workspace_id' => (int) $tenantA->workspace_id, + 'type' => 'inventory_sync', + 'initiator_name' => 'Accessible run', + ]); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenantB->getKey(), + 'workspace_id' => (int) $tenantB->workspace_id, + 'type' => 'policy.sync', + 'initiator_name' => 'Forbidden run', + ]); + + $response = $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]) + ->get('/admin') + ->assertOk() + ->assertSee('Inventory sync') + ->assertDontSee('Forbidden Tenant') + ->assertDontSee('Policy sync'); + + expect((string) $response->getContent())->not->toContain('wire:poll'); +}); diff --git a/tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php b/tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php new file mode 100644 index 0000000..c3d7964 --- /dev/null +++ b/tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php @@ -0,0 +1,26 @@ +create(['status' => 'active']); + [$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner', workspaceRole: 'readonly'); + + Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $tenantA->workspace_id, + 'name' => 'Inaccessible Tenant', + ]); + + $workspace = $tenantA->workspace()->firstOrFail(); + + $overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user); + $quickActionKeys = collect($overview['quick_actions'])->pluck('key')->all(); + + expect($overview['accessible_tenant_count'])->toBe(1) + ->and($quickActionKeys)->toContain('switch_workspace') + ->and($quickActionKeys)->not->toContain('manage_workspaces'); +}); diff --git a/tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php b/tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php index c4ae48f..dfa8fe4 100644 --- a/tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php +++ b/tests/Feature/Guards/AdminWorkspaceRoutesGuardTest.php @@ -19,3 +19,11 @@ ->get("/admin/w/{$workspaceRouteKey}/ping") ->assertNotFound(); }); + +it('returns 404 for platform-guard sessions on the workspace overview home', function (): void { + $platformUser = PlatformUser::factory()->create(); + + $this->actingAs($platformUser, 'platform') + ->get('/admin') + ->assertNotFound(); +}); diff --git a/tests/Feature/Monitoring/HeaderContextBarTest.php b/tests/Feature/Monitoring/HeaderContextBarTest.php index c1c1103..c83c256 100644 --- a/tests/Feature/Monitoring/HeaderContextBarTest.php +++ b/tests/Feature/Monitoring/HeaderContextBarTest.php @@ -29,7 +29,7 @@ ->assertSee('Search tenants…') ->assertSee('Switch workspace') ->assertSee('admin/select-tenant') - ->assertSee('Clear tenant context') + ->assertSee('Clear tenant scope') ->assertSee($tenant->getFilamentName()); $content = $response->getContent(); @@ -84,7 +84,7 @@ ->assertSee($tenant->getFilamentName()) ->assertDontSee('Search tenants…') ->assertDontSee('admin/select-tenant') - ->assertDontSee('Clear tenant context'); + ->assertDontSee('Clear tenant scope'); }); it('filters the header tenant picker to tenants the user can access', function (): void { diff --git a/tests/Feature/OpsUx/OperateHubShellTest.php b/tests/Feature/OpsUx/OperateHubShellTest.php index e89bbd8..84520e5 100644 --- a/tests/Feature/OpsUx/OperateHubShellTest.php +++ b/tests/Feature/OpsUx/OperateHubShellTest.php @@ -168,15 +168,15 @@ ->assertDontSee('Show all operations'); })->group('ops-ux'); -it('redirects non-member workspace access to chooser on /admin/operations', function (): void { +it('returns 404 for non-member workspace access on /admin/operations', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); - // User is NOT a member — middleware detects stale session and redirects. + // User is NOT a member — stale workspace context is denied as not found. $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->get(route('admin.operations.index')) - ->assertRedirect(); + ->assertNotFound(); })->group('ops-ux'); it('returns 404 for non-entitled tenant dashboard direct access', function (): void { diff --git a/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php b/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php index 683f4ae..5948fba 100644 --- a/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php +++ b/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php @@ -255,8 +255,8 @@ ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->get('/admin/_test/workspace-context'); - // Should redirect to no-access or chooser since user has no memberships. - $response->assertRedirect(); + // Active workspace IDs that no longer belong to the user are denied as not found. + $response->assertNotFound(); }); // --- T024: it_redirects_to_chooser_when_last_workspace_membership_revoked_and_shows_warning ---