feat(spec-085): tenant operate hub
This commit is contained in:
parent
0e2adeab71
commit
6dab6297f8
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -24,6 +24,9 @@ ## Active Technologies
|
||||
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface` (084-verification-surfaces-unification)
|
||||
- PostgreSQL (JSONB-backed `OperationRun.context`) (084-verification-surfaces-unification)
|
||||
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail (085-tenant-operate-hub)
|
||||
- PostgreSQL (primary) + session (workspace context + last-tenant memory) (085-tenant-operate-hub)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -43,8 +46,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 084-verification-surfaces-unification: Added PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface`
|
||||
- 082-action-surface-contract: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
|
||||
- 085-tenant-operate-hub: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4
|
||||
- 085-tenant-operate-hub: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail
|
||||
- 085-tenant-operate-hub: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
@ -4,7 +4,9 @@
|
||||
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
@ -25,4 +27,15 @@ class Alerts extends Page
|
||||
protected static ?string $title = 'Alerts';
|
||||
|
||||
protected string $view = 'filament.pages.monitoring.alerts';
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_alerts',
|
||||
returnActionName: 'operate_hub_return_alerts',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,9 @@
|
||||
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
@ -25,4 +27,15 @@ class AuditLog extends Page
|
||||
protected static ?string $title = 'Audit Log';
|
||||
|
||||
protected string $view = 'filament.pages.monitoring.audit-log';
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_audit_log',
|
||||
returnActionName: 'operate_hub_return_audit_log',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,10 +7,14 @@
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Pages\Page;
|
||||
@ -50,6 +54,46 @@ protected function getHeaderWidgets(): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
|
||||
$actions = [
|
||||
Action::make('operate_hub_scope_operations')
|
||||
->label($operateHubShell->scopeLabel(request()))
|
||||
->color('gray')
|
||||
->disabled(),
|
||||
];
|
||||
|
||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
$actions[] = Action::make('operate_hub_back_to_tenant_operations')
|
||||
->label('Back to '.$activeTenant->name)
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
||||
|
||||
$actions[] = Action::make('operate_hub_show_all_tenants')
|
||||
->label('Show all tenants')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
app(WorkspaceContext::class)->clearLastTenantId(request());
|
||||
|
||||
$this->removeTableFilter('tenant_id');
|
||||
|
||||
$this->redirect('/admin/operations');
|
||||
});
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function updatedActiveTab(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
@ -61,6 +105,8 @@ public function table(Table $table): Table
|
||||
->query(function (): Builder {
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
$query = OperationRun::query()
|
||||
->with('user')
|
||||
->latest('id')
|
||||
@ -71,6 +117,10 @@ public function table(Table $table): Table
|
||||
->when(
|
||||
! $workspaceId,
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
)
|
||||
->when(
|
||||
$activeTenant instanceof Tenant,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
|
||||
);
|
||||
|
||||
return $this->applyActiveTab($query);
|
||||
|
||||
@ -9,17 +9,20 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\EmbeddedSchema;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TenantlessOperationRunViewer extends Page
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
@ -37,16 +40,42 @@ class TenantlessOperationRunViewer extends Page
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
|
||||
$actions = [
|
||||
Action::make('refresh')
|
||||
->label('Refresh')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
Action::make('operate_hub_scope_run_detail')
|
||||
->label($operateHubShell->scopeLabel(request()))
|
||||
->color('gray')
|
||||
->url(fn (): string => isset($this->run)
|
||||
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
|
||||
: route('admin.operations.index')),
|
||||
->disabled(),
|
||||
];
|
||||
|
||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
|
||||
->label('← Back to '.$activeTenant->name)
|
||||
->color('gray')
|
||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
||||
|
||||
$actions[] = Action::make('operate_hub_show_all_operations')
|
||||
->label('Show all operations')
|
||||
->color('gray')
|
||||
->url(fn (): string => route('admin.operations.index'));
|
||||
} else {
|
||||
$actions[] = Action::make('operate_hub_back_to_operations')
|
||||
->label('Back to Operations')
|
||||
->color('gray')
|
||||
->url(fn (): string => route('admin.operations.index'));
|
||||
}
|
||||
|
||||
$actions[] = Action::make('refresh')
|
||||
->label('Refresh')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('gray')
|
||||
->url(fn (): string => isset($this->run)
|
||||
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
|
||||
: route('admin.operations.index'));
|
||||
|
||||
if (! isset($this->run)) {
|
||||
return $actions;
|
||||
}
|
||||
@ -87,7 +116,7 @@ public function mount(OperationRun $run): void
|
||||
abort(403);
|
||||
}
|
||||
|
||||
Gate::forUser($user)->authorize('view', $run);
|
||||
$this->authorize('view', $run);
|
||||
|
||||
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -317,6 +318,14 @@ public static function table(Table $table): Table
|
||||
Tables\Filters\SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->options(function (): array {
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
return [
|
||||
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
|
||||
];
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
@ -330,19 +339,19 @@ public static function table(Table $table): Table
|
||||
->all();
|
||||
})
|
||||
->default(function (): ?string {
|
||||
$tenant = Filament::getTenant();
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $activeTenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||
if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $tenant->getKey();
|
||||
return (string) $activeTenant->getKey();
|
||||
})
|
||||
->searchable(),
|
||||
Tables\Filters\SelectFilter::make('type')
|
||||
|
||||
@ -17,6 +17,14 @@ public function __invoke(Request $request): RedirectResponse
|
||||
|
||||
app(WorkspaceContext::class)->clearLastTenantId($request);
|
||||
|
||||
return redirect()->to('/admin/operations');
|
||||
$previousUrl = url()->previous();
|
||||
|
||||
$previousHost = parse_url((string) $previousUrl, PHP_URL_HOST);
|
||||
|
||||
if ($previousHost !== null && $previousHost !== $request->getHost()) {
|
||||
return redirect()->to('/admin/operations');
|
||||
}
|
||||
|
||||
return redirect()->to((string) $previousUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,6 +65,10 @@ public function handle(Request $request, Closure $next): Response
|
||||
|
||||
$canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class);
|
||||
|
||||
if (! $hasAnyActiveMembership && $this->isOperateHubPath($path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/tenants')) {
|
||||
abort(404);
|
||||
}
|
||||
@ -101,4 +105,13 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
||||
|
||||
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
|
||||
}
|
||||
|
||||
private function isOperateHubPath(string $path): bool
|
||||
{
|
||||
return in_array($path, [
|
||||
'/admin/operations',
|
||||
'/admin/alerts',
|
||||
'/admin/audit-log',
|
||||
], true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -124,6 +124,7 @@ public function panel(Panel $panel): Panel
|
||||
SubstituteBindings::class,
|
||||
'ensure-correct-guard:web',
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
@ -40,6 +41,23 @@ public function panel(Panel $panel): Panel
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
->navigationItems([
|
||||
NavigationItem::make('Runs')
|
||||
->url(fn (): string => route('admin.operations.index'))
|
||||
->icon('heroicon-o-queue-list')
|
||||
->group('Monitoring')
|
||||
->sort(10),
|
||||
NavigationItem::make('Alerts')
|
||||
->url(fn (): string => route('admin.monitoring.alerts'))
|
||||
->icon('heroicon-o-bell-alert')
|
||||
->group('Monitoring')
|
||||
->sort(20),
|
||||
NavigationItem::make('Audit Log')
|
||||
->url(fn (): string => route('admin.monitoring.audit-log'))
|
||||
->icon('heroicon-o-clipboard-document-list')
|
||||
->group('Monitoring')
|
||||
->sort(30),
|
||||
])
|
||||
->renderHook(
|
||||
PanelsRenderHook::HEAD_END,
|
||||
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
||||
|
||||
@ -34,6 +34,12 @@ public function handle(Request $request, Closure $next): Response
|
||||
$existingTenant = Filament::getTenant();
|
||||
if ($existingTenant instanceof Tenant && $workspaceId !== null && (int) $existingTenant->workspace_id !== (int) $workspaceId) {
|
||||
Filament::setTenant(null, true);
|
||||
$existingTenant = null;
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
if ($existingTenant instanceof Tenant && $user instanceof User && ! $user->canAccessTenant($existingTenant)) {
|
||||
Filament::setTenant(null, true);
|
||||
}
|
||||
|
||||
if ($path === '/livewire/update') {
|
||||
@ -129,8 +135,6 @@ public function handle(Request $request, Closure $next): Response
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
|
||||
131
app/Support/OperateHub/OperateHubShell.php
Normal file
131
app/Support/OperateHub/OperateHubShell.php
Normal file
@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\OperateHub;
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class OperateHubShell
|
||||
{
|
||||
public function __construct(
|
||||
private WorkspaceContext $workspaceContext,
|
||||
private CapabilityResolver $capabilityResolver,
|
||||
) {}
|
||||
|
||||
public function scopeLabel(?Request $request = null): string
|
||||
{
|
||||
$activeTenant = $this->activeEntitledTenant($request);
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
return 'Scope: Tenant — '.$activeTenant->name;
|
||||
}
|
||||
|
||||
return 'Scope: Workspace — all tenants';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, url: string}|null
|
||||
*/
|
||||
public function returnAffordance(?Request $request = null): ?array
|
||||
{
|
||||
$activeTenant = $this->activeEntitledTenant($request);
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
return [
|
||||
'label' => 'Back to '.$activeTenant->name,
|
||||
'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant),
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
public function headerActions(
|
||||
string $scopeActionName = 'operate_hub_scope',
|
||||
string $returnActionName = 'operate_hub_return',
|
||||
?Request $request = null,
|
||||
): array {
|
||||
$actions = [
|
||||
Action::make($scopeActionName)
|
||||
->label($this->scopeLabel($request))
|
||||
->color('gray')
|
||||
->disabled(),
|
||||
];
|
||||
|
||||
$returnAffordance = $this->returnAffordance($request);
|
||||
|
||||
if (is_array($returnAffordance)) {
|
||||
$actions[] = Action::make($returnActionName)
|
||||
->label($returnAffordance['label'])
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url($returnAffordance['url']);
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function activeEntitledTenant(?Request $request = null): ?Tenant
|
||||
{
|
||||
return $this->resolveActiveTenant($request);
|
||||
}
|
||||
|
||||
private function resolveActiveTenant(?Request $request = null): ?Tenant
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if ($tenant instanceof Tenant && $this->isEntitled($tenant, $request)) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
$rememberedTenantId = $this->workspaceContext->lastTenantId($request);
|
||||
|
||||
if ($rememberedTenantId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rememberedTenant = Tenant::query()->whereKey($rememberedTenantId)->first();
|
||||
|
||||
if (! $rememberedTenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->isEntitled($rememberedTenant, $request)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $rememberedTenant;
|
||||
}
|
||||
|
||||
private function isEntitled(Tenant $tenant, ?Request $request = null): bool
|
||||
{
|
||||
if (! $tenant->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = $this->workspaceContext->currentWorkspaceId($request);
|
||||
|
||||
if ($workspaceId !== null && (int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->capabilityResolver->isMember($user, $tenant);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Alerts is reserved for future work.
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Alerts is reserved for future work.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Audit Log is reserved for future work.
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Audit Log is reserved for future work.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
@ -40,9 +41,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
$currentTenant = Filament::getTenant();
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
$currentTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
|
||||
|
||||
$hasAnyFilamentTenantContext = Filament::getTenant() instanceof Tenant;
|
||||
|
||||
$path = '/'.ltrim(request()->path(), '/');
|
||||
$route = request()->route();
|
||||
$routeName = (string) ($route?->getName() ?? '');
|
||||
@ -65,7 +69,7 @@
|
||||
|| ($hasTenantQuery && str_starts_with($routeName, 'filament.admin.'));
|
||||
|
||||
$lastTenantId = $workspaceContext->lastTenantId(request());
|
||||
$canClearTenantContext = $currentTenantName !== null || $lastTenantId !== null;
|
||||
$canClearTenantContext = $hasAnyFilamentTenantContext || $lastTenantId !== null;
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
@ -174,7 +178,7 @@ class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-80
|
||||
<form method="POST" action="{{ route('admin.clear-tenant-context') }}">
|
||||
@csrf
|
||||
|
||||
<x-filament::button color="gray" size="sm" outlined>
|
||||
<x-filament::button type="submit" color="gray" size="sm" outlined>
|
||||
Clear tenant context
|
||||
</x-filament::button>
|
||||
</form>
|
||||
|
||||
@ -143,6 +143,7 @@
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class)
|
||||
->name('admin.operations.index');
|
||||
@ -155,6 +156,7 @@
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/alerts', \App\Filament\Pages\Monitoring\Alerts::class)
|
||||
->name('admin.monitoring.alerts');
|
||||
@ -167,6 +169,7 @@
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/audit-log', \App\Filament\Pages\Monitoring\AuditLog::class)
|
||||
->name('admin.monitoring.audit-log');
|
||||
@ -179,6 +182,7 @@
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class)
|
||||
->name('admin.operations.view');
|
||||
|
||||
34
specs/085-tenant-operate-hub/checklists/requirements.md
Normal file
34
specs/085-tenant-operate-hub/checklists/requirements.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Tenant Operate Hub / Tenant Overview IA
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-09
|
||||
**Feature**: [specs/085-tenant-operate-hub/spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Dependencies/assumptions used: canonical monitoring surfaces exist at `/admin/operations`, `/admin/operations/{run}`, `/admin/alerts`, `/admin/audit-log`; tenant plane exists at `/admin/t/{tenant}`; tenant context can be active or absent; authorization semantics remain consistent (deny-as-not-found vs forbidden); Monitoring views are view-only render surfaces that must not initiate outbound calls on render.
|
||||
76
specs/085-tenant-operate-hub/contracts/openapi.yaml
Normal file
76
specs/085-tenant-operate-hub/contracts/openapi.yaml
Normal file
@ -0,0 +1,76 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Tenant Operate Hub / Central Monitoring (UI Route Contracts)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Internal documentation of canonical central Monitoring surfaces.
|
||||
|
||||
These are Filament page routes (not a public API). The contract is used to
|
||||
pin down URL shapes and security semantics (404 vs 403) for acceptance.
|
||||
|
||||
servers:
|
||||
- url: /
|
||||
|
||||
paths:
|
||||
/admin/operations:
|
||||
get:
|
||||
summary: Central Monitoring - Operations index
|
||||
description: |
|
||||
Canonical operations list. Must render without outbound calls.
|
||||
|
||||
Scope semantics:
|
||||
- If tenant context is active AND entitled: page is tenant-filtered by default and shows tenant-scoped header/CTAs.
|
||||
- If tenant context is absent: page is workspace-wide.
|
||||
- If tenant context is active but not entitled: page behaves workspace-wide and must not reveal tenant identity.
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
'302':
|
||||
description: Redirect to choose workspace if none selected
|
||||
'403':
|
||||
description: Authenticated but forbidden (capability denial after membership)
|
||||
'404':
|
||||
description: Deny-as-not-found when not entitled to workspace scope
|
||||
/admin/clear-tenant-context:
|
||||
post:
|
||||
summary: Exit tenant context (Monitoring)
|
||||
description: |
|
||||
Clears the active tenant context for the current session.
|
||||
Used by “Show all tenants” on central Monitoring pages.
|
||||
responses:
|
||||
'302':
|
||||
description: Redirect back to a canonical Monitoring page
|
||||
'404':
|
||||
description: Deny-as-not-found when not entitled to workspace scope
|
||||
/admin/operations/{run}:
|
||||
get:
|
||||
summary: Central Monitoring - Run detail
|
||||
parameters:
|
||||
- in: path
|
||||
name: run
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
'403':
|
||||
description: Authenticated but forbidden (policy denies view)
|
||||
'404':
|
||||
description: Deny-as-not-found when run is outside entitled scope
|
||||
/admin/alerts:
|
||||
get:
|
||||
summary: Central Monitoring - Alerts
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
'404':
|
||||
description: Deny-as-not-found when not entitled to workspace scope
|
||||
/admin/audit-log:
|
||||
get:
|
||||
summary: Central Monitoring - Audit log
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
'404':
|
||||
description: Deny-as-not-found when not entitled to workspace scope
|
||||
63
specs/085-tenant-operate-hub/data-model.md
Normal file
63
specs/085-tenant-operate-hub/data-model.md
Normal file
@ -0,0 +1,63 @@
|
||||
# Data Model: Tenant Operate Hub / Tenant Overview IA
|
||||
|
||||
**Date**: 2026-02-09
|
||||
**Branch**: 085-tenant-operate-hub
|
||||
|
||||
This feature is primarily UI/IA + navigation behavior. It introduces **no new database tables**.
|
||||
|
||||
## Entities (existing)
|
||||
|
||||
### Workspace
|
||||
- Purpose: primary isolation boundary and monitoring scope.
|
||||
- Source of truth: `workspaces` + membership.
|
||||
|
||||
### Tenant
|
||||
- Purpose: managed environment; tenant-plane routes live under `/admin/t/{tenant}`.
|
||||
- Access: entitlement-based.
|
||||
|
||||
### OperationRun
|
||||
- Purpose: canonical run tracking for all operational workflows.
|
||||
- Surface:
|
||||
- Index: `/admin/operations`
|
||||
- Detail: `/admin/operations/{run}`
|
||||
|
||||
### Alert (placeholder)
|
||||
- Purpose: future operator signals.
|
||||
- Surface: `/admin/alerts`.
|
||||
|
||||
### Audit Event / Audit Log (placeholder)
|
||||
- Purpose: immutable record of sensitive actions.
|
||||
- Surface: `/admin/audit-log`.
|
||||
|
||||
## Session / Context State (existing)
|
||||
|
||||
### Workspace context
|
||||
- Key: `WorkspaceContext::SESSION_KEY` (`current_workspace_id`)
|
||||
- Meaning: selected workspace id for the current session.
|
||||
|
||||
### Last tenant per workspace (session-based)
|
||||
- Key: `WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY` (`workspace_last_tenant_ids`)
|
||||
- Shape:
|
||||
- Map keyed by workspace id string → tenant id int
|
||||
- Example:
|
||||
- `{"12": 345}`
|
||||
- APIs:
|
||||
- `WorkspaceContext::rememberLastTenantId(int $workspaceId, int $tenantId, Request $request)`
|
||||
- `WorkspaceContext::lastTenantId(Request $request): ?int`
|
||||
- `WorkspaceContext::clearLastTenantId(Request $request)`
|
||||
|
||||
### Filament tenant context
|
||||
- Source: `Filament::getTenant()` (may persist across panels depending on Filament tenancy configuration).
|
||||
- Used to determine “active tenant context” for Monitoring UX.
|
||||
|
||||
**Spec 085 scope note**: Monitoring may use session-based last-tenant memory as a tenant-context signal when Filament tenant context is absent (e.g., when navigating from the tenant panel into central Monitoring). It must not be inferred from arbitrary deep links.
|
||||
|
||||
### Stale tenant context behavior (no entitlement)
|
||||
|
||||
- If tenant context is active but the user is not entitled, Monitoring pages behave as workspace-wide views and must not display tenant identity.
|
||||
|
||||
## Validation / Rules
|
||||
|
||||
- Tenant context MUST NOT be implicitly mutated by canonical monitoring pages.
|
||||
- Deny-as-not-found (404) applies when the actor is not entitled to tenant/workspace scope.
|
||||
- Forbidden (403) applies only after membership is established but capability is missing.
|
||||
123
specs/085-tenant-operate-hub/plan.md
Normal file
123
specs/085-tenant-operate-hub/plan.md
Normal file
@ -0,0 +1,123 @@
|
||||
# Implementation Plan: Spec 085 — Tenant Operate Hub / Tenant Overview IA
|
||||
|
||||
**Branch**: `085-tenant-operate-hub` | **Date**: 2026-02-09 | **Spec**: specs/085-tenant-operate-hub/spec.md
|
||||
**Input**: specs/085-tenant-operate-hub/spec.md
|
||||
|
||||
## Summary
|
||||
|
||||
Make central Monitoring pages feel context-aware when entered from the tenant panel, without introducing tenant-scoped monitoring routes and without implicit tenant switching.
|
||||
|
||||
Key outcomes:
|
||||
- Tenant panel sidebar replaces “Operations” with a “Monitoring” group of shortcuts (Runs/Alerts/Audit Log) that open central Monitoring surfaces.
|
||||
- `/admin/operations` becomes context-aware when tenant context is active: scope label shows tenant, table defaults to tenant filter, and header includes `Back to <tenant>` + `Show all tenants` (clears tenant context).
|
||||
- `/admin/operations/{run}` adds deterministic “back” affordances: tenant back link when tenant context is active + entitled, plus secondary `Show all operations`; otherwise `Back to Operations`.
|
||||
- Monitoring page render remains DB-only: no outbound calls and no background work triggered by view-only GET.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4
|
||||
**Storage**: PostgreSQL (Sail)
|
||||
**Testing**: Pest v4 (`vendor/bin/sail artisan test`)
|
||||
**Target Platform**: Web (enterprise SaaS admin UI)
|
||||
**Project Type**: Laravel monolith (Filament panels + Livewire)
|
||||
**Performance Goals**: Monitoring page renders are DB-only, low-latency, and avoid N+1 regressions
|
||||
**Constraints**:
|
||||
- Canonical monitoring URLs must not change (`/admin/operations`, `/admin/operations/{run}`)
|
||||
- No new tenant-scoped monitoring routes
|
||||
- No implicit tenant switching (tenant selection remains explicit POST)
|
||||
- Deny-as-not-found (404) for non-members/non-entitled; 403 only after membership established
|
||||
- No outbound calls on render; no render-time side effects (jobs/notifications)
|
||||
**Scale/Scope**: Small-to-medium UX change touching tenant navigation + 2 monitoring pages + Pest tests
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first / snapshots: Not applicable (read-only monitoring UX).
|
||||
- Read/write separation: PASS (changes are navigation + view-only rendering; the only mutation is explicit “clear tenant context” action).
|
||||
- Graph contract path: PASS (no new Graph calls).
|
||||
- Deterministic capabilities: PASS (uses existing membership/entitlement checks; no new capability strings).
|
||||
- Workspace isolation: PASS (non-member workspace access remains 404).
|
||||
- Tenant isolation: PASS (no tenant identity leaks when not entitled; tenant pages remain 404).
|
||||
- Run observability: PASS (view-only pages do not start operations; Monitoring stays DB-only).
|
||||
- RBAC-UX destructive confirmation: PASS (no destructive actions added).
|
||||
- Filament UI Action Surface Contract: PASS (we’re modifying Pages; we will provide explicit header actions and table/default filter behavior; no new list resources are added).
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/085-tenant-operate-hub/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── openapi.yaml
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ ├── Resources/
|
||||
│ └── ...
|
||||
├── Http/
|
||||
│ ├── Controllers/
|
||||
│ └── Middleware/
|
||||
├── Providers/
|
||||
└── Support/
|
||||
|
||||
resources/views/
|
||||
tests/Feature/
|
||||
routes/web.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel monolith with Filament panels. Changes will be localized to existing panel providers, page classes, shared helpers (if present), and feature tests.
|
||||
|
||||
## Phase Plan
|
||||
|
||||
### Phase 0 — Research (complete)
|
||||
|
||||
Outputs:
|
||||
- specs/085-tenant-operate-hub/research.md (decisions + alternatives)
|
||||
|
||||
### Phase 1 — Design & Contracts (complete)
|
||||
|
||||
Outputs:
|
||||
- specs/085-tenant-operate-hub/data-model.md (no schema changes; context rules)
|
||||
- specs/085-tenant-operate-hub/contracts/openapi.yaml (canonical routes + clear-tenant-context POST)
|
||||
- specs/085-tenant-operate-hub/quickstart.md (manual verification)
|
||||
|
||||
### Phase 2 — Implementation Planning (next)
|
||||
|
||||
Implementation will be executed as small, test-driven slices:
|
||||
|
||||
1) Tenant panel navigation IA
|
||||
- Replace tenant-panel “Operations” entry with “Monitoring” group.
|
||||
- Add 3 shortcut items (Runs/Alerts/Audit Log).
|
||||
- Verify no new tenant-scoped monitoring routes are introduced.
|
||||
|
||||
2) Operations index context-aware header + default scope
|
||||
- If tenant context active + entitled: show scope `Tenant — <name>`, default table filter = tenant, CTAs `Back to <tenant>` and `Show all tenants`.
|
||||
- If no tenant context: show scope `Workspace — all tenants`.
|
||||
- If tenant context active but not entitled: behave workspace-wide (no tenant name, no back-to-tenant).
|
||||
|
||||
3) Run detail deterministic back affordances
|
||||
- If tenant context active + entitled: `← Back to <tenant>` plus secondary `Show all operations`.
|
||||
- Else: `Back to Operations`.
|
||||
|
||||
4) Pest tests (security + UX)
|
||||
- OperationsIndexScopeTest (tenant vs workspace scope labels + CTAs)
|
||||
- RunDetailBackToTenantTest (tenant-context vs no-context actions)
|
||||
- Deny-as-not-found coverage for non-entitled tenant pages
|
||||
- “No outbound calls on render” guard for `/admin/operations` and `/admin/operations/{run}`
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations expected.
|
||||
39
specs/085-tenant-operate-hub/quickstart.md
Normal file
39
specs/085-tenant-operate-hub/quickstart.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Quickstart: Tenant Operate Hub / Tenant Overview IA
|
||||
|
||||
**Date**: 2026-02-09
|
||||
**Branch**: 085-tenant-operate-hub
|
||||
|
||||
## Local setup
|
||||
|
||||
- Start containers: `vendor/bin/sail up -d`
|
||||
- Install deps (if needed): `vendor/bin/sail composer install`
|
||||
|
||||
## Manual verification steps (happy path)
|
||||
|
||||
1. Sign in.
|
||||
2. Select a workspace (via the context bar).
|
||||
3. Enter a tenant context (e.g., go to `/admin/t/{tenant}` via the tenant panel).
|
||||
4. In the tenant panel sidebar, open the **Monitoring** group and click:
|
||||
- Runs → lands on `/admin/operations`
|
||||
5. Verify `/admin/operations` shows:
|
||||
- Header scope: `Scope: Tenant — <tenant name>`
|
||||
- CTAs: `Back to <tenant name>` and `Show all tenants`
|
||||
- The table default scope is tenant-filtered to the active tenant.
|
||||
6. Click `Show all tenants`.
|
||||
7. Verify you stay on `/admin/operations` and scope becomes `Scope: Workspace — all tenants`.
|
||||
8. Open an operation run detail at `/admin/operations/{run}`.
|
||||
9. Verify the header shows:
|
||||
- `← Back to <tenant name>`
|
||||
- secondary `Show all operations` → `/admin/operations`
|
||||
10. Click `← Back to <tenant name>` and verify it lands on the tenant dashboard (`/admin/t/{tenant}`).
|
||||
|
||||
## Negative verification (security)
|
||||
|
||||
- With tenant context active, revoke tenant entitlement for the test user.
|
||||
- Reload `/admin/operations`.
|
||||
- Verify scope is workspace-wide and no tenant name / “Back to tenant” affordance appears.
|
||||
- Request the tenant dashboard URL directly (`/admin/t/{tenant}`) and verify deny-as-not-found.
|
||||
|
||||
## Test commands (to be added in Phase 2)
|
||||
|
||||
- Targeted suite: `vendor/bin/sail artisan test --compact --filter=OperationsIndexScopeTest`
|
||||
70
specs/085-tenant-operate-hub/research.md
Normal file
70
specs/085-tenant-operate-hub/research.md
Normal file
@ -0,0 +1,70 @@
|
||||
# Research: Tenant Operate Hub / Tenant Overview IA (Spec 085)
|
||||
|
||||
**Date**: 2026-02-09
|
||||
**Branch**: 085-tenant-operate-hub
|
||||
**Spec**: specs/085-tenant-operate-hub/spec.md
|
||||
|
||||
This research consolidates repo evidence + the final clarification decisions, so the implementation plan and tests can be deterministic.
|
||||
|
||||
## Repository Evidence (high-signal)
|
||||
|
||||
- Canonical monitoring pages already exist in the Admin panel:
|
||||
- Operations index: app/Filament/Pages/Monitoring/Operations.php
|
||||
- Run detail (tenantless viewer): app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||
- Alerts: app/Filament/Pages/Monitoring/Alerts.php
|
||||
- Audit log: app/Filament/Pages/Monitoring/AuditLog.php
|
||||
- Tenant selection + clear tenant context already exist (UI + route/controller):
|
||||
- Context bar partial: resources/views/filament/partials/context-bar.blade.php
|
||||
- Tenant select controller: app/Http/Controllers/SelectTenantController.php
|
||||
|
||||
## Decisions (resolved)
|
||||
|
||||
### Decision: Tenant context may use last-tenant memory for cross-panel flows
|
||||
- Decision: “Tenant context is active” on Monitoring pages is resolved from the active Filament tenant when present, otherwise from the remembered last-tenant id for the current workspace.
|
||||
- Rationale: Central Monitoring routes are canonical and tenantless, but users enter them from the tenant panel and expect consistent scoping.
|
||||
- Alternatives considered:
|
||||
- Query param `?tenant=`: rejected (would introduce an implicit switching vector).
|
||||
|
||||
### Decision: “Show all tenants” explicitly exits tenant context
|
||||
- Decision: The CTA “Show all tenants” clears tenant context (single meaning) and returns the user to workspace-wide Monitoring.
|
||||
- Rationale: Prevents the confusing state where tenant context is still active but filters are reset.
|
||||
- Alternatives considered:
|
||||
- Only reset table filter: rejected (context remains active and confuses scope semantics).
|
||||
|
||||
### Decision: Stale tenant context is handled without leaks
|
||||
- Decision: If tenant context is active but the user is no longer entitled to that tenant, Monitoring pages behave as workspace-wide:
|
||||
- Scope shows `Workspace — all tenants`
|
||||
- No tenant name is shown
|
||||
- No “Back to tenant” is rendered
|
||||
- Tenant pages remain deny-as-not-found
|
||||
- Rationale: Preserves deny-as-not-found and avoids tenant existence hints.
|
||||
- Alternatives considered:
|
||||
- 404 the Monitoring page: rejected (feels like being “thrown out”).
|
||||
- Auto-clear tenant context implicitly: rejected (implicit context mutation).
|
||||
|
||||
### Decision: Run detail shows an explicit tenant return + secondary escape hatch
|
||||
- Decision: When tenant context is active and entitled, run detail shows:
|
||||
- `← Back to <tenant name>` (tenant dashboard)
|
||||
- secondary `Show all operations` → `/admin/operations`
|
||||
- Rationale: “Back to tenant” is deterministic; the secondary link provides a canonical escape hatch.
|
||||
- Alternatives considered:
|
||||
- Only show `Back to tenant`: rejected by clarification (secondary escape hatch approved).
|
||||
|
||||
### Decision: Tenant panel navigation uses a “Monitoring” group with central shortcuts
|
||||
- Decision: Replace the tenant-panel “Operations” item with group “Monitoring” containing shortcuts:
|
||||
- Runs → `/admin/operations`
|
||||
- Alerts → `/admin/alerts`
|
||||
- Audit Log → `/admin/audit-log`
|
||||
- Rationale: Keep tenant sidebar labels minimal while still providing correct central Monitoring entry points, while preserving canonical URLs and avoiding tenant-scoped monitoring routes.
|
||||
- Alternatives considered:
|
||||
- New tenant-scoped monitoring routes: rejected (explicitly forbidden).
|
||||
|
||||
### Decision: “No outbound calls on render” is enforced by tests
|
||||
- Decision: Monitoring GET renders must not trigger outbound network calls or start background work as a side effect.
|
||||
- Rationale: Aligns with constitution (“Monitoring pages MUST be DB-only at render time”).
|
||||
- Alternatives considered:
|
||||
- Rely on convention: rejected; this needs regression protection.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None remaining for Phase 0. The spec clarifications cover all scope-affecting ambiguities.
|
||||
194
specs/085-tenant-operate-hub/spec.md
Normal file
194
specs/085-tenant-operate-hub/spec.md
Normal file
@ -0,0 +1,194 @@
|
||||
# Feature Specification: Tenant Operate Hub / Tenant Overview IA
|
||||
|
||||
**Feature Branch**: `085-tenant-operate-hub`
|
||||
**Created**: 2026-02-09
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Make central Monitoring surfaces feel context-aware when entered from a tenant, without changing canonical URLs, and without weakening deny-as-not-found security boundaries."
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-09 (work order alignment)
|
||||
|
||||
- Q: What is the source of truth for “Back to tenant”? → A: The active entitled tenant context (Filament tenant if present, otherwise the remembered last-tenant id for the current workspace).
|
||||
- Q: Should “Back to last tenant” be implemented as a separate feature? → A: No; remembered tenant context is used only to preserve context when navigating from a tenant into central Monitoring.
|
||||
- Q: What does “Show all tenants” do? → A: It explicitly exits tenant context to return to workspace-wide monitoring (no mixed behavior with filter resets).
|
||||
- Q: How is Monitoring reached from tenant context? → A: Tenant navigation offers a “Monitoring” group with shortcuts that open central Monitoring surfaces.
|
||||
- Q: How should stale tenant context (tenant context active but user no longer entitled) behave on Monitoring pages? → A: Monitoring renders workspace-wide (no tenant name, no “Back to tenant”), preserving deny-as-not-found for tenant pages.
|
||||
- Q: Should run detail offer a secondary escape hatch when tenant context is active? → A: Yes — show a secondary “Show all operations” link to `/admin/operations`.
|
||||
- Q: How should tenant Monitoring shortcuts indicate “opens central monitoring”? → A: Keep labels minimal (no “↗ Central” suffix).
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Monitoring feels context-aware (Priority: P1)
|
||||
|
||||
As an operator, when I open central Monitoring from within a tenant, I immediately understand:
|
||||
1) whether the Monitoring view is scoped to the current tenant or to all tenants, and
|
||||
2) how to get back to the tenant I came from.
|
||||
|
||||
**Why this priority**: This is the core usability and safety problem: monitoring and tenant work should not feel like different apps, but they must not blur security boundaries.
|
||||
|
||||
**Independent Test**: With a tenant context active, a test user can open the Operations index and a run detail, see an explicit scope indicator and a deterministic “Back to tenant”, and exit to workspace-wide monitoring intentionally.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user is a member of a workspace and has access to at least one tenant, **When** they open central Monitoring (Operations), **Then** the page clearly shows whether tenant context is active.
|
||||
2. **Given** a tenant context is active, **When** the user navigates to a canonical monitoring detail page, **Then** the UI provides a single, clear “Back to tenant” affordance that returns to that tenant dashboard.
|
||||
3. **Given** a user is not entitled to the current tenant, **When** they try to access tenant-scoped pages via a direct link, **Then** they receive a not-found experience (deny-as-not-found), without any tenant existence hints.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Canonical URLs with explicit scope (Priority: P2)
|
||||
|
||||
As an operator, I can use canonical Monitoring URLs at all times. When tenant context is active, Monitoring views can be tenant-filtered by default, but they must not implicitly change tenant selection.
|
||||
|
||||
**Why this priority**: Avoids mistakes and misinterpretation of data by preventing silent scoping changes.
|
||||
|
||||
**Independent Test**: With a tenant selected, open monitoring index and detail views and verify the scope is consistent and clearly communicated.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant context is active, **When** the user opens the monitoring index, **Then** the default view is tenant-scoped (or clearly offers a one-click tenant scope), and the UI visibly indicates the scope.
|
||||
2. **Given** no tenant context is active, **When** the user opens monitoring, **Then** the view is workspace-wide and does not imply a tenant is selected.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Deep links are safe and recoverable (Priority: P3)
|
||||
|
||||
As an operator working inside a tenant, when I land on a canonical run detail via a deep link, I can safely return to the tenant if tenant context is still active and I am still entitled.
|
||||
|
||||
**Why this priority**: These workflows are frequent. Deep links are where users most often “lose” tenant context.
|
||||
|
||||
**Independent Test**: With a tenant context active, open a canonical run detail and verify the “Back to tenant” affordance is present and correct.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant context is active and the user is still entitled, **When** they open a canonical run detail, **Then** they see a “Back to tenant” affordance.
|
||||
2. **Given** tenant context is not active, **When** the user opens a canonical run detail, **Then** they see only a “Back to Operations” affordance.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- User has no workspace selected: Monitoring must not show cross-workspace data; user must select a workspace first.
|
||||
- User has workspace access but zero tenant access: Monitoring must still work in workspace-wide mode, without tenant selection.
|
||||
- User’s tenant access is revoked while they have a deep link open: subsequent tenant-scoped navigation must be deny-as-not-found.
|
||||
- User opens a bookmarked canonical run detail directly: the UI must provide a deterministic “Back” behavior without inventing tenant context.
|
||||
- Tenant context is active, but entitlement was revoked: Monitoring must not leak tenant identity; tenant return affordance must not appear (or must be safe).
|
||||
- Monitoring views must remain view-only render surfaces: rendering must not trigger outbound calls.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Target State (hard decision)
|
||||
|
||||
This spec adopts a single, deterministic interpretation:
|
||||
|
||||
- Monitoring URLs are canonical and do not change with tenant context.
|
||||
- Tenant context makes Monitoring *feel* scoped (scope indicators, default filters, and deterministic exits) without implicit tenant switching.
|
||||
|
||||
- Operations index: `/admin/operations`
|
||||
- Operations run detail: `/admin/operations/{run}`
|
||||
- Alerts: `/admin/alerts`
|
||||
- Audit log: `/admin/audit-log`
|
||||
|
||||
Tenant plane remains under `/admin/t/{tenant}` for tenant dashboards and workflows. Monitoring views are central, but when tenant context is active they become tenant-filtered by default and provide a deterministic “Back to tenant” affordance.
|
||||
|
||||
**Constitution alignment (required):** This feature is information architecture + navigation behavior. It MUST NOT introduce new outbound calls for monitoring pages. If it introduces or changes any write/change behavior (e.g., starting workflows), it MUST maintain existing safety gates (preview/confirmation/audit), tenant isolation, run observability, and tests.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature changes how users reach surfaces; it MUST preserve and test authorization semantics:
|
||||
- Non-member / not entitled to workspace scope OR tenant scope → deny-as-not-found (404 semantics)
|
||||
- Member but missing capability → forbidden (403 semantics)
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Authentication handshakes may perform synchronous outbound communication on auth endpoints. This MUST NOT be used for Monitoring pages.
|
||||
|
||||
**Constitution alignment (BADGE-001):** If any status/severity/outcome badges are added or changed on hub pages, their meaning MUST be centralized and covered by tests.
|
||||
|
||||
**Constitution alignment (UI Action Surfaces):** If this feature adds/modifies any admin or tenant UI surfaces, the “UI Action Matrix” MUST be updated and action gating MUST remain consistent (confirmation for destructive-like actions; server-side authorization for mutations).
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-085-001**: Tenant navigation MUST offer a “Monitoring” group with shortcuts to central Monitoring surfaces:
|
||||
- Runs (Operations) → `/admin/operations`
|
||||
- Alerts → `/admin/alerts`
|
||||
- Audit Log → `/admin/audit-log`
|
||||
These shortcuts MUST NOT introduce new tenant-scoped monitoring URLs.
|
||||
|
||||
- **FR-085-002**: The Operations index (`/admin/operations`) MUST show a clear scope indicator in the page header:
|
||||
- `Scope: Workspace — all tenants` when no tenant context is active
|
||||
- `Scope: Tenant — <tenant name>` when tenant context is active
|
||||
- **FR-085-003**: Canonical Monitoring URLs MUST NOT implicitly change tenant context. Tenant context MAY influence default filters on Monitoring views.
|
||||
- **FR-085-004**: When tenant context is active on the Operations index, the default tenant filter MUST be set to the current tenant, and the UI MUST make this tenant scoping obvious.
|
||||
- **FR-085-005**: The Operations index MUST provide two explicit CTAs when tenant context is active:
|
||||
- `Show all tenants` (explicitly exits tenant context and returns to workspace-wide monitoring)
|
||||
- `Back to <tenant name>` (navigates to tenant dashboard)
|
||||
- **FR-085-006**: The run detail (`/admin/operations/{run}`) MUST provide a deterministic “Back” affordance:
|
||||
- If tenant context is active AND the user is still entitled: `← Back to <tenant name>` (tenant dashboard) AND a secondary `Show all operations` (to `/admin/operations`)
|
||||
- Else: `Back to Operations` (Operations index)
|
||||
- **FR-085-007**: “Back to tenant” MUST be based only on active entitled tenant context (Filament tenant, or remembered tenant for the current workspace). It MUST NOT be inferred from arbitrary deep-link parameters.
|
||||
- **FR-085-008**: Deny-as-not-found MUST remain: users not entitled to workspace or tenant scope MUST receive a not-found experience (404 semantics), with no tenant existence hints.
|
||||
- **FR-085-009**: Monitoring views (`/admin/operations` and `/admin/operations/{run}`) MUST remain view-only render surfaces and MUST NOT trigger outbound calls during render.
|
||||
- **FR-085-010**: If tenant context is active but the user is not entitled to that tenant, Monitoring pages MUST behave as workspace-wide views:
|
||||
- Scope indicator MUST show `Scope: Workspace — all tenants`
|
||||
- No tenant name MUST be displayed
|
||||
- No “Back to <tenant>” affordance MUST be rendered
|
||||
- Direct access to tenant pages MUST continue to be deny-as-not-found
|
||||
|
||||
## UI Action Matrix *(mandatory when UI surfaces are changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Central Operations (index) | `/admin/operations` | Scope indicator; `Show all tenants` (when tenant context active); deterministic back affordance | Linked run rows to open run detail | N/A | N/A | N/A | N/A | N/A | No | Must not implicitly change tenant context; default tenant filter when tenant context active |
|
||||
| Central Operations (run detail) | `/admin/operations/{run}` | `← Back to <tenant>` (when tenant context active + entitled) OR `Back to Operations`; secondary `Show all operations` allowed when tenant context active + entitled | N/A | N/A | N/A | N/A | N/A | N/A | No | Must not reveal tenant identity when user is not entitled |
|
||||
| Tenant navigation shortcuts | Tenant sidebar | N/A | N/A | N/A | N/A | N/A | N/A | N/A | No | “Monitoring” group with central shortcuts |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Workspace**: A security and organizational boundary for operations and monitoring.
|
||||
- **Tenant**: A managed environment within a workspace; access is entitlement-based.
|
||||
- **Monitoring (Operations)**: Central monitoring views that can be workspace-wide or tenant-scoped when tenant context is active.
|
||||
- **Operation Run**: A tracked execution of an operational workflow; viewable via canonical run detail.
|
||||
- **Alert**: An operator-facing signal about an issue or state requiring attention.
|
||||
- **Audit Event**: An immutable record of important user-triggered actions and sensitive operations.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-085-001**: In a usability walkthrough, 90% of operators can correctly identify whether Operations is scoped to a tenant or to all tenants within 10 seconds of opening `/admin/operations`.
|
||||
- **SC-085-002**: With tenant context active, operators can return to the tenant dashboard from `/admin/operations` and `/admin/operations/{run}` in ≤ 1 click.
|
||||
- **SC-085-003**: Support tickets tagged “lost tenant context / where am I?” decrease by 30% within 30 days after rollout.
|
||||
- **SC-085-004**: Authorization regression checks show zero cases where a non-entitled user can infer existence of a tenant or view tenant-scoped monitoring data.
|
||||
|
||||
### Engineering Acceptance Outcomes
|
||||
|
||||
- **SC-085-005**: When tenant context is active, `/admin/operations` and `/admin/operations/{run}` clearly show tenant scope and a “Back to <tenant>” affordance.
|
||||
- **SC-085-006**: When tenant context is not active, `/admin/operations/{run}` shows “Back to Operations” and no “Back to tenant”.
|
||||
- **SC-085-007**: Viewing Monitoring pages does not initiate outbound network requests or start background work as a side effect of rendering.
|
||||
|
||||
## Test Plan *(mandatory)*
|
||||
|
||||
1. **Operations index scope label + CTAs (tenant context)**
|
||||
- With tenant context active and user entitled, request `/admin/operations`.
|
||||
- Assert the page indicates `Scope: Tenant — <tenant name>`.
|
||||
- Assert `Show all tenants` and `Back to <tenant name>` are available.
|
||||
|
||||
2. **Operations index scope label (no tenant context)**
|
||||
- With no tenant context active, request `/admin/operations`.
|
||||
- Assert the page indicates `Scope: Workspace — all tenants`.
|
||||
|
||||
3. **Run detail back affordance (tenant context)**
|
||||
- With tenant context active and user entitled, request `/admin/operations/{run}`.
|
||||
- Assert `← Back to <tenant name>` is available.
|
||||
- Assert secondary `Show all operations` is available and links to `/admin/operations`.
|
||||
|
||||
4. **Run detail back affordance (no tenant context)**
|
||||
- With no tenant context active, request `/admin/operations/{run}`.
|
||||
- Assert only `Back to Operations` is available.
|
||||
|
||||
5. **Deny-as-not-found regression**
|
||||
- As a user without tenant entitlement, request `/admin/t/{tenant}`.
|
||||
- Assert deny-as-not-found behavior (404 semantics) and that no tenant identity hints are revealed via Monitoring CTAs.
|
||||
|
||||
6. **Stale tenant context behaves workspace-wide**
|
||||
- With tenant context active but user not entitled, request `/admin/operations`.
|
||||
- Assert scope indicates workspace-wide and no tenant name or “Back to tenant” is present.
|
||||
|
||||
7. **No outbound calls on render**
|
||||
- Assert rendering `/admin/operations` and `/admin/operations/{run}` does not initiate outbound network calls and does not start background work from a view-only GET.
|
||||
192
specs/085-tenant-operate-hub/tasks.md
Normal file
192
specs/085-tenant-operate-hub/tasks.md
Normal file
@ -0,0 +1,192 @@
|
||||
|
||||
---
|
||||
description: "Task list for Spec 085 — Tenant Operate Hub / Tenant Overview IA"
|
||||
---
|
||||
|
||||
# Tasks: Spec 085 — Tenant Operate Hub / Tenant Overview IA
|
||||
|
||||
**Input**: Design documents from `/specs/085-tenant-operate-hub/`
|
||||
|
||||
**Required**:
|
||||
- `specs/085-tenant-operate-hub/plan.md`
|
||||
- `specs/085-tenant-operate-hub/spec.md`
|
||||
|
||||
**Additional docs present**:
|
||||
- `specs/085-tenant-operate-hub/research.md`
|
||||
- `specs/085-tenant-operate-hub/data-model.md`
|
||||
- `specs/085-tenant-operate-hub/contracts/openapi.yaml`
|
||||
- `specs/085-tenant-operate-hub/quickstart.md`
|
||||
|
||||
**Tests**: REQUIRED (runtime UX + security semantics; Pest)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Confirm the existing code touchpoints and test harness for Spec 085.
|
||||
|
||||
- [X] T001 Confirm canonical Monitoring routes + existing clear-context endpoint in routes/web.php and app/Http/Controllers/ClearTenantContextController.php
|
||||
- [X] T002 Confirm the Monitoring pages exist and are canonical: app/Filament/Pages/Monitoring/Operations.php, app/Filament/Pages/Operations/TenantlessOperationRunViewer.php, app/Filament/Pages/Monitoring/Alerts.php, app/Filament/Pages/Monitoring/AuditLog.php
|
||||
- [X] T003 Confirm Tenant panel provider is the entry point for tenant sidebar Monitoring shortcuts in app/Providers/Filament/TenantPanelProvider.php
|
||||
- [X] T004 Confirm Laravel 11+/12 panel provider registration is in bootstrap/providers.php (not bootstrap/app.php)
|
||||
- [X] T005 [P] Identify existing monitoring/tenant scoping tests to extend (tests/Feature/Monitoring/OperationsTenantScopeTest.php, tests/Feature/Operations/TenantlessOperationRunViewerTest.php)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Shared helper behavior must match Spec 085 semantics before story work.
|
||||
|
||||
- [X] T006 Update scope-label copy and semantics in app/Support/OperateHub/OperateHubShell.php (MUST match FR-085-002 exactly: "Scope: Workspace — all tenants" / "Scope: Tenant — <tenant name>")
|
||||
- [X] T007 Ensure OperateHubShell resolves active entitled tenant context safely (Filament tenant when present, otherwise remembered last-tenant id for the current workspace)
|
||||
- [X] T008 Update OperateHubShell return affordance label to include tenant name ("Back to <tenant name>") in app/Support/OperateHub/OperateHubShell.php
|
||||
- [X] T009 Add a helper method to resolve “active tenant AND still entitled” in app/Support/OperateHub/OperateHubShell.php (used by Operations index + run detail to implement stale-tenant-context behavior)
|
||||
- [X] T010 Ensure Monitoring renders remain DB-only (no outbound calls / no side effects) by standardizing test guards with Http::preventStrayRequests() in tests/Feature/Spec085/*.php and existing coverage tests/Feature/Monitoring/OperationsTenantScopeTest.php and tests/Feature/Operations/TenantlessOperationRunViewerTest.php
|
||||
|
||||
**Checkpoint**: Shared semantics locked; user story work can begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Monitoring feels context-aware (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: When tenant context is active, Monitoring clearly shows tenant scope + deterministic “Back to tenant” and offers explicit “Show all tenants” to exit.
|
||||
|
||||
**Independent Test**: With tenant context active + entitled, GET `/admin/operations` shows `Scope: Tenant — <tenant>` and buttons `Back to <tenant>` and `Show all tenants`; clicking “Show all tenants” clears tenant context and returns to workspace-wide operations.
|
||||
|
||||
### Tests for User Story 1 (write first)
|
||||
|
||||
- [X] T011 [P] [US1] Add Spec 085 operations header tests in tests/Feature/Spec085/OperationsIndexHeaderTest.php (tenant scope label + both CTAs)
|
||||
- [X] T012 [P] [US1] Add stale-tenant-context test in tests/Feature/Spec085/OperationsIndexHeaderTest.php (tenant context set but user not entitled → workspace scope + no tenant name + no back-to-tenant)
|
||||
- [X] T013 [P] [US1] Add explicit-exit behavior test in tests/Feature/Spec085/OperationsIndexHeaderTest.php (POST /admin/clear-tenant-context clears Filament tenant + last tenant id)
|
||||
- [X] T014 [P] [US1] Add tenant navigation shortcuts test in tests/Feature/Spec085/TenantNavigationMonitoringShortcutsTest.php (tenant sidebar shows “Monitoring” group with Runs/Alerts/Audit Log)
|
||||
- [X] T015 [P] [US1] Add “deny-as-not-found” regression tests for canonical Monitoring access in tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php (non-workspace-member → 404 for /admin/operations and /admin/operations/{run})
|
||||
- [X] T016 [P] [US1] Add “deny-as-not-found” regression test for tenant dashboard direct access in tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php (non-entitled to tenant → 404 for /admin/t/{tenant})
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T017 [US1] Replace Tenant sidebar "Operations" item with "Monitoring" group shortcuts in app/Providers/Filament/TenantPanelProvider.php (Runs→/admin/operations, Alerts→/admin/alerts, Audit Log→/admin/audit-log)
|
||||
- [X] T018 [US1] Implement Operations index scope indicator per Spec 085 in app/Filament/Pages/Monitoring/Operations.php (workspace vs tenant; stale context treated as workspace)
|
||||
- [X] T019 [US1] Implement Operations index CTAs per Spec 085 in app/Filament/Pages/Monitoring/Operations.php (Back to <tenant> using App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant); Show all tenants exits tenant context)
|
||||
- [X] T020 [US1] Ensure “Show all tenants” uses an explicit server-side action (no implicit GET mutation) in app/Filament/Pages/Monitoring/Operations.php (perform the same mutations as app/Http/Controllers/ClearTenantContextController.php: Filament::setTenant(null, true) + WorkspaceContext::clearLastTenantId(); then redirect to /admin/operations)
|
||||
|
||||
**Checkpoint**: US1 fully testable and meets FR-085-001/002/005/007/010.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Canonical URLs with explicit scope (Priority: P2)
|
||||
|
||||
**Goal**: Canonical Monitoring URLs never implicitly change tenant context; tenant context may only affect default filtering and must be obvious.
|
||||
|
||||
**Independent Test**: With tenant context active, GET `/admin/operations` does not change tenant context and defaults the list to the active tenant (or otherwise clearly shows it’s tenant-scoped by default).
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T021 [P] [US2] Add non-mutation test in tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php (GET /admin/operations does not set/clear tenant context)
|
||||
- [X] T022 [P] [US2] Add scope label test in tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php (no tenant context → "Scope: Workspace — all tenants")
|
||||
- [X] T023 [P] [US2] Add default-tenant-filter test in tests/Feature/Monitoring/OperationsTenantScopeTest.php (tenant context active → list defaults to active tenant)
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T024 [US2] Ensure Operations index query applies workspace scoping and (when tenant context is active + entitled) tenant scoping without mutating tenant context in app/Filament/Pages/Monitoring/Operations.php
|
||||
- [X] T025 [US2] Ensure any default tenant filter is applied as a query/filter default only (no calls to Filament::setTenant() during GET) in app/Filament/Pages/Monitoring/Operations.php
|
||||
|
||||
**Checkpoint**: US2 meets FR-085-003/004/009.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Deep links are safe and recoverable (Priority: P3)
|
||||
|
||||
**Goal**: On `/admin/operations/{run}`, tenant-context users get a deterministic “Back to <tenant>” plus a secondary “Show all operations”; otherwise only “Back to Operations”.
|
||||
|
||||
**Independent Test**: With tenant context active + entitled, GET `/admin/operations/{run}` shows `← Back to <tenant>` and `Show all operations`; without tenant context it shows `Back to Operations` only.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T026 [P] [US3] Add run detail header-action tests in tests/Feature/Spec085/RunDetailBackAffordanceTest.php (tenant context vs no context)
|
||||
- [X] T027 [P] [US3] Add stale-tenant-context run detail test in tests/Feature/Spec085/RunDetailBackAffordanceTest.php (tenant context set but not entitled → no tenant name, no back-to-tenant)
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T028 [US3] Implement deterministic back affordances for run detail in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php (tenant-context+entitled → “← Back to <tenant>” + “Show all operations”; else “Back to Operations”)
|
||||
- [X] T029 [US3] Ensure run detail never reveals tenant identity when the viewer is not entitled (stale tenant context treated as workspace-wide) in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||
|
||||
**Checkpoint**: US3 meets FR-085-006/008/010.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [X] T030 [P] Confirm Spec 085 UI Action Matrix matches implemented header actions in specs/085-tenant-operate-hub/spec.md
|
||||
- [X] T031 [P] Validate manual verification steps in specs/085-tenant-operate-hub/quickstart.md against actual behavior (update doc only if it drifted)
|
||||
- [X] T037 Ensure “Show all tenants” clears Operations table tenant filter state (prevents stale Livewire table filter state from keeping the list scoped)
|
||||
- [X] T032 Run formatting on changed files under app/ and tests/ using vendor/bin/sail bin pint --dirty
|
||||
- [X] T033 Run focused test suite: vendor/bin/sail artisan test --compact tests/Feature/Spec085 tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php
|
||||
- [X] T034 Fix Filament auth-pattern guard compliance by removing Gate:: usage in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php (use $this->authorize(...))
|
||||
- [X] T035 Ensure canonical Operate Hub routes sanitize stale/non-entitled tenant context by applying ensure-filament-tenant-selected middleware to /admin/operations, /admin/alerts, /admin/audit-log, and /admin/operations/{run}
|
||||
- [X] T036 Harden Spec 085-related tests to match final copy/semantics and avoid brittle Livewire DOM assertions (tests/Feature/OpsUx/OperateHubShellTest.php, tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Dependency Graph
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
P1[Phase 1: Setup] --> P2[Phase 2: Foundational]
|
||||
P2 --> US1[US1 (P1): Context-aware Monitoring entry]
|
||||
US1 --> US2[US2 (P2): Canonical URLs + explicit scope]
|
||||
US1 --> US3[US3 (P3): Deep-link back affordances]
|
||||
US2 --> P6[Phase 6: Polish]
|
||||
US3 --> P6
|
||||
```
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- US1 is the MVP.
|
||||
- US2 and US3 depend on the shared foundational semantics (scope labels + entitled active tenant resolution).
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### US1
|
||||
|
||||
```text
|
||||
In parallel:
|
||||
- T011 (tests) in tests/Feature/Spec085/OperationsIndexHeaderTest.php
|
||||
- T017 (tenant nav shortcuts) in app/Providers/Filament/TenantPanelProvider.php
|
||||
Then:
|
||||
- T018–T020 in app/Filament/Pages/Monitoring/Operations.php
|
||||
```
|
||||
|
||||
### US2
|
||||
|
||||
```text
|
||||
In parallel:
|
||||
- T021–T022 (non-mutation + scope label tests)
|
||||
- T023 (default tenant filter test) in tests/Feature/Monitoring/OperationsTenantScopeTest.php
|
||||
Then:
|
||||
- T024–T025 in app/Filament/Pages/Monitoring/Operations.php
|
||||
```
|
||||
|
||||
### US3
|
||||
|
||||
```text
|
||||
In parallel:
|
||||
- T026–T027 (run detail tests) in tests/Feature/Spec085/RunDetailBackAffordanceTest.php
|
||||
Then:
|
||||
- T028–T029 in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP Scope
|
||||
|
||||
- Implement US1 only (T011–T020), run T033, then manually validate via specs/085-tenant-operate-hub/quickstart.md.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
- US1 → US2 → US3, keeping each story independently testable.
|
||||
@ -118,12 +118,21 @@
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(Operations::class)
|
||||
->assertCanSeeTableRecords([$runA])
|
||||
->assertCanNotSeeTableRecords([$runB]);
|
||||
->assertSee('TenantA')
|
||||
->assertDontSee('TenantB')
|
||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey());
|
||||
|
||||
$component
|
||||
->filterTable('tenant_id', null)
|
||||
->assertCanSeeTableRecords([$runA, $runB]);
|
||||
->callAction('operate_hub_show_all_tenants')
|
||||
->assertSet('tableFilters.tenant_id.value', null)
|
||||
->assertRedirect('/admin/operations');
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Operations::class)
|
||||
->assertSee('TenantA')
|
||||
->assertSee('TenantB');
|
||||
});
|
||||
|
||||
it('does not register legacy operation resource routes', function (): void {
|
||||
|
||||
@ -5,8 +5,13 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function (): void {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('defaults Monitoring → Operations list to the active tenant when tenant context is set', function () {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
$tenantB = Tenant::factory()->create();
|
||||
@ -47,6 +52,49 @@
|
||||
->assertDontSee('TenantB');
|
||||
});
|
||||
|
||||
it('defaults Monitoring → Operations list to the remembered tenant when Filament tenant is not available', function () {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
$tenantB = Tenant::factory()->create();
|
||||
|
||||
[$user] = createUserWithTenant($tenantA, role: 'owner');
|
||||
|
||||
$tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save();
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenantB->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $tenantA->getKey(),
|
||||
'type' => 'policy.sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'initiator_name' => 'TenantA',
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $tenantB->getKey(),
|
||||
'type' => 'inventory.sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'initiator_name' => 'TenantB',
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$workspaceId = (int) $tenantA->workspace_id;
|
||||
app(WorkspaceContext::class)->rememberLastTenantId($workspaceId, (int) $tenantA->getKey());
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => $workspaceId])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee('Scope: Tenant — '.$tenantA->name)
|
||||
->assertSee('Policy sync')
|
||||
->assertSee('TenantA')
|
||||
->assertDontSee('Inventory sync');
|
||||
});
|
||||
|
||||
it('scopes Monitoring → Operations tabs to the active tenant', function () {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
$tenantB = Tenant::factory()->create();
|
||||
|
||||
@ -9,6 +9,11 @@
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function (): void {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('allows viewing an operation run without a selected workspace when the user is a member of the run workspace', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
it('routes all OperationRun view links through OperationRunLinks', function (): void {
|
||||
@ -30,3 +32,12 @@
|
||||
|
||||
expect($violations)->toBeEmpty();
|
||||
})->group('ops-ux');
|
||||
|
||||
it('resolves tenantless operation run links to the canonical admin.operations.view route', function (): void {
|
||||
$run = OperationRun::factory()->create();
|
||||
|
||||
$expectedUrl = route('admin.operations.view', ['run' => (int) $run->getKey()]);
|
||||
|
||||
expect(OperationRunLinks::tenantlessView($run))->toBe($expectedUrl);
|
||||
expect(OperationRunLinks::tenantlessView((int) $run->getKey()))->toBe($expectedUrl);
|
||||
})->group('ops-ux');
|
||||
|
||||
264
tests/Feature/OpsUx/OperateHubShellTest.php
Normal file
264
tests/Feature/OpsUx/OperateHubShellTest.php
Normal file
@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
it('renders operate hub pages DB-only with no outbound HTTP and no queued jobs', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'blocked',
|
||||
'initiator_name' => 'System',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Bus::fake();
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$session = [
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
];
|
||||
|
||||
assertNoOutboundHttp(function () use ($run, $session): void {
|
||||
$this->withSession($session)
|
||||
->get(route('admin.operations.index'))
|
||||
->assertOk()
|
||||
->assertSee('Scope: Workspace — all tenants');
|
||||
|
||||
$this->withSession($session)
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('Scope: Workspace — all tenants');
|
||||
|
||||
$this->withSession($session)
|
||||
->get(route('admin.monitoring.alerts'))
|
||||
->assertOk()
|
||||
->assertSee('Scope: Workspace — all tenants');
|
||||
|
||||
$this->withSession($session)
|
||||
->get(route('admin.monitoring.audit-log'))
|
||||
->assertOk()
|
||||
->assertSee('Scope: Workspace — all tenants');
|
||||
});
|
||||
|
||||
Bus::assertNothingDispatched();
|
||||
})->group('ops-ux');
|
||||
|
||||
it('shows back to tenant on run detail when tenant context is active and entitled', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'policy.sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$response = $this->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])->get(route('admin.operations.view', ['run' => (int) $run->getKey()]));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('← Back to '.$tenant->name)
|
||||
->assertSee(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant), false)
|
||||
->assertSee('Show all operations')
|
||||
->assertDontSee('Back to Operations');
|
||||
|
||||
expect(substr_count((string) $response->getContent(), '← Back to '.$tenant->name))->toBe(1);
|
||||
})->group('ops-ux');
|
||||
|
||||
it('shows back to tenant when filament tenant is absent but last tenant memory exists', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'policy.sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$workspaceId = (int) $tenant->workspace_id;
|
||||
$lastTenantMap = [(string) $workspaceId => (int) $tenant->getKey()];
|
||||
|
||||
$response = $this->withSession([
|
||||
WorkspaceContext::SESSION_KEY => $workspaceId,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => $lastTenantMap,
|
||||
])->get(route('admin.operations.view', ['run' => (int) $run->getKey()]));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('← Back to '.$tenant->name)
|
||||
->assertSee(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant), false)
|
||||
->assertSee('Show all operations')
|
||||
->assertDontSee('Back to Operations');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('shows no tenant return affordance when active and last tenant contexts are not entitled', function (): void {
|
||||
[$user, $entitledTenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$nonEntitledTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $entitledTenant->workspace_id,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $entitledTenant->getKey(),
|
||||
'workspace_id' => (int) $entitledTenant->workspace_id,
|
||||
'type' => 'policy.sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($nonEntitledTenant, true);
|
||||
|
||||
$workspaceId = (int) $entitledTenant->workspace_id;
|
||||
|
||||
$response = $this->withSession([
|
||||
WorkspaceContext::SESSION_KEY => $workspaceId,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [(string) $workspaceId => (int) $nonEntitledTenant->getKey()],
|
||||
])->get(route('admin.operations.view', ['run' => (int) $run->getKey()]));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('Back to Operations')
|
||||
->assertDontSee('← Back to '.$nonEntitledTenant->name)
|
||||
->assertDontSee('Show all operations');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('returns 404 for non-member workspace access to /admin/operations', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(route('admin.operations.index'))
|
||||
->assertNotFound();
|
||||
})->group('ops-ux');
|
||||
|
||||
it('returns 404 for non-entitled tenant dashboard direct access', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
|
||||
->assertNotFound();
|
||||
})->group('ops-ux');
|
||||
|
||||
it('keeps member-without-capability workflow start denial as 403 with no run side effects', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(RestoreRunResource::getUrl('create', tenant: $tenant))
|
||||
->assertForbidden();
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->exists())->toBeFalse();
|
||||
})->group('ops-ux');
|
||||
|
||||
it('does not mutate workspace or last-tenant session memory on /admin/operations', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$workspaceId = (int) $tenant->workspace_id;
|
||||
$lastTenantMap = [(string) $workspaceId => (int) $tenant->getKey()];
|
||||
|
||||
$response = $this->withSession([
|
||||
WorkspaceContext::SESSION_KEY => $workspaceId,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => $lastTenantMap,
|
||||
])->get(route('admin.operations.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSessionHas(WorkspaceContext::SESSION_KEY, $workspaceId);
|
||||
$response->assertSessionHas(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, $lastTenantMap);
|
||||
})->group('ops-ux');
|
||||
|
||||
it('shows tenant scope label when tenant context is active', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])->get(route('admin.operations.index'))
|
||||
->assertOk()
|
||||
->assertSee('Scope: Tenant — '.$tenant->name)
|
||||
->assertDontSee('Scope: Workspace — all tenants');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('does not create audit entries when viewing operate hub pages', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'policy.sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$before = (int) AuditLog::query()->count();
|
||||
|
||||
$session = [
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
];
|
||||
|
||||
$this->withSession($session)
|
||||
->get(route('admin.operations.index'))
|
||||
->assertOk();
|
||||
|
||||
$this->withSession($session)
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertOk();
|
||||
|
||||
$this->withSession($session)
|
||||
->get(route('admin.monitoring.alerts'))
|
||||
->assertOk();
|
||||
|
||||
$this->withSession($session)
|
||||
->get(route('admin.monitoring.audit-log'))
|
||||
->assertOk();
|
||||
|
||||
expect((int) AuditLog::query()->count())->toBe($before);
|
||||
})->group('ops-ux');
|
||||
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('does not set or clear tenant context on GET /admin/operations', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin/operations')
|
||||
->assertOk();
|
||||
|
||||
expect(Filament::getTenant())->toBe($tenant);
|
||||
});
|
||||
|
||||
it('renders workspace scope label when no tenant context is active', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee('Scope: Workspace — all tenants');
|
||||
|
||||
expect(Filament::getTenant())->toBeNull();
|
||||
});
|
||||
63
tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php
Normal file
63
tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('returns 404 for non-workspace-members on central operations index', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
session()->forget(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/operations')
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 for non-workspace-members on central operation run detail', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
session()->forget(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => null,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get("/admin/operations/{$run->getKey()}")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 for non-entitled users on tenant dashboard direct access', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenantB))
|
||||
->assertNotFound();
|
||||
});
|
||||
83
tests/Feature/Spec085/OperationsIndexHeaderTest.php
Normal file
83
tests/Feature/Spec085/OperationsIndexHeaderTest.php
Normal file
@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('renders tenant scope label and CTAs when tenant context is active and entitled', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee('Scope: Tenant — '.$tenant->name)
|
||||
->assertSee('Back to '.$tenant->name)
|
||||
->assertSee('Show all tenants');
|
||||
});
|
||||
|
||||
it('treats stale tenant context as workspace-wide without tenant identity hints', function (): void {
|
||||
$entitledTenant = Tenant::factory()->create();
|
||||
[$user, $entitledTenant] = createUserWithTenant($entitledTenant, role: 'owner', workspaceRole: 'readonly');
|
||||
|
||||
$staleTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $entitledTenant->workspace_id,
|
||||
]);
|
||||
|
||||
Filament::setTenant($staleTenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee('Scope: Workspace — all tenants')
|
||||
->assertDontSee('Back to '.$staleTenant->name)
|
||||
->assertDontSee($staleTenant->name)
|
||||
->assertDontSee('Show all tenants');
|
||||
});
|
||||
|
||||
it('clears filament tenant context and last-tenant session state via clear-tenant-context endpoint', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
$workspaceId = (int) $tenant->workspace_id;
|
||||
$lastTenantIds = [
|
||||
(string) $workspaceId => (int) $tenant->getKey(),
|
||||
];
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => $workspaceId,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => $lastTenantIds,
|
||||
])
|
||||
->from('/admin/alerts')
|
||||
->post('/admin/clear-tenant-context')
|
||||
->assertRedirect('/admin/alerts');
|
||||
|
||||
expect(Filament::getTenant())->toBeNull();
|
||||
expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, []))
|
||||
->not->toHaveKey((string) $workspaceId);
|
||||
|
||||
$this->withSession([
|
||||
WorkspaceContext::SESSION_KEY => $workspaceId,
|
||||
])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee('Scope: Workspace — all tenants')
|
||||
->assertDontSee('Scope: Tenant — '.$tenant->name);
|
||||
});
|
||||
93
tests/Feature/Spec085/RunDetailBackAffordanceTest.php
Normal file
93
tests/Feature/Spec085/RunDetailBackAffordanceTest.php
Normal file
@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('shows back-to-tenant and show-all-operations when tenant context is active and entitled', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
]);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/operations/{$run->getKey()}")
|
||||
->assertOk()
|
||||
->assertSee('← Back to '.$tenant->name)
|
||||
->assertSee('Show all operations')
|
||||
->assertDontSee('Back to Operations');
|
||||
});
|
||||
|
||||
it('shows only back-to-operations when no tenant context is active', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/operations/{$run->getKey()}")
|
||||
->assertOk()
|
||||
->assertSee('Back to Operations')
|
||||
->assertDontSee('← Back to ')
|
||||
->assertDontSee('Show all operations');
|
||||
});
|
||||
|
||||
it('treats stale tenant context as workspace-wide on run detail', function (): void {
|
||||
$entitledTenant = Tenant::factory()->create();
|
||||
[$user, $entitledTenant] = createUserWithTenant($entitledTenant, role: 'owner', workspaceRole: 'readonly');
|
||||
|
||||
$staleTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $entitledTenant->workspace_id,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $entitledTenant->workspace_id,
|
||||
'tenant_id' => (int) $entitledTenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
]);
|
||||
|
||||
Filament::setTenant($staleTenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id])
|
||||
->get("/admin/operations/{$run->getKey()}")
|
||||
->assertOk()
|
||||
->assertSee('Scope: Workspace — all tenants')
|
||||
->assertSee('Back to Operations')
|
||||
->assertDontSee('← Back to '.$staleTenant->name)
|
||||
->assertDontSee($staleTenant->name)
|
||||
->assertDontSee('Show all operations');
|
||||
});
|
||||
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('shows a Monitoring group with central shortcuts in the tenant sidebar', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
|
||||
->assertOk();
|
||||
|
||||
$panel = Filament::getCurrentOrDefaultPanel();
|
||||
|
||||
$monitoringLabels = collect($panel->getNavigationItems())
|
||||
->filter(static fn ($item): bool => $item->getGroup() === 'Monitoring')
|
||||
->map(static fn ($item): string => $item->getLabel())
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($monitoringLabels)->toContain('Runs');
|
||||
expect($monitoringLabels)->toContain('Alerts');
|
||||
expect($monitoringLabels)->toContain('Audit Log');
|
||||
|
||||
expect($monitoringLabels)->not->toContain('Operations');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user