feat: workspace-first admin landing

Route /admin based on tenant count in current workspace; add managed-tenants landing; keep tenant selection workspace-scoped; update tests.
This commit is contained in:
Ahmed Darrazi 2026-02-02 23:58:11 +01:00
parent 6079ccb766
commit 41672c9a79
12 changed files with 442 additions and 63 deletions

View File

@ -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());
$tenants = $tenants instanceof Collection ? $tenants : collect($tenants);
if ($tenants->isEmpty()) {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId !== null) {
$role = WorkspaceMembership::query()
->where('workspace_id', $workspaceId)
->where('user_id', $user->getKey())
->value('role');
if (in_array($role, ['owner', 'manager'], true)) {
return route('filament.admin.tenant.registration');
}
if ($workspaceId === null) {
return self::getUrl();
}
return ChooseTenant::getUrl();
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return self::getUrl();
}
$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();

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Workspaces;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\Tenancy\RegisterTenant as RegisterTenantPage;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
class ManagedTenantsLanding extends Page
{
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $title = 'Managed tenants';
protected string $view = 'filament.pages.workspaces.managed-tenants-landing';
public Workspace $workspace;
public function mount(Workspace $workspace): void
{
$this->workspace = $workspace;
}
/**
* @return Collection<int, Tenant>
*/
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));
}
}

View File

@ -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()]);
}
return redirect()->to(ChooseTenant::getUrl());
if ($tenantCount === 1) {
$tenant = $tenantsQuery->first();
if ($tenant !== null) {
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
}
}
return redirect()->to(ChooseTenant::getUrl());

View File

@ -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<int, Tenant>
*/
private function getActiveTenants(User $user): Collection
{
return $user->tenants()
->where('status', 'active')
->orderBy('name')
->get();
return '/admin';
}
}

View File

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

View File

@ -0,0 +1,78 @@
<x-filament-panels::page>
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Workspace: <span class="font-medium text-gray-900 dark:text-gray-100">{{ $this->workspace->name }}</span>
</div>
@php
$tenants = $this->getTenants();
@endphp
@if ($tenants->isEmpty())
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="font-medium text-gray-900 dark:text-gray-100">No managed tenants yet.</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Add a managed tenant to start inventory, drift, backups, and policy management.
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
@if ($this->canRegisterTenant())
<x-filament::button
type="button"
color="primary"
tag="a"
href="{{ route('filament.admin.tenant.registration') }}"
>
Add managed tenant
</x-filament::button>
@endif
<x-filament::button
type="button"
color="gray"
tag="a"
href="{{ route('filament.admin.pages.choose-workspace') }}"
>
Change workspace
</x-filament::button>
</div>
</div>
@else
<div class="flex items-center justify-between gap-3">
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $tenants->count() }} managed tenant{{ $tenants->count() === 1 ? '' : 's' }}
</div>
<x-filament::button
type="button"
color="gray"
wire:click="goToChooseTenant"
>
Choose tenant
</x-filament::button>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($tenants as $tenant)
<div wire:key="tenant-{{ $tenant->id }}" class="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
<div class="flex flex-col gap-3">
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ $tenant->name }}
</div>
<x-filament::button
type="button"
color="primary"
wire:click="openTenant({{ (int) $tenant->id }})"
>
Open
</x-filament::button>
</div>
</div>
@endforeach
</div>
@endif
</div>
</x-filament::section>
</x-filament-panels::page>

View File

@ -1,12 +1,14 @@
<?php
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Pages\TenantDashboard;
use App\Http\Controllers\AdminConsentCallbackController;
use App\Http\Controllers\Auth\EntraController;
use App\Http\Controllers\RbacDelegatedAuthController;
use App\Http\Controllers\SelectTenantController;
use App\Http\Controllers\SwitchWorkspaceController;
use App\Http\Controllers\TenantOnboardingController;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use App\Support\Workspaces\WorkspaceContext;
@ -42,10 +44,40 @@
->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) {

View File

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

View File

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

View File

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

View File

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

View File

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