diff --git a/app/Filament/Pages/ChooseWorkspace.php b/app/Filament/Pages/ChooseWorkspace.php index 3a9052c..32e14d9 100644 --- a/app/Filament/Pages/ChooseWorkspace.php +++ b/app/Filament/Pages/ChooseWorkspace.php @@ -14,6 +14,7 @@ use Filament\Notifications\Notification; use Filament\Pages\Page; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Gate; class ChooseWorkspace extends Page { @@ -38,6 +39,12 @@ protected function getHeaderActions(): array Action::make('createWorkspace') ->label('Create workspace') ->modalHeading('Create workspace') + ->visible(function (): bool { + $user = auth()->user(); + + return $user instanceof User + && Gate::forUser($user)->check('create', Workspace::class); + }) ->form([ TextInput::make('name') ->required() @@ -117,6 +124,8 @@ public function createWorkspace(array $data): void abort(403); } + Gate::forUser($user)->authorize('create', Workspace::class); + $workspace = Workspace::query()->create([ 'name' => $data['name'], 'slug' => $data['slug'] ?? null, diff --git a/app/Http/Middleware/EnsureWorkspaceSelected.php b/app/Http/Middleware/EnsureWorkspaceSelected.php index d207cc6..8ebd8f2 100644 --- a/app/Http/Middleware/EnsureWorkspaceSelected.php +++ b/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -3,12 +3,14 @@ namespace App\Http\Middleware; use App\Models\User; +use App\Models\Workspace; use App\Models\WorkspaceMembership; use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceIntendedUrl; use Closure; use Illuminate\Http\Request; use Illuminate\Http\Response as HttpResponse; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Schema; use Symfony\Component\HttpFoundation\Response; @@ -29,27 +31,14 @@ public function handle(Request $request, Closure $next): Response $path = '/'.ltrim($request->path(), '/'); + if ($this->isWorkspaceOptionalPath($request, $path)) { + return $next($request); + } + if (str_starts_with($path, '/admin/t/')) { return $next($request); } - if ($path === '/livewire/update') { - $refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? ''; - $refererPath = '/'.ltrim((string) $refererPath, '/'); - - if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) { - return $next($request); - } - } - - if (preg_match('#^/admin/operations/[^/]+$#', $path) === 1) { - return $next($request); - } - - if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) { - return $next($request); - } - $user = $request->user(); if (! $user instanceof User) { @@ -74,7 +63,11 @@ public function handle(Request $request, Closure $next): Response ->exists() : $membershipQuery->exists(); - $target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access'; + $canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class); + + $target = ($hasAnyActiveMembership || $canCreateWorkspace) + ? '/admin/choose-workspace' + : '/admin/no-access'; if ($target === '/admin/choose-workspace') { WorkspaceIntendedUrl::storeFromRequest($request); @@ -82,4 +75,26 @@ public function handle(Request $request, Closure $next): Response return new HttpResponse('', 302, ['Location' => $target]); } + + private function isWorkspaceOptionalPath(Request $request, string $path): bool + { + if (str_starts_with($path, '/admin/workspaces')) { + return true; + } + + if (in_array($path, ['/admin/choose-workspace', '/admin/no-access', '/admin/onboarding'], true)) { + return true; + } + + if ($path === '/livewire/update') { + $refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? ''; + $refererPath = '/'.ltrim((string) $refererPath, '/'); + + if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) { + return true; + } + } + + return preg_match('#^/admin/operations/[^/]+$#', $path) === 1; + } } diff --git a/app/Policies/WorkspacePolicy.php b/app/Policies/WorkspacePolicy.php index 87d2e52..1dbb787 100644 --- a/app/Policies/WorkspacePolicy.php +++ b/app/Policies/WorkspacePolicy.php @@ -6,7 +6,6 @@ use App\Models\Workspace; use App\Models\WorkspaceMembership; use App\Services\Auth\WorkspaceCapabilityResolver; -use App\Services\Auth\WorkspaceRoleCapabilityMap; use App\Support\Auth\Capabilities; use Illuminate\Auth\Access\Response; @@ -17,11 +16,7 @@ class WorkspacePolicy */ public function viewAny(User $user): bool|Response { - $isMember = WorkspaceMembership::query() - ->where('user_id', $user->getKey()) - ->exists(); - - return $isMember ? Response::allow() : Response::denyAsNotFound(); + return Response::allow(); } /** @@ -42,24 +37,7 @@ public function view(User $user, Workspace $workspace): bool|Response */ public function create(User $user): bool|Response { - $hasAnyMembership = WorkspaceMembership::query() - ->where('user_id', $user->getKey()) - ->exists(); - - if (! $hasAnyMembership) { - return Response::denyAsNotFound(); - } - - $rolesWithManageCapability = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MANAGE); - - $canManageAnyWorkspace = WorkspaceMembership::query() - ->where('user_id', $user->getKey()) - ->whereIn('role', $rolesWithManageCapability) - ->exists(); - - return $canManageAnyWorkspace - ? Response::allow() - : Response::deny(); + return Response::allow(); } /** diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 5f32e5e..f6a363e 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -57,18 +57,13 @@ public function panel(Panel $panel): Panel 'primary' => Color::Amber, ]) ->navigationItems([ - NavigationItem::make('Switch workspace') - ->url(fn (): string => ChooseWorkspace::getUrl()) - ->icon('heroicon-o-squares-2x2') - ->group('Settings') - ->sort(10), NavigationItem::make('Manage workspaces') ->url(function (): string { return route('filament.admin.resources.workspaces.index'); }) ->icon('heroicon-o-squares-2x2') ->group('Settings') - ->sort(20) + ->sort(10) ->visible(function (): bool { $user = auth()->user(); @@ -107,10 +102,6 @@ public function panel(Panel $panel): Panel PanelsRenderHook::TOPBAR_START, fn () => view('filament.partials.context-bar')->render() ) - ->renderHook( - PanelsRenderHook::USER_MENU_PROFILE_AFTER, - fn () => view('filament.partials.workspace-switcher')->render() - ) ->renderHook( PanelsRenderHook::BODY_END, fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true) diff --git a/app/Support/Middleware/EnsureFilamentTenantSelected.php b/app/Support/Middleware/EnsureFilamentTenantSelected.php index 8482519..957f718 100644 --- a/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -2,7 +2,6 @@ namespace App\Support\Middleware; -use App\Filament\Pages\ChooseWorkspace; use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; @@ -157,19 +156,12 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void $panel->navigation(function (): NavigationBuilder { return app(NavigationBuilder::class) - ->item( - NavigationItem::make('Switch workspace') - ->url(fn (): string => ChooseWorkspace::getUrl()) - ->icon('heroicon-o-squares-2x2') - ->group('Settings') - ->sort(10), - ) ->item( NavigationItem::make('Manage workspaces') ->url(fn (): string => route('filament.admin.resources.workspaces.index')) ->icon('heroicon-o-squares-2x2') ->group('Settings') - ->sort(20) + ->sort(10) ->visible(function (): bool { $user = auth()->user(); diff --git a/resources/views/filament/partials/context-bar.blade.php b/resources/views/filament/partials/context-bar.blade.php index 4814a60..ff860ea 100644 --- a/resources/views/filament/partials/context-bar.blade.php +++ b/resources/views/filament/partials/context-bar.blade.php @@ -43,6 +43,9 @@ $currentTenant = Filament::getTenant(); $currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null; + $path = '/'.ltrim(request()->path(), '/'); + $isTenantScopedRoute = request()->route()?->hasParameter('tenant') || str_starts_with($path, '/admin/t/'); + $lastTenantId = $workspaceContext->lastTenantId(request()); $canClearTenantContext = $currentTenantName !== null || $lastTenantId !== null; @endphp @@ -67,90 +70,105 @@ class="block px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800" > Switch workspace - - @if ($canSeeAllWorkspaceTenants) - - Manage workspaces - - @endif
- - + @if (! $workspace) +
- {{ $currentTenantName ?? 'Select tenant' }} + Select tenant - - -
-
- Tenant context - @if ($canSeeAllWorkspaceTenants) - · all workspace tenants +
Choose a workspace first.
+
+ @elseif ($isTenantScopedRoute) + + {{ $currentTenantName ?? 'Tenant' }} + + @else + + + + {{ $currentTenantName ?? 'Select tenant' }} + + + + +
+
+ Tenant context + @if ($canSeeAllWorkspaceTenants) + · all workspace tenants + @endif +
+ + @if ($tenants->isEmpty()) +
+ {{ $canSeeAllWorkspaceTenants ? 'No tenants exist in this workspace.' : 'No tenants you can access in this workspace.' }} +
+ @else +
+ + +
+ @foreach ($tenants as $tenant) +
+ @csrf + + + +
+ @endforeach +
+ + @if ($canClearTenantContext) +
+ @csrf + + + Clear tenant context + +
+ @endif + +
+ Switching tenants is explicit. Canonical monitoring URLs do not change tenant context. +
+
@endif
- - @if (! $workspace) -
Choose a workspace first.
- @elseif ($tenants->isEmpty()) -
- {{ $canSeeAllWorkspaceTenants ? 'No tenants exist in this workspace.' : 'No tenants you can access in this workspace.' }} -
- @else -
- - -
- @foreach ($tenants as $tenant) -
- @csrf - - - -
- @endforeach -
- - @if ($canClearTenantContext) -
- @csrf - - - Clear tenant context - -
- @endif - -
- Switching tenants is explicit. Canonical monitoring URLs do not change tenant context. -
-
- @endif -
-
- + + + @endif
diff --git a/specs/077-workspace-nav-monitoring-hub/contracts/routes.md b/specs/077-workspace-nav-monitoring-hub/contracts/routes.md index dbb2d83..bb6d4b2 100644 --- a/specs/077-workspace-nav-monitoring-hub/contracts/routes.md +++ b/specs/077-workspace-nav-monitoring-hub/contracts/routes.md @@ -28,9 +28,11 @@ ### Workspace management (CRUD) Contract semantics: +- Workspace context is optional on `/admin/workspaces` (Global Mode). - Index lists only workspaces the user is a member of. - If user attempts to access a workspace record they are not a member of → 404 (deny-as-not-found) -- If user is a member but lacks the required capability for a protected action/screen (create/edit/membership management) → 403 +- Workspace creation is self-serve for authenticated users (policy-driven). +- If user is a member but lacks the required capability for a protected action/screen (edit/membership management) → 403 - If user is authorized → normal Filament behavior ### Monitoring hub — Operations diff --git a/specs/077-workspace-nav-monitoring-hub/plan.md b/specs/077-workspace-nav-monitoring-hub/plan.md index ebf4a41..5c1541a 100644 --- a/specs/077-workspace-nav-monitoring-hub/plan.md +++ b/specs/077-workspace-nav-monitoring-hub/plan.md @@ -125,17 +125,13 @@ ## Phase 1 — Design & Contracts (complete) - Route/security contracts: [contracts/routes.md](contracts/routes.md) - Manual validation steps + suggested test filters: [quickstart.md](quickstart.md) -Agent context update: - -- Re-run `.specify/scripts/bash/update-agent-context.sh copilot` after finalizing this plan file (the earlier run happened while this file contained placeholders). - ## Phase 2 — Implementation Plan (ready for tasks) ### Step 1 — Navigation labels: “one label, one meaning” - Update admin navigation to include: - - **Switch workspace** → `/admin/choose-workspace` - - **Manage workspaces** → `/admin/workspaces` + - **Switch workspace** (topbar context switcher) → `/admin/choose-workspace` + - **Manage workspaces** (sidebar Settings) → `/admin/workspaces` - Remove/replace any navigation items labeled only “Workspaces”. Implementation targets: @@ -149,7 +145,7 @@ ### Step 1 — Navigation labels: “one label, one meaning” ### Step 2 — Enforce workspace-scoped RBAC semantics for `/admin/workspaces` -- `/admin/workspaces` stays tenantless and workspace-scoped. +- `/admin/workspaces` stays tenantless and is **Global Mode** (workspace-optional). - Enforce strict non-leakage semantics: - Non-member attempting to access a workspace record → **404** (deny-as-not-found) - Member missing required capability for protected actions/screens → **403** @@ -158,7 +154,7 @@ ### Step 2 — Enforce workspace-scoped RBAC semantics for `/admin/workspaces` - Scope the Workspaces query (index) to only workspaces the user is a member of. - Ensure `WorkspacePolicy` returns 404 semantics for non-members (record access). -- Gate create/edit/membership-management behind canonical workspace capabilities (no raw strings). +- Workspace creation is self-serve (policy-driven). Gate edit/membership-management behind canonical workspace capabilities (no raw strings). - Hide “Manage workspaces” navigation unless the user can manage something workspace-admin related (capability-based). ### Step 3 — Workspace selection redirect + return-to-intended diff --git a/specs/077-workspace-nav-monitoring-hub/spec.md b/specs/077-workspace-nav-monitoring-hub/spec.md index 10549f1..2ade549 100644 --- a/specs/077-workspace-nav-monitoring-hub/spec.md +++ b/specs/077-workspace-nav-monitoring-hub/spec.md @@ -2,14 +2,15 @@ # Feature Specification: Workspace-first Navigation & Monitoring Hub **Feature Branch**: `077-workspace-nav-monitoring-hub` **Created**: 2026-02-06 -**Status**: Draft +**Status**: Implemented **Input**: User description: "Workspace-first navigation and monitoring hub for an enterprise admin suite: remove workspace navigation ambiguity, lock canonical operations deep links, apply tenant context only as default filters, and enforce strict 404/403 access semantics without information leakage." ## Clarifications ### Session 2026-02-06 -- Q: What is the authorization plane + status-code rule for `/admin/workspaces` ("Manage workspaces")? → A: Tenant plane (`/admin`, Entra users). Workspace management is workspace-scoped: non-members receive 404 (deny-as-not-found); members missing required capabilities receive 403. +- Q: What is the authorization plane + status-code rule for `/admin/workspaces` ("Manage workspaces")? → A: Tenant plane (`/admin`, Entra users). `/admin/workspaces` is **Global Mode** (workspace-optional). Index lists only the user’s workspaces; per-record access for non-members is 404 (deny-as-not-found); protected actions/screens return 403 when unauthorized. +- Q: Should `/admin/workspaces` require an active `current_workspace_id`? → A: No. `/admin/workspaces` is **Global Mode** (workspace-optional). The index lists only workspaces the user is a member of; per-record access for non-members remains 404. - Q: How should the tenant-context default filter on `/admin/operations` be implemented? → A: Server-side default state with a removable filter chip; URL remains `/admin/operations`. - Q: What happens when a user visits a workspace-scoped page (e.g. `/admin/operations`) with no `current_workspace_id` selected? → A: Redirect to `/admin/choose-workspace` and return to the originally requested URL after selection. - Q: If tenant context is active but the tenant is not in the current workspace (e.g., user switches workspaces), what should happen? → A: Auto-clear tenant context and continue on tenantless workspace pages. @@ -97,6 +98,7 @@ ### Functional Requirements - "Switch workspace" for selecting the active workspace context. - "Manage workspaces" for workspace CRUD/administration. - **FR-002 (Canonical workspace switch route)**: "Switch workspace" MUST navigate to `/admin/choose-workspace`. + - **UX note**: "Switch workspace" is a global context control and MUST NOT be registered as a sidebar navigation item. - **FR-003 (Canonical workspace management route)**: "Manage workspaces" MUST navigate to `/admin/workspaces` and MUST NOT be labeled simply "Workspaces". - **FR-004 (Breadcrumb correctness)**: Breadcrumbs in workspace management MUST point back to `/admin/workspaces` and must not send users to the workspace switcher. - **FR-005 (Monitoring is workspace-level)**: Monitoring pages MUST be workspace-scoped and reachable without tenant context. diff --git a/specs/077-workspace-nav-monitoring-hub/tasks.md b/specs/077-workspace-nav-monitoring-hub/tasks.md index a2a729f..5654a62 100644 --- a/specs/077-workspace-nav-monitoring-hub/tasks.md +++ b/specs/077-workspace-nav-monitoring-hub/tasks.md @@ -139,6 +139,11 @@ ## Phase 6: Polish & Cross-Cutting Concerns ### Post-implementation bugfixes - [X] T058 Fix route conflict so Operations “View” consistently hits canonical `/admin/operations/{run}` by moving Filament resource view route to `/admin/operations/r/{record}` in app/Filament/Resources/OperationRunResource.php +- [X] T059 Remove “Switch workspace” from sidebar navigation (workspace switching is topbar-only) in app/Providers/Filament/AdminPanelProvider.php and app/Support/Middleware/EnsureFilamentTenantSelected.php +- [X] T060 Define Global Mode: make `/admin/workspaces` workspace-optional + add explicit allowlist in app/Http/Middleware/EnsureWorkspaceSelected.php +- [X] T061 Disable tenant picker when no workspace is active (Global Mode) in resources/views/filament/partials/context-bar.blade.php +- [X] T062 Remove “Manage workspaces” link from the topbar context switcher to avoid redundant entry points in resources/views/filament/partials/context-bar.blade.php +- [X] T063 Unify workspace creation authorization: ChooseWorkspace create action must use WorkspacePolicy (Gate) in app/Filament/Pages/ChooseWorkspace.php and app/Policies/WorkspacePolicy.php --- diff --git a/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php b/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php index d461c58..8c43e75 100644 --- a/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php +++ b/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php @@ -52,7 +52,7 @@ ->assertDontSee($tenantB->name); }); -test('user menu renders a workspace switcher when a workspace is selected', function () { +test('user menu does not render a workspace switcher (topbar context bar is the single entry point)', function () { [$user, $tenant] = createUserWithTenant(); $workspace = Workspace::query()->whereKey($tenant->workspace_id)->firstOrFail(); @@ -61,6 +61,5 @@ ->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant))) ->assertOk() ->assertSee($workspace->name) - ->assertSee('Switch workspace') - ->assertSee('name="workspace_id"', escape: false); + ->assertDontSee('name="workspace_id"', escape: false); }); diff --git a/tests/Feature/Monitoring/HeaderContextBarTest.php b/tests/Feature/Monitoring/HeaderContextBarTest.php index 5e08df9..30c4d9e 100644 --- a/tests/Feature/Monitoring/HeaderContextBarTest.php +++ b/tests/Feature/Monitoring/HeaderContextBarTest.php @@ -40,6 +40,44 @@ ->assertRedirect(); }); +it('disables the tenant picker when no workspace is active (Global Mode)', function (): void { + $user = \App\Models\User::factory()->create(); + $workspace = \App\Models\Workspace::factory()->create(); + \App\Models\WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + Filament::setTenant(null, true); + + session()->forget(WorkspaceContext::SESSION_KEY); + + $this->actingAs($user) + ->get('/admin/workspaces') + ->assertOk() + ->assertSee('Select workspace') + ->assertSee('Select tenant') + ->assertSee('Choose a workspace first.') + ->assertDontSee('Search tenants…'); +}); + +it('renders the tenant indicator read-only on tenant-scoped pages (Filament tenant menu is primary)', function (): void { + $tenant = Tenant::factory()->create(['status' => 'active']); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, + ]) + ->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant))) + ->assertOk() + ->assertSee($tenant->getFilamentName()) + ->assertDontSee('Search tenants…') + ->assertDontSee('admin/select-tenant') + ->assertDontSee('Clear tenant context'); +}); + it('filters the header tenant picker to tenants the user can access', function (): void { $tenantA = Tenant::factory()->create(['status' => 'active']); [$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner', workspaceRole: 'readonly'); diff --git a/tests/Feature/Workspaces/WorkspaceNavigationHubTest.php b/tests/Feature/Workspaces/WorkspaceNavigationHubTest.php index 12bb772..71b9588 100644 --- a/tests/Feature/Workspaces/WorkspaceNavigationHubTest.php +++ b/tests/Feature/Workspaces/WorkspaceNavigationHubTest.php @@ -11,7 +11,7 @@ uses(RefreshDatabase::class); -it('shows "Switch workspace" navigation when no tenant is selected', function (): void { +it('does not show "Switch workspace" in sidebar navigation (topbar-only)', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -34,6 +34,7 @@ ->map(static fn ($item): string => $item->getLabel()) ->all(); - expect($labels)->toContain('Switch workspace'); + expect($labels)->not->toContain('Switch workspace'); + expect($labels)->toContain('Manage workspaces'); expect($labels)->not->toContain('Workspaces'); }); diff --git a/tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php b/tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php index f97d4a8..87c7372 100644 --- a/tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php +++ b/tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php @@ -32,6 +32,23 @@ ->assertOk(); }); +it('serves /admin/workspaces without an active workspace selected (Global Mode)', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(['slug' => 'acme']); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->get('/admin/workspaces') + ->assertOk() + ->assertSee('Select workspace') + ->assertSee('Choose a workspace first.'); +}); + it('serves the Workspaces view page tenantless at /admin/workspaces/{record}', function (): void { $user = User::factory()->create();