fix: consolidate tenant creation + harden selection flows

This commit is contained in:
Ahmed Darrazi 2026-02-22 20:51:50 +01:00
parent 88ba8a14d8
commit 0856aeaa72
13 changed files with 181 additions and 322 deletions

View File

@ -27,6 +27,17 @@ class ChooseTenant extends Page
protected string $view = 'filament.pages.choose-tenant';
/**
* Disable the simple-layout topbar to prevent lazy-loaded
* DatabaseNotifications from triggering Livewire update 404s.
*/
protected function getLayoutData(): array
{
return [
'hasTopbar' => false,
];
}
/**
* @return Collection<int, Tenant>
*/

View File

@ -75,6 +75,18 @@ class ManagedTenantOnboardingWizard extends Page
protected static ?string $slug = 'onboarding';
/**
* Disable the simple-layout topbar to prevent lazy-loaded
* DatabaseNotifications from triggering Livewire update 404s
* on this workspace-scoped route.
*/
protected function getLayoutData(): array
{
return [
'hasTopbar' => false,
];
}
public Workspace $workspace;
public ?Tenant $managedTenant = null;

View File

@ -17,7 +17,8 @@ protected function getHeaderActions(): array
return [
CreateAction::make()
->label('Create baseline profile')
->disabled(fn (): bool => ! BaselineProfileResource::canCreate()),
->disabled(fn (): bool => ! BaselineProfileResource::canCreate())
->visible(fn (): bool => $this->getTableRecords()->count() > 0),
];
}
}

View File

@ -12,7 +12,6 @@
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap;
use App\Services\Directory\EntraGroupLabelResolver;
@ -76,29 +75,13 @@ class TenantResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Settings';
/**
* Tenant creation is handled exclusively by the onboarding wizard.
* The CRUD create page has been removed.
*/
public static function canCreate(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
if (static::userCanManageAnyTenant($user)) {
return true;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId === null) {
return false;
}
return WorkspaceMembership::query()
->where('workspace_id', $workspaceId)
->where('user_id', $user->getKey())
->whereIn('role', ['owner', 'manager'])
->exists();
return false;
}
public static function canEdit(Model $record): bool
@ -999,7 +982,6 @@ public static function getPages(): array
{
return [
'index' => Pages\ListTenants::route('/'),
'create' => Pages\CreateTenant::route('/create'),
'view' => Pages\ViewTenant::route('/{record}'),
'edit' => Pages\EditTenant::route('/{record}/edit'),
'memberships' => Pages\ManageTenantMemberships::route('/{record}/memberships'),

View File

@ -1,41 +0,0 @@
<?php
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Resources\Pages\CreateRecord;
class CreateTenant extends CreateRecord
{
protected static string $resource = TenantResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId !== null) {
$data['workspace_id'] = $workspaceId;
}
return $data;
}
protected function afterCreate(): void
{
$user = auth()->user();
if (! $user instanceof User) {
return;
}
$user->tenants()->syncWithoutDetaching([
$this->record->getKey() => ['role' => 'owner'],
]);
}
}

View File

@ -13,9 +13,10 @@ class ListTenants extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->disabled(fn (): bool => ! TenantResource::canCreate())
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.')
Actions\Action::make('add_tenant')
->label('Add tenant')
->icon('heroicon-m-plus')
->url(route('admin.onboarding'))
->visible(fn (): bool => $this->getTableRecords()->count() > 0),
];
}
@ -23,9 +24,10 @@ protected function getHeaderActions(): array
protected function getTableEmptyStateActions(): array
{
return [
Actions\CreateAction::make()
->disabled(fn (): bool => ! TenantResource::canCreate())
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'),
Actions\Action::make('add_tenant')
->label('Add tenant')
->icon('heroicon-m-plus')
->url(route('admin.onboarding')),
];
}
}

View File

@ -163,7 +163,7 @@ public function panel(Panel $panel): Panel
)
->renderHook(
PanelsRenderHook::BODY_END,
fn (): string => request()->routeIs('admin.workspace.managed-tenants.index')
fn (): string => request()->routeIs('admin.workspace.managed-tenants.index', 'admin.onboarding', 'filament.admin.pages.choose-tenant')
? ''
: ((bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
? view('livewire.bulk-operation-progress-wrapper')->render()

View File

@ -1,60 +1,133 @@
<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">
Select a tenant to continue.
@php
$tenants = $this->getTenants();
$workspace = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspace();
@endphp
@if ($tenants->isEmpty())
{{-- Empty state --}}
<div class="mx-auto max-w-md">
<div class="rounded-xl border border-gray-200 bg-white p-8 text-center shadow-sm dark:border-white/10 dark:bg-white/5">
@if ($workspace)
<div class="mb-5 inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs font-medium text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
<x-filament::icon icon="heroicon-m-building-office-2" class="h-3.5 w-3.5" />
{{ $workspace->name }}
</div>
@endif
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 dark:bg-primary-950/30">
<x-filament::icon
icon="heroicon-o-server-stack"
class="h-7 w-7 text-primary-500 dark:text-primary-400"
/>
</div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">No tenants available</h3>
<p class="mx-auto mt-2 max-w-xs text-sm text-gray-500 dark:text-gray-400">
There are no active tenants in this workspace yet. Add one via onboarding, or switch to a different workspace.
</p>
<div class="mt-6 flex flex-col items-center gap-3">
<x-filament::button
tag="a"
href="{{ route('admin.onboarding') }}"
icon="heroicon-m-plus"
size="lg"
>
Add tenant
</x-filament::button>
<a href="{{ route('filament.admin.pages.choose-workspace') }}"
class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<x-filament::icon icon="heroicon-m-arrows-right-left" class="h-4 w-4" />
Switch workspace
</a>
</div>
</div>
</div>
@else
{{-- Tenant list --}}
<div class="mx-auto max-w-3xl">
{{-- Header row --}}
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-2">
@if ($workspace)
<div class="inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs font-medium text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
<x-filament::icon icon="heroicon-m-building-office-2" class="h-3.5 w-3.5" />
{{ $workspace->name }}
</div>
@endif
<span class="text-sm text-gray-500 dark:text-gray-400">
&middot; {{ $tenants->count() }} {{ \Illuminate\Support\Str::plural('tenant', $tenants->count()) }}
</span>
</div>
</div>
@php
$tenants = $this->getTenants();
@endphp
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">Select a tenant to continue.</p>
@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 tenants are available for your account.</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Switch workspaces, or contact an administrator.
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<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="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($tenants as $tenant)
<div
wire:key="tenant-{{ $tenant->id }}"
x-data
@click="if ($event.target.closest('button,a,input,select,textarea')) return; $refs.form.submit();"
class="cursor-pointer rounded-lg border border-gray-200 p-4 dark:border-gray-800"
>
<form x-ref="form" method="POST" action="{{ route('admin.select-tenant') }}" class="flex flex-col gap-3">
@csrf
<input type="hidden" name="tenant_id" value="{{ (int) $tenant->id }}" />
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ $tenant->name }}
</div>
<x-filament::button
type="submit"
color="primary"
class="w-full"
>
Continue
</x-filament::button>
</form>
{{-- Tenant cards --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-{{ min($tenants->count(), 3) }}">
@foreach ($tenants as $tenant)
<button
type="button"
wire:key="tenant-{{ $tenant->id }}"
wire:click="selectTenant({{ (int) $tenant->id }})"
class="group relative flex flex-col rounded-xl border border-gray-200 bg-white p-5 text-left shadow-sm transition-all duration-150 hover:border-gray-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:border-white/10 dark:bg-white/5 dark:hover:border-white/20 dark:focus:ring-offset-gray-900"
>
{{-- Loading overlay --}}
<div wire:loading wire:target="selectTenant({{ (int) $tenant->id }})"
class="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-white/80 dark:bg-gray-900/80">
<x-filament::loading-indicator class="h-5 w-5 text-primary-500" />
</div>
@endforeach
</div>
@endif
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-gray-100 group-hover:bg-gray-200 dark:bg-white/10 dark:group-hover:bg-white/15">
<x-filament::icon
icon="heroicon-o-server-stack"
class="h-5 w-5 text-gray-500 group-hover:text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-300"
/>
</div>
<div class="min-w-0 flex-1">
<h3 class="truncate text-sm font-semibold text-gray-900 dark:text-white">
{{ $tenant->name }}
</h3>
@if ($tenant->domain)
<p class="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">
{{ $tenant->domain }}
</p>
@endif
@if ($tenant->environment)
<span class="mt-1.5 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-white/10 dark:text-gray-400">
{{ strtoupper($tenant->environment) }}
</span>
@endif
</div>
</div>
{{-- Hover arrow --}}
<div class="absolute right-4 top-5 opacity-0 transition-opacity group-hover:opacity-100">
<x-filament::icon
icon="heroicon-m-arrow-right"
class="h-4 w-4 text-gray-400 dark:text-gray-500"
/>
</div>
</button>
@endforeach
</div>
{{-- Footer links --}}
<div class="mt-6 flex items-center justify-center gap-6">
<a href="{{ route('admin.onboarding') }}"
class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<x-filament::icon icon="heroicon-m-plus" class="h-4 w-4" />
Add tenant
</a>
<a href="{{ route('filament.admin.pages.choose-workspace') }}"
class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<x-filament::icon icon="heroicon-m-arrows-right-left" class="h-4 w-4" />
Switch workspace
</a>
</div>
</div>
</x-filament::section>
@endif
</x-filament-panels::page>

View File

@ -1,170 +1,3 @@
<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>
<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">
Managed tenant onboarding
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
This wizard will guide you through identifying a managed tenant and verifying access.
</div>
</div>
@if ($this->managedTenant)
<div class="rounded-md border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200">
<div class="font-medium text-gray-900 dark:text-gray-100">Identified tenant</div>
<dl class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Name</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $this->managedTenant->name }}</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Tenant ID</dt>
<dd class="mt-1 font-mono text-sm text-gray-900 dark:text-gray-100">{{ $this->managedTenant->tenant_id }}</dd>
</div>
</dl>
</div>
@endif
@php
$verificationSucceeded = $this->verificationSucceeded();
$hasTenant = (bool) $this->managedTenant;
$hasConnection = $hasTenant && is_int($this->selectedProviderConnectionId) && $this->selectedProviderConnectionId > 0;
@endphp
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 1 Identify managed tenant</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Provide tenant ID + display name to start or resume the flow.</div>
</div>
<div class="text-xs font-medium {{ $hasTenant ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $hasTenant ? 'Done' : 'Pending' }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="primary"
wire:click="mountAction('identifyManagedTenant')"
>
{{ $hasTenant ? 'Change tenant' : 'Identify tenant' }}
</x-filament::button>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 2 Provider connection</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Create or pick the connection used to verify access.</div>
</div>
<div class="text-xs font-medium {{ $hasConnection ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $hasConnection ? 'Selected' : ($hasTenant ? 'Pending' : 'Locked') }}
</div>
</div>
@if ($hasTenant)
<div class="mt-3 text-sm text-gray-700 dark:text-gray-200">
<span class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Selected connection ID</span>
<div class="mt-1 font-mono">{{ $this->selectedProviderConnectionId ?? '—' }}</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="gray"
wire:click="mountAction('createProviderConnection')"
>
Create connection
</x-filament::button>
<x-filament::button
type="button"
color="gray"
wire:click="mountAction('selectProviderConnection')"
>
Select connection
</x-filament::button>
</div>
@endif
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 3 Verify access</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Runs a verification operation and records the result.</div>
</div>
<div class="text-xs font-medium {{ $verificationSucceeded ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $verificationSucceeded ? 'Succeeded' : ($hasConnection ? 'Pending' : 'Locked') }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="primary"
:disabled="! $hasConnection"
wire:click="mountAction('startVerification')"
>
Run verification
</x-filament::button>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 4 Bootstrap (optional)</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Start inventory/compliance sync after verification.</div>
</div>
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ $verificationSucceeded ? 'Available' : 'Locked' }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="gray"
:disabled="! $verificationSucceeded"
wire:click="mountAction('startBootstrap')"
>
Start bootstrap
</x-filament::button>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Step 5 Complete onboarding</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">Marks the tenant as active after successful verification.</div>
</div>
<div class="text-xs font-medium {{ $verificationSucceeded ? 'text-emerald-700 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $verificationSucceeded ? 'Ready' : 'Locked' }}
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<x-filament::button
type="button"
color="success"
:disabled="! $verificationSucceeded"
wire:click="mountAction('completeOnboarding')"
>
Complete onboarding
</x-filament::button>
</div>
</div>
</div>
</x-filament::section>
</x-filament-panels::page>

View File

@ -25,7 +25,7 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/choose-tenant')
->assertSuccessful()
->assertSee('No tenants are available')
->assertSee('No tenants available')
->assertDontSee('Register tenant');
});
@ -44,7 +44,7 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/choose-tenant')
->assertSuccessful()
->assertSee('No tenants are available')
->assertSee('Change workspace')
->assertSee('No tenants available')
->assertSee('Switch workspace')
->assertDontSee('Register tenant');
});

View File

@ -1,6 +1,5 @@
<?php
use App\Filament\Resources\TenantResource\Pages\CreateTenant;
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
@ -18,33 +17,22 @@
uses(RefreshDatabase::class);
test('tenant can be created via filament and verification start enqueues an operation run', function () {
test('verification start enqueues an operation run for a tenant', function () {
Queue::fake();
bindFailHardGraphClient();
$user = User::factory()->create();
$this->actingAs($user);
$contextTenant = Tenant::create([
'tenant_id' => 'tenant-context',
'name' => 'Context Tenant',
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-guid',
'name' => 'Contoso',
'environment' => 'other',
'domain' => 'contoso.com',
]);
[$user, $contextTenant] = createUserWithTenant($contextTenant, $user, role: 'owner');
[$user, $tenant] = createUserWithTenant($tenant, $user, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
Filament::setTenant($contextTenant, true);
Livewire::test(CreateTenant::class)
->fillForm([
'name' => 'Contoso',
'environment' => 'other',
'tenant_id' => 'tenant-guid',
'domain' => 'contoso.com',
])
->call('create')
->assertHasNoFormErrors();
$tenant = Tenant::query()->where('tenant_id', 'tenant-guid')->first();
expect($tenant)->not->toBeNull();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),

View File

@ -1,7 +1,7 @@
<?php
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Resources\TenantResource\Pages\CreateTenant;
use App\Filament\Resources\TenantResource;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -24,12 +24,10 @@
Filament::setCurrentPanel(null);
});
test('readonly users cannot create tenants', function () {
test('readonly users cannot create tenants via CRUD', function () {
[$user] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
Livewire::actingAs($user)
->test(CreateTenant::class)
->assertStatus(403);
expect(TenantResource::canCreate())->toBeFalse();
});

View File

@ -15,12 +15,12 @@
expect(TenantResource::canCreate())->toBeFalse();
});
it('can be created by managers (TENANT_MANAGE)', function () {
it('cannot be created via CRUD (onboarding wizard is the only path)', function () {
[$user] = createUserWithTenant(role: 'manager');
$this->actingAs($user);
expect(TenantResource::canCreate())->toBeTrue();
expect(TenantResource::canCreate())->toBeFalse();
});
it('can be edited by managers (TENANT_MANAGE)', function () {