feat(spec-077): global mode + context bar redundancy cleanup

- Define Global Mode: /admin/workspaces is workspace-optional; allowlist in EnsureWorkspaceSelected

- Remove redundancy: no sidebar Switch workspace; no topbar Manage workspaces link; tenant context read-only on /admin/t/{tenant}

- Unify workspace creation auth via WorkspacePolicy + Gate enforcement

- Tests: vendor/bin/sail artisan test --compact tests/Feature/Workspaces tests/Feature/Monitoring tests/Feature/OpsUx tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php
This commit is contained in:
Ahmed Darrazi 2026-02-06 23:11:14 +01:00
parent ffbf342d52
commit a23684a852
14 changed files with 211 additions and 148 deletions

View File

@ -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,

View File

@ -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;
}
}

View File

@ -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();
}
/**

View File

@ -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)

View File

@ -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();

View File

@ -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,20 +70,36 @@ class="block px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
>
Switch workspace
</a>
@if ($canSeeAllWorkspaceTenants)
<a
href="{{ route('filament.admin.resources.workspaces.index') }}"
class="block px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
>
Manage workspaces
</a>
@endif
</x-filament::dropdown.list>
</x-filament::dropdown>
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700"></div>
@if (! $workspace)
<div class="flex items-center gap-2">
<x-filament::button
color="gray"
outlined
size="sm"
icon="heroicon-o-building-office-2"
disabled
>
Select tenant
</x-filament::button>
<div class="text-xs text-gray-500 dark:text-gray-400">Choose a workspace first.</div>
</div>
@elseif ($isTenantScopedRoute)
<x-filament::button
color="gray"
outlined
size="sm"
icon="heroicon-o-building-office-2"
disabled
>
{{ $currentTenantName ?? 'Tenant' }}
</x-filament::button>
@else
<x-filament::dropdown placement="bottom-start" teleport>
<x-slot name="trigger">
<x-filament::button
@ -102,9 +121,7 @@ class="block px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
@endif
</div>
@if (! $workspace)
<div class="text-xs text-gray-500 dark:text-gray-400">Choose a workspace first.</div>
@elseif ($tenants->isEmpty())
@if ($tenants->isEmpty())
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $canSeeAllWorkspaceTenants ? 'No tenants exist in this workspace.' : 'No tenants you can access in this workspace.' }}
</div>
@ -153,4 +170,5 @@ class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-80
</div>
</x-filament::dropdown.list>
</x-filament::dropdown>
@endif
</div>

View File

@ -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

View File

@ -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

View File

@ -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 users 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.

View File

@ -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
---

View File

@ -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);
});

View File

@ -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');

View File

@ -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');
});

View File

@ -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();