+ {{ $canSeeAllWorkspaceTenants ? 'No tenants exist in this workspace.' : 'No tenants you can access in this workspace.' }}
+
+ @else
+
+
+
+
+ @foreach ($tenants as $tenant)
+
+ @endforeach
+
+
+ @if ($canClearTenantContext)
+
+ @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)
-
- @endforeach
-
-
- @if ($canClearTenantContext)
-
- @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();