Compare commits

...

8 Commits

Author SHA1 Message Date
Ahmed Darrazi
98c7408c00 fix(ui): hide tenant-scoped admin nav without tenant 2026-02-11 21:59:56 +01:00
Ahmed Darrazi
c1eda3b19f Merge branch 'dev' into 085-tenant-operate-hub 2026-02-11 14:06:00 +01:00
Ahmed Darrazi
e76ef02fab merge: agent session work 2026-02-11 13:58:26 +01:00
Ahmed Darrazi
bd19864a42 fix(spec-085-086): stabilize ops UX + provider connection fixtures 2026-02-11 13:50:44 +01:00
Ahmed Darrazi
c8e5996a1a Merge branch '086-retire-legacy-runs-into-operation-runs-session-1770683729' into 085-tenant-operate-hub
# Conflicts:
#	.github/agents/copilot-instructions.md
2026-02-11 01:04:09 +01:00
Ahmed Darrazi
b870c0c8d4 feat(spec-086): retire legacy runs into operation runs 2026-02-11 01:03:00 +01:00
Ahmed Darrazi
6dab6297f8 feat(spec-085): tenant operate hub 2026-02-11 01:02:42 +01:00
Ahmed Darrazi
11f7209783 spec(086): retire legacy runs into operation runs 2026-02-10 01:35:24 +01:00
6 changed files with 151 additions and 0 deletions

View File

@ -4,7 +4,11 @@
use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Widgets\Inventory\InventoryKpiHeader; use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\CoverageCapabilitiesResolver; use App\Services\Inventory\CoverageCapabilitiesResolver;
use App\Support\Auth\Capabilities;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use BackedEnum; use BackedEnum;
use Filament\Pages\Page; use Filament\Pages\Page;
@ -24,6 +28,22 @@ class InventoryCoverage extends Page
protected string $view = 'filament.pages.inventory-coverage'; protected string $view = 'filament.pages.inventory-coverage';
public static function canAccess(): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
protected function getHeaderWidgets(): array protected function getHeaderWidgets(): array
{ {
return [ return [

View File

@ -49,6 +49,22 @@ class BackupSetResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
public static function canViewAny(): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public static function canCreate(): bool public static function canCreate(): bool
{ {
$tenant = Tenant::current(); $tenant = Tenant::current();

View File

@ -11,6 +11,7 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\PolicyNormalizer;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
@ -58,6 +59,22 @@ class PolicyResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function canViewAny(): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)

View File

@ -57,6 +57,22 @@ class PolicyVersionResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function canViewAny(): bool
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)

View File

@ -72,6 +72,14 @@ public function handle(Request $request, Closure $next): Response
$tenantParameter = $request->query('tenant'); $tenantParameter = $request->query('tenant');
} }
if (
$tenantParameter === null
&& ! filled(Filament::getTenant())
&& $this->adminPathRequiresTenantSelection($path)
) {
return redirect()->route('filament.admin.pages.choose-tenant');
}
if ($tenantParameter !== null) { if ($tenantParameter !== null) {
$user = $request->user(); $user = $request->user();
@ -208,4 +216,13 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void
); );
}); });
} }
private function adminPathRequiresTenantSelection(string $path): bool
{
if (! str_starts_with($path, '/admin/')) {
return false;
}
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets)(/|$)#', $path) === 1;
}
} }

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('redirects tenant-scoped admin surfaces to choose-tenant when no tenant is selected', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenants = Tenant::factory()->count(2)->create([
'status' => 'active',
'workspace_id' => $workspace->getKey(),
]);
foreach ($tenants as $tenant) {
TenantMembership::query()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
'source' => 'manual',
'source_ref' => null,
'created_by_user_id' => null,
]);
}
$this
->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/policies')
->assertRedirect('/admin/choose-tenant');
$this
->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/policy-versions')
->assertRedirect('/admin/choose-tenant');
$this
->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/backup-sets')
->assertRedirect('/admin/choose-tenant');
$this
->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/inventory')
->assertRedirect('/admin/choose-tenant');
});