diff --git a/app/Filament/Pages/ChooseWorkspace.php b/app/Filament/Pages/ChooseWorkspace.php index 8de5ab3..33aec13 100644 --- a/app/Filament/Pages/ChooseWorkspace.php +++ b/app/Filament/Pages/ChooseWorkspace.php @@ -9,7 +9,6 @@ use App\Models\WorkspaceMembership; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; -use Filament\Facades\Filament; use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; use Filament\Pages\Page; @@ -138,25 +137,34 @@ public function createWorkspace(array $data): void private function redirectAfterWorkspaceSelected(User $user): string { - $tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel()); + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); - $tenants = $tenants instanceof Collection ? $tenants : collect($tenants); + if ($workspaceId === null) { + return self::getUrl(); + } - if ($tenants->isEmpty()) { - $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); + $workspace = Workspace::query()->whereKey($workspaceId)->first(); - if ($workspaceId !== null) { - $role = WorkspaceMembership::query() - ->where('workspace_id', $workspaceId) - ->where('user_id', $user->getKey()) - ->value('role'); + if (! $workspace instanceof Workspace) { + return self::getUrl(); + } - if (in_array($role, ['owner', 'manager'], true)) { - return route('filament.admin.tenant.registration'); - } + $tenantsQuery = $user->tenants() + ->where('workspace_id', $workspace->getKey()) + ->where('status', 'active'); + + $tenantCount = (int) $tenantsQuery->count(); + + if ($tenantCount === 0) { + return route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]); + } + + if ($tenantCount === 1) { + $tenant = $tenantsQuery->first(); + + if ($tenant !== null) { + return TenantDashboard::getUrl(tenant: $tenant); } - - return ChooseTenant::getUrl(); } return ChooseTenant::getUrl(); diff --git a/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php b/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php new file mode 100644 index 0000000..922ee8b --- /dev/null +++ b/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php @@ -0,0 +1,85 @@ +workspace = $workspace; + } + + /** + * @return Collection + */ + public function getTenants(): Collection + { + $user = auth()->user(); + + if (! $user instanceof User) { + return Tenant::query()->whereRaw('1 = 0')->get(); + } + + return $user->tenants() + ->where('workspace_id', $this->workspace->getKey()) + ->where('status', 'active') + ->orderBy('name') + ->get(); + } + + public function canRegisterTenant(): bool + { + return RegisterTenantPage::canView(); + } + + public function goToChooseTenant(): void + { + $this->redirect(ChooseTenant::getUrl()); + } + + public function openTenant(int $tenantId): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $tenant = Tenant::query() + ->where('status', 'active') + ->where('workspace_id', $this->workspace->getKey()) + ->whereKey($tenantId) + ->first(); + + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! $user->canAccessTenant($tenant)) { + abort(404); + } + + $this->redirect(TenantDashboard::getUrl(tenant: $tenant)); + } +} diff --git a/app/Http/Controllers/SwitchWorkspaceController.php b/app/Http/Controllers/SwitchWorkspaceController.php index 79a1a0f..15dbab8 100644 --- a/app/Http/Controllers/SwitchWorkspaceController.php +++ b/app/Http/Controllers/SwitchWorkspaceController.php @@ -5,11 +5,10 @@ namespace App\Http\Controllers; use App\Filament\Pages\ChooseTenant; -use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage; +use App\Filament\Pages\TenantDashboard; use App\Models\User; use App\Models\Workspace; use App\Support\Workspaces\WorkspaceContext; -use Filament\Facades\Filament; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -45,15 +44,22 @@ public function __invoke(Request $request): RedirectResponse $context->setCurrentWorkspace($workspace, $user, $request); - $tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel()); - $tenants = $tenants instanceof \Illuminate\Database\Eloquent\Collection ? $tenants : collect($tenants); + $tenantsQuery = $user->tenants() + ->where('workspace_id', $workspace->getKey()) + ->where('status', 'active'); - if ($tenants->isEmpty()) { - if (RegisterTenantPage::canView()) { - return redirect()->route('filament.admin.tenant.registration'); + $tenantCount = (int) $tenantsQuery->count(); + + if ($tenantCount === 0) { + return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]); + } + + if ($tenantCount === 1) { + $tenant = $tenantsQuery->first(); + + if ($tenant !== null) { + return redirect()->to(TenantDashboard::getUrl(tenant: $tenant)); } - - return redirect()->to(ChooseTenant::getUrl()); } return redirect()->to(ChooseTenant::getUrl()); diff --git a/app/Services/Auth/PostLoginRedirectResolver.php b/app/Services/Auth/PostLoginRedirectResolver.php index 06fce10..e45e15c 100644 --- a/app/Services/Auth/PostLoginRedirectResolver.php +++ b/app/Services/Auth/PostLoginRedirectResolver.php @@ -4,39 +4,27 @@ namespace App\Services\Auth; -use App\Filament\Pages\TenantDashboard; -use App\Models\Tenant; use App\Models\User; -use Illuminate\Support\Collection; +use App\Models\WorkspaceMembership; +use Illuminate\Support\Facades\Schema; class PostLoginRedirectResolver { public function resolve(User $user): string { - $tenants = $this->getActiveTenants($user); + $membershipQuery = WorkspaceMembership::query()->where('user_id', $user->getKey()); - if ($tenants->isEmpty()) { + $hasAnyActiveMembership = Schema::hasColumn('workspaces', 'archived_at') + ? $membershipQuery + ->join('workspaces', 'workspace_memberships.workspace_id', '=', 'workspaces.id') + ->whereNull('workspaces.archived_at') + ->exists() + : $membershipQuery->exists(); + + if (! $hasAnyActiveMembership) { return '/admin/no-access'; } - if ($tenants->count() === 1) { - /** @var Tenant $tenant */ - $tenant = $tenants->first(); - - return TenantDashboard::getUrl(tenant: $tenant); - } - - return '/admin/choose-tenant'; - } - - /** - * @return Collection - */ - private function getActiveTenants(User $user): Collection - { - return $user->tenants() - ->where('status', 'active') - ->orderBy('name') - ->get(); + return '/admin'; } } diff --git a/app/Support/Middleware/EnsureFilamentTenantSelected.php b/app/Support/Middleware/EnsureFilamentTenantSelected.php index 7f8255a..b789998 100644 --- a/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -25,6 +25,8 @@ public function handle(Request $request, Closure $next): Response { $panel = Filament::getCurrentOrDefaultPanel(); + $path = '/'.ltrim($request->path(), '/'); + if ($request->route()?->hasParameter('tenant')) { $user = $request->user(); @@ -78,6 +80,16 @@ public function handle(Request $request, Closure $next): Response return $next($request); } + 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) + ) { + $this->configureNavigationForRequest($panel); + + return $next($request); + } + if (filled(Filament::getTenant())) { $this->configureNavigationForRequest($panel); diff --git a/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php b/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php new file mode 100644 index 0000000..776dd02 --- /dev/null +++ b/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php @@ -0,0 +1,78 @@ + + +
+
+ Workspace: {{ $this->workspace->name }} +
+ + @php + $tenants = $this->getTenants(); + @endphp + + @if ($tenants->isEmpty()) +
+
No managed tenants yet.
+
+ Add a managed tenant to start inventory, drift, backups, and policy management. +
+ +
+ @if ($this->canRegisterTenant()) + + Add managed tenant + + @endif + + + Change workspace + +
+
+ @else +
+
+ {{ $tenants->count() }} managed tenant{{ $tenants->count() === 1 ? '' : 's' }} +
+ + + Choose tenant + +
+ +
+ @foreach ($tenants as $tenant) +
+
+
+ {{ $tenant->name }} +
+ + + Open + +
+
+ @endforeach +
+ @endif +
+
+
diff --git a/routes/web.php b/routes/web.php index 9b90ef0..bf89564 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,12 +1,14 @@ get('/admin', function (Request $request) { $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request); + $user = $request->user(); + + if (! $user instanceof User) { + return redirect()->to('/admin/choose-workspace'); + } + if ($workspaceId === null) { return redirect()->to('/admin/choose-workspace'); } + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return redirect()->to('/admin/choose-workspace'); + } + + $tenantsQuery = $user->tenants() + ->where('workspace_id', $workspace->getKey()) + ->where('status', 'active'); + + $tenantCount = (int) $tenantsQuery->count(); + + if ($tenantCount === 0) { + return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]); + } + + if ($tenantCount === 1) { + $tenant = $tenantsQuery->first(); + + if ($tenant !== null) { + return redirect()->to(TenantDashboard::getUrl(tenant: $tenant)); + } + } + return redirect()->to('/admin/choose-tenant'); }) ->name('admin.home'); @@ -137,18 +169,29 @@ Route::middleware(['web', 'auth', 'ensure-workspace-member']) ->prefix('/admin/w/{workspace}') ->group(function (): void { - Route::get('/', fn () => redirect('/admin/choose-tenant')) + Route::get('/', fn () => redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => request()->route('workspace')])) ->name('admin.workspace.home'); Route::get('/ping', fn () => response()->noContent())->name('admin.workspace.ping'); - Route::get('/managed-tenants', fn () => redirect('/admin/choose-tenant')) - ->name('admin.workspace.managed-tenants.index'); - Route::get('/managed-tenants/onboarding', fn () => redirect('/admin/register-tenant')) ->name('admin.workspace.managed-tenants.onboarding'); }); +Route::middleware([ + 'web', + 'panel:admin', + 'ensure-correct-guard:web', + DenyNonMemberTenantAccess::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + FilamentAuthenticate::class, + 'ensure-workspace-member', + 'ensure-filament-tenant-selected', +]) + ->get('/admin/w/{workspace}/managed-tenants', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class) + ->name('admin.workspace.managed-tenants.index'); + if (app()->runningUnitTests()) { Route::middleware(['web', 'auth', 'ensure-workspace-selected']) ->get('/admin/_test/workspace-context', function (Request $request) { diff --git a/specs/072-managed-tenants-workspace-enforcement/tasks.md b/specs/072-managed-tenants-workspace-enforcement/tasks.md index c25422a..1ee239c 100644 --- a/specs/072-managed-tenants-workspace-enforcement/tasks.md +++ b/specs/072-managed-tenants-workspace-enforcement/tasks.md @@ -21,7 +21,7 @@ ## UX follow-ups - [x] T210 Add a workspace switcher in the user menu (link to Choose Workspace). - [x] T220 Add regression tests for workspace switcher + tenant selection. - [x] T230 Ensure `/admin` lands on workspace-first flow (avoid redirecting to tenant registration). -- [x] T240 After choosing a workspace with zero tenants, route into tenant registration (not empty Choose Tenant). +- [x] T240 After choosing a workspace with zero tenants, route into the workspace Managed Tenants landing (with CTA). - [x] T250 Allow workspace owners to register the first tenant in a workspace (bootstrap). ## Security hardening (owners / audit / recovery) diff --git a/tests/Feature/Auth/PostLoginRoutingByMembershipTest.php b/tests/Feature/Auth/PostLoginRoutingByMembershipTest.php index b37319d..4032432 100644 --- a/tests/Feature/Auth/PostLoginRoutingByMembershipTest.php +++ b/tests/Feature/Auth/PostLoginRoutingByMembershipTest.php @@ -2,10 +2,11 @@ declare(strict_types=1); -use App\Filament\Pages\TenantDashboard; use App\Models\Tenant; use App\Models\TenantMembership; use App\Models\User; +use App\Models\Workspace; +use App\Models\WorkspaceMembership; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; @@ -72,7 +73,17 @@ function entra_fake_token_exchange(string $tid, string $oid): void 'entra_object_id' => 'object-1', ]); - $tenant = Tenant::factory()->create(['status' => 'active']); + $workspace = Workspace::factory()->create(); + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => $workspace->getKey(), + ]); TenantMembership::query()->create([ 'tenant_id' => $tenant->getKey(), @@ -89,7 +100,7 @@ function entra_fake_token_exchange(string $tid, string $oid): void ->withSession(['entra_state' => $state]) ->get(route('auth.entra.callback', ['code' => 'code-123', 'state' => $state])); - $response->assertRedirect(TenantDashboard::getUrl(tenant: $tenant)); + $response->assertRedirect('/admin'); }); it('routes to choose-tenant when user has multiple tenant memberships', function () { @@ -101,7 +112,17 @@ function entra_fake_token_exchange(string $tid, string $oid): void 'entra_object_id' => 'object-1', ]); - $tenants = Tenant::factory()->count(2)->create(['status' => 'active']); + $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([ @@ -120,5 +141,5 @@ function entra_fake_token_exchange(string $tid, string $oid): void ->withSession(['entra_state' => $state]) ->get(route('auth.entra.callback', ['code' => 'code-123', 'state' => $state])); - $response->assertRedirect('/admin/choose-tenant'); + $response->assertRedirect('/admin'); }); diff --git a/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php b/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php index a6c6fe5..87aecdb 100644 --- a/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php +++ b/tests/Feature/Filament/AdminHomeRedirectsToChooseTenantWhenWorkspaceSelectedTest.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use App\Filament\Pages\TenantDashboard; +use App\Models\Tenant; +use App\Models\TenantMembership; use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; @@ -10,7 +13,7 @@ uses(RefreshDatabase::class); -it('redirects /admin to choose-tenant when a workspace is selected', function (): void { +it('redirects /admin to the workspace managed-tenants landing when a workspace is selected and has no tenants', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -25,5 +28,71 @@ ->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->get('/admin') - ->assertRedirect(route('filament.admin.pages.choose-tenant')); + ->assertRedirect(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()])); +}); + +it('redirects /admin to choose-tenant when a workspace is selected and has multiple tenants', 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') + ->assertRedirect('/admin/choose-tenant'); +}); + +it('redirects /admin to the tenant dashboard when a workspace is selected and has exactly one tenant', function (): void { + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => $workspace->getKey(), + ]); + + 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') + ->assertRedirect(TenantDashboard::getUrl(tenant: $tenant)); }); diff --git a/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php b/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php index f0cee18..149ff6f 100644 --- a/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php +++ b/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Filament\Pages\ChooseWorkspace; +use App\Filament\Pages\TenantDashboard; use App\Models\Tenant; use App\Models\TenantMembership; use App\Models\User; @@ -13,7 +14,7 @@ uses(RefreshDatabase::class); -it('redirects to tenant registration after selecting a workspace with no tenants', function (): void { +it('redirects to the workspace managed-tenants landing after selecting a workspace with no tenants', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -26,10 +27,10 @@ Livewire::actingAs($user) ->test(ChooseWorkspace::class) ->call('selectWorkspace', $workspace->getKey()) - ->assertRedirect(route('filament.admin.tenant.registration')); + ->assertRedirect(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()])); }); -it('redirects to choose-tenant after selecting a workspace with tenants', function (): void { +it('redirects to the tenant dashboard after selecting a workspace with exactly one tenant', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -53,6 +54,38 @@ 'created_by_user_id' => null, ]); + Livewire::actingAs($user) + ->test(ChooseWorkspace::class) + ->call('selectWorkspace', $workspace->getKey()) + ->assertRedirect(TenantDashboard::getUrl(tenant: $tenant)); +}); + +it('redirects to choose-tenant after selecting a workspace with multiple tenants', 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, + ]); + } + Livewire::actingAs($user) ->test(ChooseWorkspace::class) ->call('selectWorkspace', $workspace->getKey()) diff --git a/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php b/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php index c93f556..18ab164 100644 --- a/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php +++ b/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php @@ -33,6 +33,42 @@ ->assertRedirect("/admin/w/{$workspace->slug}/managed-tenants"); }); +it('keeps the managed-tenants landing tenantless even if the user has a tenant in another workspace', function (): void { + $user = User::factory()->create(); + + $workspaceEmpty = Workspace::factory()->create(['slug' => 'empty']); + $workspaceOther = Workspace::factory()->create(['slug' => 'other']); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceEmpty->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspaceOther->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $tenantInOther = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $workspaceOther->getKey(), + 'external_id' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + 'tenant_id' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenantInOther->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceEmpty->getKey()]) + ->get('/admin/w/'.$workspaceEmpty->slug.'/managed-tenants') + ->assertSuccessful() + ->assertDontSee('/admin/t/'.$tenantInOther->external_id, false); +}); + it('returns 404 on tenant routes when workspace context is missing', function (): void { $user = User::factory()->create();