From 5421aa06ae4ede2dc2f42343770153444b46d124 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Fri, 6 Feb 2026 20:33:32 +0100 Subject: [PATCH 1/4] feat(spec-077): workspace navigation + monitoring hub --- app/Filament/Pages/ChooseTenant.php | 3 + app/Filament/Pages/ChooseWorkspace.php | 9 +- app/Filament/Pages/Monitoring/Alerts.php | 26 +++ app/Filament/Pages/Monitoring/AuditLog.php | 26 +++ app/Filament/Pages/Monitoring/Operations.php | 148 +++++------- .../Resources/OperationRunResource.php | 65 +++++- .../TenantResource/Pages/ViewTenant.php | 2 + .../Workspaces/WorkspaceResource.php | 23 ++ .../Widgets/Dashboard/DashboardKpis.php | 5 +- .../Tenant/RecentOperationsSummary.php | 56 +++++ .../ClearTenantContextController.php | 22 ++ .../Controllers/SelectTenantController.php | 2 + .../Controllers/SwitchWorkspaceController.php | 7 + .../Middleware/EnsureWorkspaceSelected.php | 5 + app/Policies/WorkspacePolicy.php | 47 +++- app/Providers/Filament/AdminPanelProvider.php | 44 +++- .../Intune/TenantPermissionService.php | 23 +- .../EnsureFilamentTenantSelected.php | 125 +++++----- app/Support/OperationRunLinks.php | 5 +- app/Support/Workspaces/WorkspaceContext.php | 52 +++++ .../Workspaces/WorkspaceIntendedUrl.php | 126 ++++++++++ .../pages/monitoring/alerts.blade.php | 6 + .../pages/monitoring/audit-log.blade.php | 6 + .../pages/monitoring/operations.blade.php | 33 +++ .../filament/partials/context-bar.blade.php | 102 ++++++++ .../partials/workspace-switcher.blade.php | 4 +- .../recent-operations-summary.blade.php | 55 +++++ routes/web.php | 47 ++++ .../checklists/requirements.md | 35 +++ .../contracts/routes.md | 67 ++++++ .../data-model.md | 66 ++++++ .../077-workspace-nav-monitoring-hub/plan.md | 219 ++++++++++++++++++ .../quickstart.md | 50 ++++ .../research.md | 58 +++++ .../077-workspace-nav-monitoring-hub/spec.md | 162 +++++++++++++ .../077-workspace-nav-monitoring-hub/tasks.md | 215 +++++++++++++++++ .../Monitoring/HeaderContextBarTest.php | 110 +++++++++ .../OperationsActionsEnqueueRunTest.php | 8 +- .../OperationsCanonicalUrlsTest.php | 142 ++++++++++++ .../Monitoring/OperationsDbOnlyTest.php | 15 +- .../Monitoring/OperationsTenantScopeTest.php | 33 ++- tests/Feature/MonitoringOperationsTest.php | 110 +++++---- .../NonLeakageWorkspaceOperationsTest.php | 59 +++++ .../RunAuthorizationTenantIsolationTest.php | 84 ++++--- .../ManagedTenantsWorkspaceRoutingTest.php | 2 + .../Workspaces/WorkspaceIntendedUrlTest.php | 40 ++++ .../Workspaces/WorkspaceNavigationHubTest.php | 39 ++++ .../WorkspacesResourceIsTenantlessTest.php | 18 ++ 48 files changed, 2330 insertions(+), 276 deletions(-) create mode 100644 app/Filament/Pages/Monitoring/Alerts.php create mode 100644 app/Filament/Pages/Monitoring/AuditLog.php create mode 100644 app/Filament/Widgets/Tenant/RecentOperationsSummary.php create mode 100644 app/Http/Controllers/ClearTenantContextController.php create mode 100644 app/Support/Workspaces/WorkspaceIntendedUrl.php create mode 100644 resources/views/filament/pages/monitoring/alerts.blade.php create mode 100644 resources/views/filament/pages/monitoring/audit-log.blade.php create mode 100644 resources/views/filament/partials/context-bar.blade.php create mode 100644 resources/views/filament/widgets/tenant/recent-operations-summary.blade.php create mode 100644 specs/077-workspace-nav-monitoring-hub/checklists/requirements.md create mode 100644 specs/077-workspace-nav-monitoring-hub/contracts/routes.md create mode 100644 specs/077-workspace-nav-monitoring-hub/data-model.md create mode 100644 specs/077-workspace-nav-monitoring-hub/plan.md create mode 100644 specs/077-workspace-nav-monitoring-hub/quickstart.md create mode 100644 specs/077-workspace-nav-monitoring-hub/research.md create mode 100644 specs/077-workspace-nav-monitoring-hub/spec.md create mode 100644 specs/077-workspace-nav-monitoring-hub/tasks.md create mode 100644 tests/Feature/Monitoring/HeaderContextBarTest.php create mode 100644 tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php create mode 100644 tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php create mode 100644 tests/Feature/Workspaces/WorkspaceIntendedUrlTest.php create mode 100644 tests/Feature/Workspaces/WorkspaceNavigationHubTest.php diff --git a/app/Filament/Pages/ChooseTenant.php b/app/Filament/Pages/ChooseTenant.php index 31e8181..05ef5f5 100644 --- a/app/Filament/Pages/ChooseTenant.php +++ b/app/Filament/Pages/ChooseTenant.php @@ -7,6 +7,7 @@ use App\Models\Tenant; use App\Models\User; use App\Models\UserTenantPreference; +use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Filament\Pages\Page; use Illuminate\Database\Eloquent\Collection; @@ -69,6 +70,8 @@ public function selectTenant(int $tenantId): void $this->persistLastTenant($user, $tenant); + app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request()); + $this->redirect(TenantDashboard::getUrl(tenant: $tenant)); } diff --git a/app/Filament/Pages/ChooseWorkspace.php b/app/Filament/Pages/ChooseWorkspace.php index 33aec13..3a9052c 100644 --- a/app/Filament/Pages/ChooseWorkspace.php +++ b/app/Filament/Pages/ChooseWorkspace.php @@ -8,6 +8,7 @@ use App\Models\Workspace; use App\Models\WorkspaceMembership; use App\Support\Workspaces\WorkspaceContext; +use App\Support\Workspaces\WorkspaceIntendedUrl; use Filament\Actions\Action; use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; @@ -100,7 +101,9 @@ public function selectWorkspace(int $workspaceId): void $context->setCurrentWorkspace($workspace, $user, request()); - $this->redirect($this->redirectAfterWorkspaceSelected($user)); + $intendedUrl = WorkspaceIntendedUrl::consume(request()); + + $this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user)); } /** @@ -132,7 +135,9 @@ public function createWorkspace(array $data): void ->success() ->send(); - $this->redirect($this->redirectAfterWorkspaceSelected($user)); + $intendedUrl = WorkspaceIntendedUrl::consume(request()); + + $this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user)); } private function redirectAfterWorkspaceSelected(User $user): string diff --git a/app/Filament/Pages/Monitoring/Alerts.php b/app/Filament/Pages/Monitoring/Alerts.php new file mode 100644 index 0000000..3391709 --- /dev/null +++ b/app/Filament/Pages/Monitoring/Alerts.php @@ -0,0 +1,26 @@ +mountInteractsWithTable(); + } + + protected function getHeaderWidgets(): array + { + return [ + OperationsKpiHeader::class, + ]; + } + + public function updatedActiveTab(): void + { + $this->resetPage(); + } + public function table(Table $table): Table { - return $table - ->query( - OperationRun::query() - ->where('tenant_id', Filament::getTenant()->id) - ->latest('created_at') - ) - ->columns([ - TextColumn::make('type') - ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)) - ->searchable() - ->sortable(), + return OperationRunResource::table($table) + ->query(function (): Builder { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); - TextColumn::make('status') - ->badge() - ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus)) - ->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus)) - ->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)), + $query = OperationRun::query() + ->with('user') + ->latest('id') + ->when( + $workspaceId, + fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId), + ) + ->when( + ! $workspaceId, + fn (Builder $query): Builder => $query->whereRaw('1 = 0'), + ); - TextColumn::make('outcome') - ->badge() - ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome)) - ->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome)) - ->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)), + return $this->applyActiveTab($query); + }); + } - TextColumn::make('initiator_name') - ->label('Initiator') - ->searchable(), - - TextColumn::make('created_at') - ->dateTime() - ->sortable() - ->label('Started'), - - TextColumn::make('duration') - ->getStateUsing(function (OperationRun $record) { - if ($record->started_at && $record->completed_at) { - return $record->completed_at->diffForHumans($record->started_at, true); - } - - return '-'; - }), - ]) - ->filters([ - SelectFilter::make('outcome') - ->options([ - 'succeeded' => 'Succeeded', - 'partially_succeeded' => 'Partially Succeeded', - 'failed' => 'Failed', - 'cancelled' => 'Cancelled', - 'pending' => 'Pending', - ]), - - SelectFilter::make('type') - ->options( - fn () => OperationRun::where('tenant_id', Filament::getTenant()->id) - ->distinct() - ->pluck('type', 'type') - ->toArray() - ), - - Filter::make('created_at') - ->form([ - DatePicker::make('created_from'), - DatePicker::make('created_until'), - ]) - ->query(function (Builder $query, array $data): Builder { - return $query - ->when( - $data['created_from'], - fn (Builder $query, $date) => $query->whereDate('created_at', '>=', $date), - ) - ->when( - $data['created_until'], - fn (Builder $query, $date) => $query->whereDate('created_at', '<=', $date), - ); - }), - ]) - ->actions([ - // View action handled by opening a modal or side-peek - ]); + private function applyActiveTab(Builder $query): Builder + { + return match ($this->activeTab) { + 'active' => $query->whereIn('status', [ + OperationRunStatus::Queued->value, + OperationRunStatus::Running->value, + ]), + 'succeeded' => $query + ->where('status', OperationRunStatus::Completed->value) + ->where('outcome', OperationRunOutcome::Succeeded->value), + 'partial' => $query + ->where('status', OperationRunStatus::Completed->value) + ->where('outcome', OperationRunOutcome::PartiallySucceeded->value), + 'failed' => $query + ->where('status', OperationRunStatus::Completed->value) + ->where('outcome', OperationRunOutcome::Failed->value), + default => $query, + }; } } diff --git a/app/Filament/Resources/OperationRunResource.php b/app/Filament/Resources/OperationRunResource.php index 608ae3c..ca5bdc2 100644 --- a/app/Filament/Resources/OperationRunResource.php +++ b/app/Filament/Resources/OperationRunResource.php @@ -17,8 +17,10 @@ use App\Support\OperationRunStatus; use App\Support\OpsUx\RunDetailPolling; use App\Support\OpsUx\RunDurationInsights; +use App\Support\Workspaces\WorkspaceContext; use BackedEnum; use Filament\Actions; +use Filament\Facades\Filament; use Filament\Forms\Components\DatePicker; use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\ViewEntry; @@ -38,6 +40,8 @@ class OperationRunResource extends Resource protected static ?string $slug = 'operations'; + protected static bool $shouldRegisterNavigation = false; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list'; protected static string|UnitEnum|null $navigationGroup = 'Monitoring'; @@ -46,12 +50,13 @@ class OperationRunResource extends Resource public static function getEloquentQuery(): Builder { - $tenantId = Tenant::current()?->getKey(); + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); return parent::getEloquentQuery() ->with('user') ->latest('id') - ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)); + ->when($workspaceId, fn (Builder $query) => $query->where('workspace_id', (int) $workspaceId)) + ->when(! $workspaceId, fn (Builder $query) => $query->whereRaw('1 = 0')); } public static function form(Schema $schema): Schema @@ -156,7 +161,7 @@ public static function infolist(Schema $schema): Schema $previousRunUrl = null; if ($changeIndicator !== null) { - $tenant = Tenant::current(); + $tenant = Filament::getTenant(); $previousRunUrl = $tenant instanceof Tenant ? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant) @@ -272,16 +277,47 @@ public static function table(Table $table): Table ->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)), ]) ->filters([ + Tables\Filters\SelectFilter::make('tenant_id') + ->label('Tenant') + ->options(function (): array { + $user = auth()->user(); + + if (! $user instanceof User) { + return []; + } + + return collect($user->getTenants(Filament::getCurrentOrDefaultPanel())) + ->mapWithKeys(static fn (Tenant $tenant): array => [ + (string) $tenant->getKey() => $tenant->getFilamentName(), + ]) + ->all(); + }) + ->default(function (): ?string { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return null; + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); + + if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) { + return null; + } + + return (string) $tenant->getKey(); + }) + ->searchable(), Tables\Filters\SelectFilter::make('type') ->options(function (): array { - $tenantId = Tenant::current()?->getKey(); + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); - if (! $tenantId) { + if ($workspaceId === null) { return []; } return OperationRun::query() - ->where('tenant_id', $tenantId) + ->where('workspace_id', (int) $workspaceId) ->select('type') ->distinct() ->orderBy('type') @@ -299,14 +335,20 @@ public static function table(Table $table): Table Tables\Filters\SelectFilter::make('initiator_name') ->label('Initiator') ->options(function (): array { - $tenantId = Tenant::current()?->getKey(); + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); - if (! $tenantId) { + if ($workspaceId === null) { return []; } + $tenant = Filament::getTenant(); + $tenantId = $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspaceId + ? (int) $tenant->getKey() + : null; + return OperationRun::query() - ->where('tenant_id', $tenantId) + ->where('workspace_id', (int) $workspaceId) + ->when($tenantId, fn (Builder $query): Builder => $query->where('tenant_id', $tenantId)) ->whereNotNull('initiator_name') ->select('initiator_name') ->distinct() @@ -342,7 +384,8 @@ public static function table(Table $table): Table }), ]) ->actions([ - Actions\ViewAction::make(), + Actions\ViewAction::make() + ->url(fn (OperationRun $record): string => route('admin.operations.view', ['run' => (int) $record->getKey()])), ]) ->bulkActions([]); } @@ -351,7 +394,7 @@ public static function getPages(): array { return [ 'index' => Pages\ListOperationRuns::route('/'), - 'view' => Pages\ViewOperationRun::route('/{record}'), + 'view' => Pages\ViewOperationRun::route('/r/{record}'), ]; } diff --git a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php index f92b500..34f86c9 100644 --- a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource; +use App\Filament\Widgets\Tenant\RecentOperationsSummary; use App\Filament\Widgets\Tenant\TenantArchivedBanner; use App\Models\Tenant; use App\Services\Intune\AuditLogger; @@ -23,6 +24,7 @@ protected function getHeaderWidgets(): array { return [ TenantArchivedBanner::class, + RecentOperationsSummary::class, ]; } diff --git a/app/Filament/Resources/Workspaces/WorkspaceResource.php b/app/Filament/Resources/Workspaces/WorkspaceResource.php index e3c8b75..9885c80 100644 --- a/app/Filament/Resources/Workspaces/WorkspaceResource.php +++ b/app/Filament/Resources/Workspaces/WorkspaceResource.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\Workspaces; use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager; +use App\Models\User; use App\Models\Workspace; use BackedEnum; use Filament\Actions; @@ -11,6 +12,7 @@ use Filament\Schemas\Schema; use Filament\Tables; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; use UnitEnum; class WorkspaceResource extends Resource @@ -25,10 +27,31 @@ class WorkspaceResource extends Resource protected static bool $shouldRegisterNavigation = false; + protected static ?string $breadcrumb = 'Manage workspaces'; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2'; protected static string|UnitEnum|null $navigationGroup = 'Settings'; + public static function getEloquentQuery(): Builder + { + $query = parent::getEloquentQuery(); + + $user = auth()->user(); + + if (! $user instanceof User) { + return $query->whereRaw('1 = 0'); + } + + return $query + ->whereNull('archived_at') + ->whereIn('id', function ($subQuery) use ($user): void { + $subQuery->from('workspace_memberships') + ->select('workspace_id') + ->where('user_id', $user->getKey()); + }); + } + public static function form(Schema $schema): Schema { return $schema diff --git a/app/Filament/Widgets/Dashboard/DashboardKpis.php b/app/Filament/Widgets/Dashboard/DashboardKpis.php index 9dc3cc5..b6522dc 100644 --- a/app/Filament/Widgets/Dashboard/DashboardKpis.php +++ b/app/Filament/Widgets/Dashboard/DashboardKpis.php @@ -5,7 +5,6 @@ namespace App\Filament\Widgets\Dashboard; use App\Filament\Resources\FindingResource; -use App\Filament\Resources\OperationRunResource; use App\Models\Finding; use App\Models\OperationRun; use App\Models\Tenant; @@ -81,10 +80,10 @@ protected function getStats(): array ->url(FindingResource::getUrl('index', tenant: $tenant)), Stat::make('Active operations', $activeRuns) ->color($activeRuns > 0 ? 'warning' : 'gray') - ->url(OperationRunResource::getUrl('index', tenant: $tenant)), + ->url(route('admin.operations.index')), Stat::make('Inventory active', $inventoryActiveRuns) ->color($inventoryActiveRuns > 0 ? 'warning' : 'gray') - ->url(OperationRunResource::getUrl('index', tenant: $tenant)), + ->url(route('admin.operations.index')), ]; } } diff --git a/app/Filament/Widgets/Tenant/RecentOperationsSummary.php b/app/Filament/Widgets/Tenant/RecentOperationsSummary.php new file mode 100644 index 0000000..36bc776 --- /dev/null +++ b/app/Filament/Widgets/Tenant/RecentOperationsSummary.php @@ -0,0 +1,56 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + 'tenant' => null, + 'runs' => collect(), + 'operationsIndexUrl' => route('admin.operations.index'), + ]; + } + + /** @var Collection $runs */ + $runs = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->limit(5) + ->get([ + 'id', + 'type', + 'status', + 'outcome', + 'created_at', + 'started_at', + 'completed_at', + ]); + + return [ + 'tenant' => $tenant, + 'runs' => $runs, + 'operationsIndexUrl' => route('admin.operations.index'), + ]; + } +} diff --git a/app/Http/Controllers/ClearTenantContextController.php b/app/Http/Controllers/ClearTenantContextController.php new file mode 100644 index 0000000..839a415 --- /dev/null +++ b/app/Http/Controllers/ClearTenantContextController.php @@ -0,0 +1,22 @@ +clearLastTenantId($request); + + return redirect()->to('/admin/operations'); + } +} diff --git a/app/Http/Controllers/SelectTenantController.php b/app/Http/Controllers/SelectTenantController.php index e95bb6f..edc5701 100644 --- a/app/Http/Controllers/SelectTenantController.php +++ b/app/Http/Controllers/SelectTenantController.php @@ -49,6 +49,8 @@ public function __invoke(Request $request): RedirectResponse $this->persistLastTenant($user, $tenant); + app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request); + return redirect()->to(TenantDashboard::getUrl(tenant: $tenant)); } diff --git a/app/Http/Controllers/SwitchWorkspaceController.php b/app/Http/Controllers/SwitchWorkspaceController.php index 0b65bdd..4a28801 100644 --- a/app/Http/Controllers/SwitchWorkspaceController.php +++ b/app/Http/Controllers/SwitchWorkspaceController.php @@ -9,6 +9,7 @@ use App\Models\User; use App\Models\Workspace; use App\Support\Workspaces\WorkspaceContext; +use App\Support\Workspaces\WorkspaceIntendedUrl; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -44,6 +45,12 @@ public function __invoke(Request $request): RedirectResponse $context->setCurrentWorkspace($workspace, $user, $request); + $intendedUrl = WorkspaceIntendedUrl::consume($request); + + if ($intendedUrl !== null) { + return redirect()->to($intendedUrl); + } + $tenantsQuery = $user->tenants() ->where('workspace_id', $workspace->getKey()) ->where('status', 'active'); diff --git a/app/Http/Middleware/EnsureWorkspaceSelected.php b/app/Http/Middleware/EnsureWorkspaceSelected.php index ec5fe4e..d207cc6 100644 --- a/app/Http/Middleware/EnsureWorkspaceSelected.php +++ b/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -5,6 +5,7 @@ use App\Models\User; 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; @@ -75,6 +76,10 @@ public function handle(Request $request, Closure $next): Response $target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access'; + if ($target === '/admin/choose-workspace') { + WorkspaceIntendedUrl::storeFromRequest($request); + } + return new HttpResponse('', 302, ['Location' => $target]); } } diff --git a/app/Policies/WorkspacePolicy.php b/app/Policies/WorkspacePolicy.php index 0a40b4b..87d2e52 100644 --- a/app/Policies/WorkspacePolicy.php +++ b/app/Policies/WorkspacePolicy.php @@ -6,46 +6,77 @@ 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; class WorkspacePolicy { /** * Determine whether the user can view any models. */ - public function viewAny(User $user): bool + public function viewAny(User $user): bool|Response { - return true; + $isMember = WorkspaceMembership::query() + ->where('user_id', $user->getKey()) + ->exists(); + + return $isMember ? Response::allow() : Response::denyAsNotFound(); } /** * Determine whether the user can view the model. */ - public function view(User $user, Workspace $workspace): bool + public function view(User $user, Workspace $workspace): bool|Response { - return WorkspaceMembership::query() + $isMember = WorkspaceMembership::query() ->where('user_id', $user->getKey()) ->where('workspace_id', $workspace->getKey()) ->exists(); + + return $isMember ? Response::allow() : Response::denyAsNotFound(); } /** * Determine whether the user can create models. */ - public function create(User $user): bool + public function create(User $user): bool|Response { - return true; + $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(); } /** * Determine whether the user can update the model. */ - public function update(User $user, Workspace $workspace): bool + public function update(User $user, Workspace $workspace): bool|Response { /** @var WorkspaceCapabilityResolver $resolver */ $resolver = app(WorkspaceCapabilityResolver::class); - return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE); + if (! $resolver->isMember($user, $workspace)) { + return Response::denyAsNotFound(); + } + + return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE) + ? Response::allow() + : Response::deny(); } /** diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index fb1de8c..5f32e5e 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -9,6 +9,10 @@ use App\Filament\Pages\TenantDashboard; use App\Filament\Resources\Workspaces\WorkspaceResource; use App\Models\Tenant; +use App\Models\User; +use App\Models\WorkspaceMembership; +use App\Services\Auth\WorkspaceRoleCapabilityMap; +use App\Support\Auth\Capabilities; use App\Support\Middleware\DenyNonMemberTenantAccess; use Filament\Facades\Filament; use Filament\Http\Middleware\Authenticate; @@ -53,18 +57,56 @@ public function panel(Panel $panel): Panel 'primary' => Color::Amber, ]) ->navigationItems([ - NavigationItem::make('Workspaces') + 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) + ->visible(function (): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE); + + return WorkspaceMembership::query() + ->where('user_id', (int) $user->getKey()) + ->whereIn('role', $roles) + ->exists(); + }), + NavigationItem::make('Operations') + ->url(fn (): string => route('admin.operations.index')) + ->icon('heroicon-o-queue-list') + ->group('Monitoring') ->sort(10), + NavigationItem::make('Alerts') + ->url(fn (): string => route('admin.monitoring.alerts')) + ->icon('heroicon-o-bell-alert') + ->group('Monitoring') + ->sort(20), + NavigationItem::make('Audit Log') + ->url(fn (): string => route('admin.monitoring.audit-log')) + ->icon('heroicon-o-clipboard-document-list') + ->group('Monitoring') + ->sort(30), ]) ->renderHook( PanelsRenderHook::HEAD_END, fn () => view('filament.partials.livewire-intercept-shim')->render() ) + ->renderHook( + PanelsRenderHook::TOPBAR_START, + fn () => view('filament.partials.context-bar')->render() + ) ->renderHook( PanelsRenderHook::USER_MENU_PROFILE_AFTER, fn () => view('filament.partials.workspace-switcher')->render() diff --git a/app/Services/Intune/TenantPermissionService.php b/app/Services/Intune/TenantPermissionService.php index 5f768fa..31a47de 100644 --- a/app/Services/Intune/TenantPermissionService.php +++ b/app/Services/Intune/TenantPermissionService.php @@ -135,9 +135,28 @@ public function compare( $canPersist = $persist; - if ($liveCheckMeta['attempted'] === true && $liveCheckMeta['succeeded'] === false) { + if ($canPersist && $liveCheckMeta['attempted'] === true && $liveCheckMeta['succeeded'] === false) { // Enterprise-safe: never overwrite stored inventory when we could not refresh it. - $canPersist = false; + // When the failure is a deterministic misconfiguration (e.g. permission denied), persist an "error" snapshot + // only if we have no stored inventory yet, so the UI can explain the failure. + $reasonCode = is_string($liveCheckMeta['reason_code'] ?? null) + ? (string) $liveCheckMeta['reason_code'] + : null; + + $shouldPersistErrorSnapshot = in_array($reasonCode, [ + 'authentication_failed', + 'permission_denied', + ], true); + + if (! $shouldPersistErrorSnapshot) { + $canPersist = false; + } else { + $hasStoredStatuses = TenantPermission::query() + ->where('tenant_id', $tenant->id) + ->exists(); + + $canPersist = ! $hasStoredStatuses; + } } foreach ($required as $permission) { diff --git a/app/Support/Middleware/EnsureFilamentTenantSelected.php b/app/Support/Middleware/EnsureFilamentTenantSelected.php index 3cf9b61..8482519 100644 --- a/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -6,7 +6,9 @@ use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; -use App\Services\Auth\CapabilityResolver; +use App\Models\WorkspaceMembership; +use App\Services\Auth\WorkspaceRoleCapabilityMap; +use App\Support\Auth\Capabilities; use App\Support\Workspaces\WorkspaceContext; use Closure; use Filament\Facades\Filament; @@ -27,6 +29,14 @@ public function handle(Request $request, Closure $next): Response $path = '/'.ltrim($request->path(), '/'); + $workspaceContext = app(WorkspaceContext::class); + $workspaceId = $workspaceContext->currentWorkspaceId($request); + + $existingTenant = Filament::getTenant(); + if ($existingTenant instanceof Tenant && $workspaceId !== null && (int) $existingTenant->workspace_id !== (int) $workspaceId) { + Filament::setTenant(null, true); + } + if ($path === '/livewire/update') { $refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? ''; $refererPath = '/'.ltrim((string) $refererPath, '/'); @@ -44,6 +54,12 @@ public function handle(Request $request, Closure $next): Response return $next($request); } + if ($path === '/admin/operations') { + $this->configureNavigationForRequest($panel); + + return $next($request); + } + if ($request->route()?->hasParameter('tenant')) { $user = $request->user(); @@ -66,9 +82,6 @@ public function handle(Request $request, Closure $next): Response abort(404); } - $workspaceContext = app(WorkspaceContext::class); - $workspaceId = $workspaceContext->currentWorkspaceId($request); - if ($workspaceId === null) { abort(404); } @@ -92,6 +105,9 @@ public function handle(Request $request, Closure $next): Response } Filament::setTenant($tenant, true); + + app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request); + $this->configureNavigationForRequest($panel); return $next($request); @@ -100,7 +116,8 @@ public function handle(Request $request, Closure $next): Response if ( str_starts_with($path, '/admin/w/') || str_starts_with($path, '/admin/workspaces') - || in_array($path, ['/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access'], true) + || 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'], true) ) { $this->configureNavigationForRequest($panel); @@ -121,60 +138,6 @@ public function handle(Request $request, Closure $next): Response return $next($request); } - $tenant = null; - - $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request); - - if ($workspaceId !== null) { - $tenant = $user->tenants() - ->where('workspace_id', $workspaceId) - ->where('status', 'active') - ->first(); - - if (! $tenant) { - $tenant = $user->tenants() - ->where('workspace_id', $workspaceId) - ->first(); - } - - if (! $tenant) { - $tenant = $user->tenants() - ->withTrashed() - ->where('workspace_id', $workspaceId) - ->first(); - } - } - - if (! $tenant) { - try { - $tenant = Tenant::current(); - } catch (\RuntimeException) { - $tenant = null; - } - - if ($tenant instanceof Tenant && ! app(CapabilityResolver::class)->isMember($user, $tenant)) { - $tenant = null; - } - } - - if (! $tenant) { - $tenant = $user->tenants() - ->where('status', 'active') - ->first(); - } - - if (! $tenant) { - $tenant = $user->tenants()->first(); - } - - if (! $tenant) { - $tenant = $user->tenants()->withTrashed()->first(); - } - - if ($tenant) { - Filament::setTenant($tenant, true); - } - $this->configureNavigationForRequest($panel); return $next($request); @@ -195,11 +158,53 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void $panel->navigation(function (): NavigationBuilder { return app(NavigationBuilder::class) ->item( - NavigationItem::make('Workspaces') + 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) + ->visible(function (): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE); + + return WorkspaceMembership::query() + ->where('user_id', (int) $user->getKey()) + ->whereIn('role', $roles) + ->exists(); + }), + ) + ->item( + NavigationItem::make('Operations') + ->url(fn (): string => route('admin.operations.index')) + ->icon('heroicon-o-queue-list') + ->group('Monitoring') + ->sort(10), + ) + ->item( + NavigationItem::make('Alerts') + ->url(fn (): string => '/admin/alerts') + ->icon('heroicon-o-bell-alert') + ->group('Monitoring') + ->sort(20), + ) + ->item( + NavigationItem::make('Audit Log') + ->url(fn (): string => '/admin/audit-log') + ->icon('heroicon-o-clipboard-document-list') + ->group('Monitoring') + ->sort(30), ); }); } diff --git a/app/Support/OperationRunLinks.php b/app/Support/OperationRunLinks.php index 0aad35c..65ce699 100644 --- a/app/Support/OperationRunLinks.php +++ b/app/Support/OperationRunLinks.php @@ -7,7 +7,6 @@ use App\Filament\Resources\BackupScheduleResource; use App\Filament\Resources\BackupSetResource; use App\Filament\Resources\EntraGroupResource; -use App\Filament\Resources\OperationRunResource; use App\Filament\Resources\PolicyResource; use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\RestoreRunResource; @@ -18,7 +17,7 @@ final class OperationRunLinks { public static function index(Tenant $tenant): string { - return OperationRunResource::getUrl('index', tenant: $tenant); + return route('admin.operations.index'); } public static function tenantlessView(OperationRun|int $run): string @@ -30,7 +29,7 @@ public static function tenantlessView(OperationRun|int $run): string public static function view(OperationRun|int $run, Tenant $tenant): string { - return OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant); + return self::tenantlessView($run); } /** diff --git a/app/Support/Workspaces/WorkspaceContext.php b/app/Support/Workspaces/WorkspaceContext.php index 6ed0554..16a50d7 100644 --- a/app/Support/Workspaces/WorkspaceContext.php +++ b/app/Support/Workspaces/WorkspaceContext.php @@ -11,6 +11,10 @@ final class WorkspaceContext { public const SESSION_KEY = 'current_workspace_id'; + public const INTENDED_URL_SESSION_KEY = 'workspace_intended_url'; + + public const LAST_TENANT_IDS_SESSION_KEY = 'workspace_last_tenant_ids'; + public function __construct(private WorkspaceResolver $resolver) {} public function currentWorkspaceId(?Request $request = null): ?int @@ -53,6 +57,54 @@ public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?R } } + public function rememberLastTenantId(int $workspaceId, int $tenantId, ?Request $request = null): void + { + $session = ($request && $request->hasSession()) ? $request->session() : session(); + + $map = $session->get(self::LAST_TENANT_IDS_SESSION_KEY, []); + $map = is_array($map) ? $map : []; + + $map[(string) $workspaceId] = $tenantId; + + $session->put(self::LAST_TENANT_IDS_SESSION_KEY, $map); + } + + public function lastTenantId(?Request $request = null): ?int + { + $workspaceId = $this->currentWorkspaceId($request); + + if ($workspaceId === null) { + return null; + } + + $session = ($request && $request->hasSession()) ? $request->session() : session(); + + $map = $session->get(self::LAST_TENANT_IDS_SESSION_KEY, []); + $map = is_array($map) ? $map : []; + + $id = $map[(string) $workspaceId] ?? null; + + return is_int($id) ? $id : (is_numeric($id) ? (int) $id : null); + } + + public function clearLastTenantId(?Request $request = null): void + { + $workspaceId = $this->currentWorkspaceId($request); + + if ($workspaceId === null) { + return; + } + + $session = ($request && $request->hasSession()) ? $request->session() : session(); + + $map = $session->get(self::LAST_TENANT_IDS_SESSION_KEY, []); + $map = is_array($map) ? $map : []; + + unset($map[(string) $workspaceId]); + + $session->put(self::LAST_TENANT_IDS_SESSION_KEY, $map); + } + public function clearCurrentWorkspace(?User $user = null, ?Request $request = null): void { $session = ($request && $request->hasSession()) ? $request->session() : session(); diff --git a/app/Support/Workspaces/WorkspaceIntendedUrl.php b/app/Support/Workspaces/WorkspaceIntendedUrl.php new file mode 100644 index 0000000..a00a909 --- /dev/null +++ b/app/Support/Workspaces/WorkspaceIntendedUrl.php @@ -0,0 +1,126 @@ +put(WorkspaceContext::INTENDED_URL_SESSION_KEY, $pathWithQuery); + } + + /** + * Store the intended URL derived from the current request. + */ + public static function storeFromRequest(Request $request): void + { + if (! $request->isMethod('GET')) { + return; + } + + $path = '/'.ltrim($request->path(), '/'); + + $queryString = $request->getQueryString(); + $pathWithQuery = $queryString ? "{$path}?{$queryString}" : $path; + + self::store($pathWithQuery, $request); + } + + /** + * Consume (read + forget) the intended URL. Returns null if missing or unsafe. + */ + public static function consume(?Request $request = null): ?string + { + $session = self::session($request); + + if (! $session instanceof Store) { + return null; + } + + $value = $session->pull(WorkspaceContext::INTENDED_URL_SESSION_KEY); + + if (! is_string($value)) { + return null; + } + + $value = trim($value); + + if ($value === '' || ! self::isAllowed($value)) { + return null; + } + + return $value; + } + + public static function clear(?Request $request = null): void + { + $session = self::session($request); + + if (! $session instanceof Store) { + return; + } + + $session->forget(WorkspaceContext::INTENDED_URL_SESSION_KEY); + } + + private static function session(?Request $request = null): ?Store + { + $session = ($request && $request->hasSession()) + ? $request->session() + : session()->driver(); + + return $session instanceof Store ? $session : null; + } + + private static function isAllowed(string $pathWithQuery): bool + { + if (str_contains($pathWithQuery, "\n") || str_contains($pathWithQuery, "\r")) { + return false; + } + + if (preg_match('#^https?://#i', $pathWithQuery) === 1) { + return false; + } + + if (str_starts_with($pathWithQuery, '//')) { + return false; + } + + if (! str_starts_with($pathWithQuery, '/admin')) { + return false; + } + + $path = parse_url($pathWithQuery, PHP_URL_PATH); + $path = '/'.ltrim((string) ($path ?? ''), '/'); + + if (in_array($path, ['/admin/choose-workspace', '/admin/no-access'], true)) { + return false; + } + + return true; + } +} diff --git a/resources/views/filament/pages/monitoring/alerts.blade.php b/resources/views/filament/pages/monitoring/alerts.blade.php new file mode 100644 index 0000000..306c863 --- /dev/null +++ b/resources/views/filament/pages/monitoring/alerts.blade.php @@ -0,0 +1,6 @@ +
+
+ Alerts is reserved for future work. +
+
+ diff --git a/resources/views/filament/pages/monitoring/audit-log.blade.php b/resources/views/filament/pages/monitoring/audit-log.blade.php new file mode 100644 index 0000000..0ab0b00 --- /dev/null +++ b/resources/views/filament/pages/monitoring/audit-log.blade.php @@ -0,0 +1,6 @@ +
+
+ Audit Log is reserved for future work. +
+
+ diff --git a/resources/views/filament/pages/monitoring/operations.blade.php b/resources/views/filament/pages/monitoring/operations.blade.php index ce096a2..4e2ca6f 100644 --- a/resources/views/filament/pages/monitoring/operations.blade.php +++ b/resources/views/filament/pages/monitoring/operations.blade.php @@ -1,3 +1,36 @@ + + + All + + + Active + + + Succeeded + + + Partial + + + Failed + + + {{ $this->table }} diff --git a/resources/views/filament/partials/context-bar.blade.php b/resources/views/filament/partials/context-bar.blade.php new file mode 100644 index 0000000..948f282 --- /dev/null +++ b/resources/views/filament/partials/context-bar.blade.php @@ -0,0 +1,102 @@ +@php + use App\Filament\Pages\ChooseWorkspace; + use App\Models\Tenant; + use App\Models\User; + use App\Support\Workspaces\WorkspaceContext; + use Filament\Facades\Filament; + + /** @var WorkspaceContext $workspaceContext */ + $workspaceContext = app(WorkspaceContext::class); + + $workspace = $workspaceContext->currentWorkspace(request()); + + $user = auth()->user(); + + $tenants = collect(); + if ($user instanceof User) { + $tenants = collect($user->getTenants(Filament::getCurrentOrDefaultPanel())); + } + + $currentTenant = Filament::getTenant(); + $currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null; + + $lastTenantId = $workspaceContext->lastTenantId(request()); + $canClearTenantContext = $currentTenantName !== null || $lastTenantId !== null; +@endphp + +
+ + Workspace: + {{ $workspace?->name ?? '—' }} + + +
+ + + + + + + +
+
Tenant context
+ + @if (! $workspace) +
Choose a workspace first.
+ @elseif ($tenants->isEmpty()) +
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 +
+
+
+
diff --git a/resources/views/filament/partials/workspace-switcher.blade.php b/resources/views/filament/partials/workspace-switcher.blade.php index 7c2020f..aab6cab 100644 --- a/resources/views/filament/partials/workspace-switcher.blade.php +++ b/resources/views/filament/partials/workspace-switcher.blade.php @@ -25,7 +25,7 @@
@csrf -
Workspace
+
Switch workspace
diff --git a/tests/Feature/Monitoring/HeaderContextBarTest.php b/tests/Feature/Monitoring/HeaderContextBarTest.php index 25334d8..5e08df9 100644 --- a/tests/Feature/Monitoring/HeaderContextBarTest.php +++ b/tests/Feature/Monitoring/HeaderContextBarTest.php @@ -11,6 +11,8 @@ $tenant = Tenant::factory()->create(['status' => 'active']); [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + $workspaceName = $tenant->workspace?->name; + Filament::setTenant(null, true); $this->actingAs($user) @@ -22,9 +24,10 @@ ]) ->get('/admin/operations') ->assertOk() - ->assertSee('Workspace:') - ->assertSee('Tenant:') - ->assertSee('Select tenant…') + ->assertSee($workspaceName ?? 'Select workspace') + ->assertSee('Select tenant') + ->assertSee('Search tenants…') + ->assertSee('Switch workspace') ->assertSee('admin/select-tenant') ->assertSee('Clear tenant context') ->assertSee($tenant->getFilamentName()); @@ -39,7 +42,7 @@ 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'); + [$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner', workspaceRole: 'readonly'); $tenantB = Tenant::factory()->create([ 'status' => 'active', @@ -59,6 +62,28 @@ ->assertDontSee($tenantB->getFilamentName()); }); +it('shows all workspace tenants in the header tenant picker for workspace owners', function (): void { + $tenantA = Tenant::factory()->create(['status' => 'active']); + [$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner', workspaceRole: 'owner'); + + $tenantB = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $tenantA->workspace_id, + 'name' => 'ZZZ-UNASSIGNED-TENANT-NAME-12345', + ]); + + Filament::setTenant(null, true); + + $this->actingAs($user) + ->withSession([ + WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id, + ]) + ->get('/admin/operations') + ->assertOk() + ->assertSee($tenantA->getFilamentName()) + ->assertSee($tenantB->getFilamentName()); +}); + it('does not implicitly switch tenant when opening canonical operation deep links', function (): void { $tenantA = Tenant::factory()->create(['status' => 'active']); [$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner'); -- 2.45.2 From a23684a85203963dfea0643894d2d82bc683a0c1 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Fri, 6 Feb 2026 23:11:14 +0100 Subject: [PATCH 4/4] 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 --- app/Filament/Pages/ChooseWorkspace.php | 9 + .../Middleware/EnsureWorkspaceSelected.php | 51 ++++-- app/Policies/WorkspacePolicy.php | 26 +-- app/Providers/Filament/AdminPanelProvider.php | 11 +- .../EnsureFilamentTenantSelected.php | 10 +- .../filament/partials/context-bar.blade.php | 160 ++++++++++-------- .../contracts/routes.md | 4 +- .../077-workspace-nav-monitoring-hub/plan.md | 12 +- .../077-workspace-nav-monitoring-hub/spec.md | 6 +- .../077-workspace-nav-monitoring-hub/tasks.md | 5 + ...aceContextTopbarAndTenantSelectionTest.php | 5 +- .../Monitoring/HeaderContextBarTest.php | 38 +++++ .../Workspaces/WorkspaceNavigationHubTest.php | 5 +- .../WorkspacesResourceIsTenantlessTest.php | 17 ++ 14 files changed, 211 insertions(+), 148 deletions(-) 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(); -- 2.45.2