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