## Summary
- centralize tenant operability into a lane-aware, actor-aware policy boundary
- align selector eligibility, administrative discoverability, remembered context, tenant-bound routes, and canonical run viewers
- add focused Pest coverage plus Spec 148 artifacts and final polish task completion
## Validation
- `vendor/bin/sail artisan test --compact tests/Unit/Tenants/TenantOperabilityServiceTest.php tests/Unit/Tenants/TenantOperabilityOutcomeTest.php tests/Feature/Workspaces/ChooseTenantPageTest.php tests/Feature/Workspaces/SelectTenantControllerTest.php tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php tests/Feature/TenantRBAC/TenantRouteDenyAsNotFoundTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/OpsUx/OperateHubShellTest.php tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php tests/Feature/Rbac/TenantResourceAuthorizationTest.php tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`
- `vendor/bin/sail bin pint --dirty --format agent`
- manual browser smoke checks on `/admin/choose-tenant`, `/admin/tenants`, `/admin/onboarding`, `/admin/onboarding/{draft}`, and `/admin/operations/{run}`
## Filament / platform notes
- Livewire v4 compliance preserved
- panel provider registration unchanged in `bootstrap/providers.php`
- Tenant resource global search remains backed by existing view/edit pages and is now separated from active-only selector eligibility
- destructive actions remain action closures with confirmation and authorization enforcement
- no asset pipeline changes and no new `filament:assets` deployment requirement
Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #177
214 lines
12 KiB
PHP
214 lines
12 KiB
PHP
@php
|
|
use App\Filament\Pages\ChooseTenant;
|
|
use App\Filament\Pages\ChooseWorkspace;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Support\OperateHub\OperateHubShell;
|
|
use App\Support\Tenants\TenantPageCategory;
|
|
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 && $workspace) {
|
|
$tenants = collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
|
->filter(fn ($tenant): bool => $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspace->getKey())
|
|
->values();
|
|
}
|
|
|
|
$operateHubShell = app(OperateHubShell::class);
|
|
$currentTenant = $operateHubShell->activeEntitledTenant(request());
|
|
$currentTenantId = $currentTenant instanceof Tenant ? (int) $currentTenant->getKey() : null;
|
|
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
|
|
|
|
$hasAnyFilamentTenantContext = Filament::getTenant() instanceof Tenant;
|
|
|
|
$route = request()->route();
|
|
$routeName = (string) ($route?->getName() ?? '');
|
|
$pageCategory = TenantPageCategory::fromRequest(request());
|
|
$tenantQuery = request()->query('tenant');
|
|
$hasTenantQuery = is_string($tenantQuery) && trim($tenantQuery) !== '';
|
|
|
|
$isTenantScopedRoute = $pageCategory === TenantPageCategory::TenantBound
|
|
|| ($hasTenantQuery && str_starts_with($routeName, 'filament.admin.'));
|
|
|
|
$lastTenantId = $workspaceContext->lastTenantId(request());
|
|
$canClearTenantContext = $hasAnyFilamentTenantContext || $lastTenantId !== null;
|
|
@endphp
|
|
|
|
@php
|
|
$tenantLabel = $currentTenantName ?? 'All tenants';
|
|
$workspaceLabel = $workspace?->name ?? 'Select workspace';
|
|
$hasActiveTenant = $currentTenantName !== null;
|
|
$managedTenantsUrl = $workspace
|
|
? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace])
|
|
: route('admin.onboarding');
|
|
$workspaceUrl = $workspace
|
|
? route('admin.home')
|
|
: ChooseWorkspace::getUrl(panel: 'admin');
|
|
$tenantTriggerLabel = $workspace ? ($hasActiveTenant ? $tenantLabel : 'No tenant selected') : 'Select tenant';
|
|
@endphp
|
|
|
|
<div class="inline-flex items-center gap-0 rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5">
|
|
{{-- Workspace label: standalone link --}}
|
|
<a
|
|
href="{{ $workspaceUrl }}"
|
|
wire:navigate
|
|
class="inline-flex items-center gap-1.5 rounded-l-lg px-2.5 py-1.5 font-medium text-gray-700 transition hover:bg-gray-50 hover:text-primary-600 dark:text-gray-200 dark:hover:bg-white/10 dark:hover:text-primary-400"
|
|
>
|
|
<x-filament::icon icon="heroicon-o-squares-2x2" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
|
{{ $workspaceLabel }}
|
|
</a>
|
|
|
|
@if ($workspace)
|
|
<x-filament::icon icon="heroicon-m-chevron-right" class="h-3 w-3 shrink-0 text-gray-300 dark:text-gray-600" />
|
|
@endif
|
|
|
|
{{-- Dropdown trigger: tenant label + chevron --}}
|
|
<x-filament::dropdown placement="bottom-start" teleport width="xs">
|
|
<x-slot name="trigger">
|
|
<button
|
|
type="button"
|
|
aria-label="{{ $workspace ? 'Tenant scope' : 'Select tenant' }}"
|
|
class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hover:bg-gray-50 dark:hover:bg-white/10"
|
|
>
|
|
<span class="{{ $workspace && $hasActiveTenant ? 'font-medium text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400' }}">
|
|
{{ $tenantTriggerLabel }}
|
|
</span>
|
|
|
|
<x-filament::icon icon="heroicon-m-chevron-down" class="h-3.5 w-3.5 text-gray-400 dark:text-gray-500" />
|
|
</button>
|
|
</x-slot>
|
|
|
|
<x-filament::dropdown.list>
|
|
<div class="space-y-3 px-3 py-2" x-data="{ query: '' }">
|
|
{{-- Workspace section --}}
|
|
<div class="space-y-1">
|
|
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
Workspace
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5">
|
|
<div class="flex items-center gap-2">
|
|
<x-filament::icon icon="heroicon-o-squares-2x2" class="h-4 w-4 text-gray-500 dark:text-gray-400" />
|
|
<span class="text-sm font-medium text-gray-950 dark:text-white">{{ $workspaceLabel }}</span>
|
|
</div>
|
|
|
|
<a
|
|
href="{{ ChooseWorkspace::getUrl(panel: 'admin').'?choose=1' }}"
|
|
class="text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
|
>
|
|
Switch workspace
|
|
</a>
|
|
</div>
|
|
|
|
<a
|
|
href="{{ route('admin.home') }}"
|
|
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5"
|
|
>
|
|
<x-filament::icon icon="heroicon-o-home" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
|
Workspace Home
|
|
</a>
|
|
</div>
|
|
|
|
@if ($workspace)
|
|
<div class="border-t border-gray-200 dark:border-white/10"></div>
|
|
|
|
{{-- Tenant section --}}
|
|
<div class="space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
Selected tenant
|
|
</div>
|
|
</div>
|
|
|
|
@if ($isTenantScopedRoute)
|
|
<div class="flex items-center gap-2 rounded-lg bg-primary-50 px-3 py-2 dark:bg-primary-950/30">
|
|
<x-filament::icon icon="heroicon-o-building-office-2" class="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
|
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">{{ $currentTenantName ?? 'Tenant' }}</span>
|
|
<a
|
|
href="{{ ChooseTenant::getUrl(panel: 'admin') }}"
|
|
class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
|
>
|
|
Switch tenant
|
|
</a>
|
|
</div>
|
|
@else
|
|
@if ($tenants->isEmpty())
|
|
<div class="space-y-2 rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
|
|
<div>No active tenants are available for the standard operating context in this workspace.</div>
|
|
<a href="{{ $managedTenantsUrl }}" class="inline-flex items-center gap-1 text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
|
|
<x-filament::icon icon="heroicon-o-arrow-top-right-on-square" class="h-3.5 w-3.5" />
|
|
View managed tenants
|
|
</a>
|
|
</div>
|
|
@else
|
|
@if (! $hasActiveTenant)
|
|
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-white/5 dark:text-gray-400">
|
|
No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context.
|
|
</div>
|
|
@endif
|
|
|
|
<input
|
|
type="text"
|
|
class="fi-input fi-text-input w-full"
|
|
placeholder="Search tenants…"
|
|
x-model="query"
|
|
/>
|
|
|
|
<div class="max-h-48 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
|
@foreach ($tenants as $tenant)
|
|
@php
|
|
$isActive = $currentTenantId !== null && (int) $tenant->getKey() === $currentTenantId;
|
|
@endphp
|
|
|
|
<form method="POST" action="{{ route('admin.select-tenant') }}">
|
|
@csrf
|
|
<input type="hidden" name="tenant_id" value="{{ (int) $tenant->getKey() }}" />
|
|
|
|
<button
|
|
type="submit"
|
|
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{ $isActive ? 'bg-primary-50 font-medium text-primary-700 dark:bg-primary-950/30 dark:text-primary-300' : 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5' }}"
|
|
data-search="{{ (string) str($tenant->getFilamentName())->lower() }}"
|
|
x-show="query === '' || ($el.dataset.search ?? '').includes(query.toLowerCase())"
|
|
>
|
|
@if ($isActive)
|
|
<x-filament::icon icon="heroicon-s-check-circle" class="h-4 w-4 shrink-0 text-primary-600 dark:text-primary-400" />
|
|
@else
|
|
<x-filament::icon icon="heroicon-o-building-office-2" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
|
@endif
|
|
|
|
{{ $tenant->getFilamentName() }}
|
|
</button>
|
|
</form>
|
|
@endforeach
|
|
</div>
|
|
|
|
@if ($canClearTenantContext)
|
|
<form method="POST" action="{{ route('admin.clear-tenant-context') }}">
|
|
@csrf
|
|
|
|
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
|
|
Clear tenant scope
|
|
</button>
|
|
</form>
|
|
@endif
|
|
@endif
|
|
@endif
|
|
</div>
|
|
@else
|
|
<div class="rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
|
|
Choose a workspace first.
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</x-filament::dropdown.list>
|
|
</x-filament::dropdown>
|
|
</div>
|