Spec 077: Workspace Global Mode + context bar redundancy cleanup #94
@ -7,6 +7,7 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserTenantPreference;
|
use App\Models\UserTenantPreference;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
@ -69,6 +70,8 @@ public function selectTenant(int $tenantId): void
|
|||||||
|
|
||||||
$this->persistLastTenant($user, $tenant);
|
$this->persistLastTenant($user, $tenant);
|
||||||
|
|
||||||
|
app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request());
|
||||||
|
|
||||||
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use App\Support\Workspaces\WorkspaceIntendedUrl;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -100,7 +101,9 @@ public function selectWorkspace(int $workspaceId): void
|
|||||||
|
|
||||||
$context->setCurrentWorkspace($workspace, $user, request());
|
$context->setCurrentWorkspace($workspace, $user, request());
|
||||||
|
|
||||||
$this->redirect($this->redirectAfterWorkspaceSelected($user));
|
$intendedUrl = WorkspaceIntendedUrl::consume(request());
|
||||||
|
|
||||||
|
$this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -132,7 +135,9 @@ public function createWorkspace(array $data): void
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
$this->redirect($this->redirectAfterWorkspaceSelected($user));
|
$intendedUrl = WorkspaceIntendedUrl::consume(request());
|
||||||
|
|
||||||
|
$this->redirect($intendedUrl ?: $this->redirectAfterWorkspaceSelected($user));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function redirectAfterWorkspaceSelected(User $user): string
|
private function redirectAfterWorkspaceSelected(User $user): string
|
||||||
|
|||||||
26
app/Filament/Pages/Monitoring/Alerts.php
Normal file
26
app/Filament/Pages/Monitoring/Alerts.php
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Monitoring;
|
||||||
|
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class Alerts extends Page
|
||||||
|
{
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Alerts';
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
|
||||||
|
|
||||||
|
protected static ?string $slug = 'alerts';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Alerts';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.monitoring.alerts';
|
||||||
|
}
|
||||||
26
app/Filament/Pages/Monitoring/AuditLog.php
Normal file
26
app/Filament/Pages/Monitoring/AuditLog.php
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Monitoring;
|
||||||
|
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class AuditLog extends Page
|
||||||
|
{
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Audit Log';
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-list';
|
||||||
|
|
||||||
|
protected static ?string $slug = 'audit-log';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Audit Log';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.monitoring.audit-log';
|
||||||
|
}
|
||||||
@ -1,22 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Pages\Monitoring;
|
namespace App\Filament\Pages\Monitoring;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OperationRunResource;
|
||||||
|
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Components\DatePicker;
|
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
use Filament\Forms\Contracts\HasForms;
|
use Filament\Forms\Contracts\HasForms;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use Filament\Tables\Filters\Filter;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
@ -26,6 +25,8 @@ class Operations extends Page implements HasForms, HasTable
|
|||||||
use InteractsWithForms;
|
use InteractsWithForms;
|
||||||
use InteractsWithTable;
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
public string $activeTab = 'all';
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
||||||
@ -37,89 +38,62 @@ class Operations extends Page implements HasForms, HasTable
|
|||||||
// Must be non-static
|
// Must be non-static
|
||||||
protected string $view = 'filament.pages.monitoring.operations';
|
protected string $view = 'filament.pages.monitoring.operations';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->mountInteractsWithTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
OperationsKpiHeader::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedActiveTab(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return OperationRunResource::table($table)
|
||||||
->query(
|
->query(function (): Builder {
|
||||||
OperationRun::query()
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
->where('tenant_id', Filament::getTenant()->id)
|
|
||||||
->latest('created_at')
|
|
||||||
)
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('type')
|
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
|
||||||
->searchable()
|
|
||||||
->sortable(),
|
|
||||||
|
|
||||||
TextColumn::make('status')
|
$query = OperationRun::query()
|
||||||
->badge()
|
->with('user')
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
->latest('id')
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
|
||||||
|
|
||||||
TextColumn::make('outcome')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
|
||||||
|
|
||||||
TextColumn::make('initiator_name')
|
|
||||||
->label('Initiator')
|
|
||||||
->searchable(),
|
|
||||||
|
|
||||||
TextColumn::make('created_at')
|
|
||||||
->dateTime()
|
|
||||||
->sortable()
|
|
||||||
->label('Started'),
|
|
||||||
|
|
||||||
TextColumn::make('duration')
|
|
||||||
->getStateUsing(function (OperationRun $record) {
|
|
||||||
if ($record->started_at && $record->completed_at) {
|
|
||||||
return $record->completed_at->diffForHumans($record->started_at, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return '-';
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('outcome')
|
|
||||||
->options([
|
|
||||||
'succeeded' => 'Succeeded',
|
|
||||||
'partially_succeeded' => 'Partially Succeeded',
|
|
||||||
'failed' => 'Failed',
|
|
||||||
'cancelled' => 'Cancelled',
|
|
||||||
'pending' => 'Pending',
|
|
||||||
]),
|
|
||||||
|
|
||||||
SelectFilter::make('type')
|
|
||||||
->options(
|
|
||||||
fn () => OperationRun::where('tenant_id', Filament::getTenant()->id)
|
|
||||||
->distinct()
|
|
||||||
->pluck('type', 'type')
|
|
||||||
->toArray()
|
|
||||||
),
|
|
||||||
|
|
||||||
Filter::make('created_at')
|
|
||||||
->form([
|
|
||||||
DatePicker::make('created_from'),
|
|
||||||
DatePicker::make('created_until'),
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data): Builder {
|
|
||||||
return $query
|
|
||||||
->when(
|
->when(
|
||||||
$data['created_from'],
|
$workspaceId,
|
||||||
fn (Builder $query, $date) => $query->whereDate('created_at', '>=', $date),
|
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
||||||
)
|
)
|
||||||
->when(
|
->when(
|
||||||
$data['created_until'],
|
! $workspaceId,
|
||||||
fn (Builder $query, $date) => $query->whereDate('created_at', '<=', $date),
|
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||||
);
|
);
|
||||||
}),
|
|
||||||
])
|
return $this->applyActiveTab($query);
|
||||||
->actions([
|
});
|
||||||
// View action handled by opening a modal or side-peek
|
}
|
||||||
]);
|
|
||||||
|
private function applyActiveTab(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return match ($this->activeTab) {
|
||||||
|
'active' => $query->whereIn('status', [
|
||||||
|
OperationRunStatus::Queued->value,
|
||||||
|
OperationRunStatus::Running->value,
|
||||||
|
]),
|
||||||
|
'succeeded' => $query
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->where('outcome', OperationRunOutcome::Succeeded->value),
|
||||||
|
'partial' => $query
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->where('outcome', OperationRunOutcome::PartiallySucceeded->value),
|
||||||
|
'failed' => $query
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->where('outcome', OperationRunOutcome::Failed->value),
|
||||||
|
default => $query,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,8 +17,10 @@
|
|||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OpsUx\RunDetailPolling;
|
use App\Support\OpsUx\RunDetailPolling;
|
||||||
use App\Support\OpsUx\RunDurationInsights;
|
use App\Support\OpsUx\RunDurationInsights;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\DatePicker;
|
use Filament\Forms\Components\DatePicker;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
@ -38,6 +40,8 @@ class OperationRunResource extends Resource
|
|||||||
|
|
||||||
protected static ?string $slug = 'operations';
|
protected static ?string $slug = 'operations';
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
@ -46,12 +50,13 @@ class OperationRunResource extends Resource
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::current()?->getKey();
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->with('user')
|
->with('user')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
->when($workspaceId, fn (Builder $query) => $query->where('workspace_id', (int) $workspaceId))
|
||||||
|
->when(! $workspaceId, fn (Builder $query) => $query->whereRaw('1 = 0'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
@ -156,7 +161,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
$previousRunUrl = null;
|
$previousRunUrl = null;
|
||||||
|
|
||||||
if ($changeIndicator !== null) {
|
if ($changeIndicator !== null) {
|
||||||
$tenant = Tenant::current();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
$previousRunUrl = $tenant instanceof Tenant
|
$previousRunUrl = $tenant instanceof Tenant
|
||||||
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)
|
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)
|
||||||
@ -272,16 +277,47 @@ public static function table(Table $table): Table
|
|||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('tenant_id')
|
||||||
|
->label('Tenant')
|
||||||
|
->options(function (): array {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($user->getTenants(Filament::getCurrentOrDefaultPanel()))
|
||||||
|
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||||
|
(string) $tenant->getKey() => $tenant->getFilamentName(),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
})
|
||||||
|
->default(function (): ?string {
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
|
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $tenant->getKey();
|
||||||
|
})
|
||||||
|
->searchable(),
|
||||||
Tables\Filters\SelectFilter::make('type')
|
Tables\Filters\SelectFilter::make('type')
|
||||||
->options(function (): array {
|
->options(function (): array {
|
||||||
$tenantId = Tenant::current()?->getKey();
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
if (! $tenantId) {
|
if ($workspaceId === null) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return OperationRun::query()
|
return OperationRun::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('workspace_id', (int) $workspaceId)
|
||||||
->select('type')
|
->select('type')
|
||||||
->distinct()
|
->distinct()
|
||||||
->orderBy('type')
|
->orderBy('type')
|
||||||
@ -299,14 +335,20 @@ public static function table(Table $table): Table
|
|||||||
Tables\Filters\SelectFilter::make('initiator_name')
|
Tables\Filters\SelectFilter::make('initiator_name')
|
||||||
->label('Initiator')
|
->label('Initiator')
|
||||||
->options(function (): array {
|
->options(function (): array {
|
||||||
$tenantId = Tenant::current()?->getKey();
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|
||||||
if (! $tenantId) {
|
if ($workspaceId === null) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
$tenantId = $tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $workspaceId
|
||||||
|
? (int) $tenant->getKey()
|
||||||
|
: null;
|
||||||
|
|
||||||
return OperationRun::query()
|
return OperationRun::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('workspace_id', (int) $workspaceId)
|
||||||
|
->when($tenantId, fn (Builder $query): Builder => $query->where('tenant_id', $tenantId))
|
||||||
->whereNotNull('initiator_name')
|
->whereNotNull('initiator_name')
|
||||||
->select('initiator_name')
|
->select('initiator_name')
|
||||||
->distinct()
|
->distinct()
|
||||||
@ -342,7 +384,8 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make()
|
||||||
|
->url(fn (OperationRun $record): string => route('admin.operations.view', ['run' => (int) $record->getKey()])),
|
||||||
])
|
])
|
||||||
->bulkActions([]);
|
->bulkActions([]);
|
||||||
}
|
}
|
||||||
@ -351,7 +394,7 @@ public static function getPages(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'index' => Pages\ListOperationRuns::route('/'),
|
'index' => Pages\ListOperationRuns::route('/'),
|
||||||
'view' => Pages\ViewOperationRun::route('/{record}'),
|
'view' => Pages\ViewOperationRun::route('/r/{record}'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
||||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
@ -23,6 +24,7 @@ protected function getHeaderWidgets(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
TenantArchivedBanner::class,
|
TenantArchivedBanner::class,
|
||||||
|
RecentOperationsSummary::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\Workspaces;
|
namespace App\Filament\Resources\Workspaces;
|
||||||
|
|
||||||
use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager;
|
use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager;
|
||||||
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -11,6 +12,7 @@
|
|||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class WorkspaceResource extends Resource
|
class WorkspaceResource extends Resource
|
||||||
@ -25,10 +27,31 @@ class WorkspaceResource extends Resource
|
|||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static ?string $breadcrumb = 'Manage workspaces';
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$query = parent::getEloquentQuery();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return $query->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->whereNull('archived_at')
|
||||||
|
->whereIn('id', function ($subQuery) use ($user): void {
|
||||||
|
$subQuery->from('workspace_memberships')
|
||||||
|
->select('workspace_id')
|
||||||
|
->where('user_id', $user->getKey());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
namespace App\Filament\Widgets\Dashboard;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -81,10 +80,10 @@ protected function getStats(): array
|
|||||||
->url(FindingResource::getUrl('index', tenant: $tenant)),
|
->url(FindingResource::getUrl('index', tenant: $tenant)),
|
||||||
Stat::make('Active operations', $activeRuns)
|
Stat::make('Active operations', $activeRuns)
|
||||||
->color($activeRuns > 0 ? 'warning' : 'gray')
|
->color($activeRuns > 0 ? 'warning' : 'gray')
|
||||||
->url(OperationRunResource::getUrl('index', tenant: $tenant)),
|
->url(route('admin.operations.index')),
|
||||||
Stat::make('Inventory active', $inventoryActiveRuns)
|
Stat::make('Inventory active', $inventoryActiveRuns)
|
||||||
->color($inventoryActiveRuns > 0 ? 'warning' : 'gray')
|
->color($inventoryActiveRuns > 0 ? 'warning' : 'gray')
|
||||||
->url(OperationRunResource::getUrl('index', tenant: $tenant)),
|
->url(route('admin.operations.index')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
56
app/Filament/Widgets/Tenant/RecentOperationsSummary.php
Normal file
56
app/Filament/Widgets/Tenant/RecentOperationsSummary.php
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets\Tenant;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Widgets\Widget;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class RecentOperationsSummary extends Widget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected string $view = 'filament.widgets.tenant.recent-operations-summary';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function getViewData(): array
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [
|
||||||
|
'tenant' => null,
|
||||||
|
'runs' => collect(),
|
||||||
|
'operationsIndexUrl' => route('admin.operations.index'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Collection<int, OperationRun> $runs */
|
||||||
|
$runs = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->limit(5)
|
||||||
|
->get([
|
||||||
|
'id',
|
||||||
|
'type',
|
||||||
|
'status',
|
||||||
|
'outcome',
|
||||||
|
'created_at',
|
||||||
|
'started_at',
|
||||||
|
'completed_at',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'runs' => $runs,
|
||||||
|
'operationsIndexUrl' => route('admin.operations.index'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Http/Controllers/ClearTenantContextController.php
Normal file
22
app/Http/Controllers/ClearTenantContextController.php
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class ClearTenantContextController
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
app(WorkspaceContext::class)->clearLastTenantId($request);
|
||||||
|
|
||||||
|
return redirect()->to('/admin/operations');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,6 +49,8 @@ public function __invoke(Request $request): RedirectResponse
|
|||||||
|
|
||||||
$this->persistLastTenant($user, $tenant);
|
$this->persistLastTenant($user, $tenant);
|
||||||
|
|
||||||
|
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
|
||||||
|
|
||||||
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
|
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use App\Support\Workspaces\WorkspaceIntendedUrl;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
@ -44,6 +45,12 @@ public function __invoke(Request $request): RedirectResponse
|
|||||||
|
|
||||||
$context->setCurrentWorkspace($workspace, $user, $request);
|
$context->setCurrentWorkspace($workspace, $user, $request);
|
||||||
|
|
||||||
|
$intendedUrl = WorkspaceIntendedUrl::consume($request);
|
||||||
|
|
||||||
|
if ($intendedUrl !== null) {
|
||||||
|
return redirect()->to($intendedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
$tenantsQuery = $user->tenants()
|
$tenantsQuery = $user->tenants()
|
||||||
->where('workspace_id', $workspace->getKey())
|
->where('workspace_id', $workspace->getKey())
|
||||||
->where('status', 'active');
|
->where('status', 'active');
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use App\Support\Workspaces\WorkspaceIntendedUrl;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response as HttpResponse;
|
use Illuminate\Http\Response as HttpResponse;
|
||||||
@ -75,6 +76,10 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
|
|
||||||
$target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access';
|
$target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access';
|
||||||
|
|
||||||
|
if ($target === '/admin/choose-workspace') {
|
||||||
|
WorkspaceIntendedUrl::storeFromRequest($request);
|
||||||
|
}
|
||||||
|
|
||||||
return new HttpResponse('', 302, ['Location' => $target]);
|
return new HttpResponse('', 302, ['Location' => $target]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,46 +6,77 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use Illuminate\Auth\Access\Response;
|
||||||
|
|
||||||
class WorkspacePolicy
|
class WorkspacePolicy
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Determine whether the user can view any models.
|
* Determine whether the user can view any models.
|
||||||
*/
|
*/
|
||||||
public function viewAny(User $user): bool
|
public function viewAny(User $user): bool|Response
|
||||||
{
|
{
|
||||||
return true;
|
$isMember = WorkspaceMembership::query()
|
||||||
|
->where('user_id', $user->getKey())
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
return $isMember ? Response::allow() : Response::denyAsNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine whether the user can view the model.
|
* Determine whether the user can view the model.
|
||||||
*/
|
*/
|
||||||
public function view(User $user, Workspace $workspace): bool
|
public function view(User $user, Workspace $workspace): bool|Response
|
||||||
{
|
{
|
||||||
return WorkspaceMembership::query()
|
$isMember = WorkspaceMembership::query()
|
||||||
->where('user_id', $user->getKey())
|
->where('user_id', $user->getKey())
|
||||||
->where('workspace_id', $workspace->getKey())
|
->where('workspace_id', $workspace->getKey())
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
|
return $isMember ? Response::allow() : Response::denyAsNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine whether the user can create models.
|
* Determine whether the user can create models.
|
||||||
*/
|
*/
|
||||||
public function create(User $user): bool
|
public function create(User $user): bool|Response
|
||||||
{
|
{
|
||||||
return true;
|
$hasAnyMembership = WorkspaceMembership::query()
|
||||||
|
->where('user_id', $user->getKey())
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $hasAnyMembership) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$rolesWithManageCapability = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MANAGE);
|
||||||
|
|
||||||
|
$canManageAnyWorkspace = WorkspaceMembership::query()
|
||||||
|
->where('user_id', $user->getKey())
|
||||||
|
->whereIn('role', $rolesWithManageCapability)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
return $canManageAnyWorkspace
|
||||||
|
? Response::allow()
|
||||||
|
: Response::deny();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine whether the user can update the model.
|
* Determine whether the user can update the model.
|
||||||
*/
|
*/
|
||||||
public function update(User $user, Workspace $workspace): bool
|
public function update(User $user, Workspace $workspace): bool|Response
|
||||||
{
|
{
|
||||||
/** @var WorkspaceCapabilityResolver $resolver */
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE);
|
if (! $resolver->isMember($user, $workspace)) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE)
|
||||||
|
? Response::allow()
|
||||||
|
: Response::deny();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -9,6 +9,10 @@
|
|||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
@ -53,18 +57,56 @@ public function panel(Panel $panel): Panel
|
|||||||
'primary' => Color::Amber,
|
'primary' => Color::Amber,
|
||||||
])
|
])
|
||||||
->navigationItems([
|
->navigationItems([
|
||||||
NavigationItem::make('Workspaces')
|
NavigationItem::make('Switch workspace')
|
||||||
|
->url(fn (): string => ChooseWorkspace::getUrl())
|
||||||
|
->icon('heroicon-o-squares-2x2')
|
||||||
|
->group('Settings')
|
||||||
|
->sort(10),
|
||||||
|
NavigationItem::make('Manage workspaces')
|
||||||
->url(function (): string {
|
->url(function (): string {
|
||||||
return route('filament.admin.resources.workspaces.index');
|
return route('filament.admin.resources.workspaces.index');
|
||||||
})
|
})
|
||||||
->icon('heroicon-o-squares-2x2')
|
->icon('heroicon-o-squares-2x2')
|
||||||
->group('Settings')
|
->group('Settings')
|
||||||
|
->sort(20)
|
||||||
|
->visible(function (): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||||
|
|
||||||
|
return WorkspaceMembership::query()
|
||||||
|
->where('user_id', (int) $user->getKey())
|
||||||
|
->whereIn('role', $roles)
|
||||||
|
->exists();
|
||||||
|
}),
|
||||||
|
NavigationItem::make('Operations')
|
||||||
|
->url(fn (): string => route('admin.operations.index'))
|
||||||
|
->icon('heroicon-o-queue-list')
|
||||||
|
->group('Monitoring')
|
||||||
->sort(10),
|
->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(
|
->renderHook(
|
||||||
PanelsRenderHook::HEAD_END,
|
PanelsRenderHook::HEAD_END,
|
||||||
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
||||||
)
|
)
|
||||||
|
->renderHook(
|
||||||
|
PanelsRenderHook::TOPBAR_START,
|
||||||
|
fn () => view('filament.partials.context-bar')->render()
|
||||||
|
)
|
||||||
->renderHook(
|
->renderHook(
|
||||||
PanelsRenderHook::USER_MENU_PROFILE_AFTER,
|
PanelsRenderHook::USER_MENU_PROFILE_AFTER,
|
||||||
fn () => view('filament.partials.workspace-switcher')->render()
|
fn () => view('filament.partials.workspace-switcher')->render()
|
||||||
|
|||||||
@ -135,9 +135,28 @@ public function compare(
|
|||||||
|
|
||||||
$canPersist = $persist;
|
$canPersist = $persist;
|
||||||
|
|
||||||
if ($liveCheckMeta['attempted'] === true && $liveCheckMeta['succeeded'] === false) {
|
if ($canPersist && $liveCheckMeta['attempted'] === true && $liveCheckMeta['succeeded'] === false) {
|
||||||
// Enterprise-safe: never overwrite stored inventory when we could not refresh it.
|
// Enterprise-safe: never overwrite stored inventory when we could not refresh it.
|
||||||
|
// When the failure is a deterministic misconfiguration (e.g. permission denied), persist an "error" snapshot
|
||||||
|
// only if we have no stored inventory yet, so the UI can explain the failure.
|
||||||
|
$reasonCode = is_string($liveCheckMeta['reason_code'] ?? null)
|
||||||
|
? (string) $liveCheckMeta['reason_code']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$shouldPersistErrorSnapshot = in_array($reasonCode, [
|
||||||
|
'authentication_failed',
|
||||||
|
'permission_denied',
|
||||||
|
], true);
|
||||||
|
|
||||||
|
if (! $shouldPersistErrorSnapshot) {
|
||||||
$canPersist = false;
|
$canPersist = false;
|
||||||
|
} else {
|
||||||
|
$hasStoredStatuses = TenantPermission::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
$canPersist = ! $hasStoredStatuses;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($required as $permission) {
|
foreach ($required as $permission) {
|
||||||
|
|||||||
@ -6,7 +6,9 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -27,6 +29,14 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
|
|
||||||
$path = '/'.ltrim($request->path(), '/');
|
$path = '/'.ltrim($request->path(), '/');
|
||||||
|
|
||||||
|
$workspaceContext = app(WorkspaceContext::class);
|
||||||
|
$workspaceId = $workspaceContext->currentWorkspaceId($request);
|
||||||
|
|
||||||
|
$existingTenant = Filament::getTenant();
|
||||||
|
if ($existingTenant instanceof Tenant && $workspaceId !== null && (int) $existingTenant->workspace_id !== (int) $workspaceId) {
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
if ($path === '/livewire/update') {
|
if ($path === '/livewire/update') {
|
||||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
||||||
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
||||||
@ -44,6 +54,12 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($path === '/admin/operations') {
|
||||||
|
$this->configureNavigationForRequest($panel);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
if ($request->route()?->hasParameter('tenant')) {
|
if ($request->route()?->hasParameter('tenant')) {
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
@ -66,9 +82,6 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$workspaceContext = app(WorkspaceContext::class);
|
|
||||||
$workspaceId = $workspaceContext->currentWorkspaceId($request);
|
|
||||||
|
|
||||||
if ($workspaceId === null) {
|
if ($workspaceId === null) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
@ -92,6 +105,9 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
}
|
}
|
||||||
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
|
||||||
|
|
||||||
$this->configureNavigationForRequest($panel);
|
$this->configureNavigationForRequest($panel);
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
@ -100,7 +116,8 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
if (
|
if (
|
||||||
str_starts_with($path, '/admin/w/')
|
str_starts_with($path, '/admin/w/')
|
||||||
|| str_starts_with($path, '/admin/workspaces')
|
|| str_starts_with($path, '/admin/workspaces')
|
||||||
|| in_array($path, ['/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access'], true)
|
|| str_starts_with($path, '/admin/operations')
|
||||||
|
|| in_array($path, ['/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding'], true)
|
||||||
) {
|
) {
|
||||||
$this->configureNavigationForRequest($panel);
|
$this->configureNavigationForRequest($panel);
|
||||||
|
|
||||||
@ -121,60 +138,6 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = null;
|
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
|
|
||||||
|
|
||||||
if ($workspaceId !== null) {
|
|
||||||
$tenant = $user->tenants()
|
|
||||||
->where('workspace_id', $workspaceId)
|
|
||||||
->where('status', 'active')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
$tenant = $user->tenants()
|
|
||||||
->where('workspace_id', $workspaceId)
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
$tenant = $user->tenants()
|
|
||||||
->withTrashed()
|
|
||||||
->where('workspace_id', $workspaceId)
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
try {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
} catch (\RuntimeException) {
|
|
||||||
$tenant = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant && ! app(CapabilityResolver::class)->isMember($user, $tenant)) {
|
|
||||||
$tenant = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
$tenant = $user->tenants()
|
|
||||||
->where('status', 'active')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
$tenant = $user->tenants()->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
$tenant = $user->tenants()->withTrashed()->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenant) {
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->configureNavigationForRequest($panel);
|
$this->configureNavigationForRequest($panel);
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
@ -195,11 +158,53 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void
|
|||||||
$panel->navigation(function (): NavigationBuilder {
|
$panel->navigation(function (): NavigationBuilder {
|
||||||
return app(NavigationBuilder::class)
|
return app(NavigationBuilder::class)
|
||||||
->item(
|
->item(
|
||||||
NavigationItem::make('Workspaces')
|
NavigationItem::make('Switch workspace')
|
||||||
->url(fn (): string => ChooseWorkspace::getUrl())
|
->url(fn (): string => ChooseWorkspace::getUrl())
|
||||||
->icon('heroicon-o-squares-2x2')
|
->icon('heroicon-o-squares-2x2')
|
||||||
->group('Settings')
|
->group('Settings')
|
||||||
->sort(10),
|
->sort(10),
|
||||||
|
)
|
||||||
|
->item(
|
||||||
|
NavigationItem::make('Manage workspaces')
|
||||||
|
->url(fn (): string => route('filament.admin.resources.workspaces.index'))
|
||||||
|
->icon('heroicon-o-squares-2x2')
|
||||||
|
->group('Settings')
|
||||||
|
->sort(20)
|
||||||
|
->visible(function (): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||||
|
|
||||||
|
return WorkspaceMembership::query()
|
||||||
|
->where('user_id', (int) $user->getKey())
|
||||||
|
->whereIn('role', $roles)
|
||||||
|
->exists();
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
->item(
|
||||||
|
NavigationItem::make('Operations')
|
||||||
|
->url(fn (): string => route('admin.operations.index'))
|
||||||
|
->icon('heroicon-o-queue-list')
|
||||||
|
->group('Monitoring')
|
||||||
|
->sort(10),
|
||||||
|
)
|
||||||
|
->item(
|
||||||
|
NavigationItem::make('Alerts')
|
||||||
|
->url(fn (): string => '/admin/alerts')
|
||||||
|
->icon('heroicon-o-bell-alert')
|
||||||
|
->group('Monitoring')
|
||||||
|
->sort(20),
|
||||||
|
)
|
||||||
|
->item(
|
||||||
|
NavigationItem::make('Audit Log')
|
||||||
|
->url(fn (): string => '/admin/audit-log')
|
||||||
|
->icon('heroicon-o-clipboard-document-list')
|
||||||
|
->group('Monitoring')
|
||||||
|
->sort(30),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
use App\Filament\Resources\BackupScheduleResource;
|
use App\Filament\Resources\BackupScheduleResource;
|
||||||
use App\Filament\Resources\BackupSetResource;
|
use App\Filament\Resources\BackupSetResource;
|
||||||
use App\Filament\Resources\EntraGroupResource;
|
use App\Filament\Resources\EntraGroupResource;
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
@ -18,7 +17,7 @@ final class OperationRunLinks
|
|||||||
{
|
{
|
||||||
public static function index(Tenant $tenant): string
|
public static function index(Tenant $tenant): string
|
||||||
{
|
{
|
||||||
return OperationRunResource::getUrl('index', tenant: $tenant);
|
return route('admin.operations.index');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function tenantlessView(OperationRun|int $run): string
|
public static function tenantlessView(OperationRun|int $run): string
|
||||||
@ -30,7 +29,7 @@ public static function tenantlessView(OperationRun|int $run): string
|
|||||||
|
|
||||||
public static function view(OperationRun|int $run, Tenant $tenant): string
|
public static function view(OperationRun|int $run, Tenant $tenant): string
|
||||||
{
|
{
|
||||||
return OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant);
|
return self::tenantlessView($run);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -11,6 +11,10 @@ final class WorkspaceContext
|
|||||||
{
|
{
|
||||||
public const SESSION_KEY = 'current_workspace_id';
|
public const SESSION_KEY = 'current_workspace_id';
|
||||||
|
|
||||||
|
public const INTENDED_URL_SESSION_KEY = 'workspace_intended_url';
|
||||||
|
|
||||||
|
public const LAST_TENANT_IDS_SESSION_KEY = 'workspace_last_tenant_ids';
|
||||||
|
|
||||||
public function __construct(private WorkspaceResolver $resolver) {}
|
public function __construct(private WorkspaceResolver $resolver) {}
|
||||||
|
|
||||||
public function currentWorkspaceId(?Request $request = null): ?int
|
public function currentWorkspaceId(?Request $request = null): ?int
|
||||||
@ -53,6 +57,54 @@ public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function rememberLastTenantId(int $workspaceId, int $tenantId, ?Request $request = null): void
|
||||||
|
{
|
||||||
|
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
||||||
|
|
||||||
|
$map = $session->get(self::LAST_TENANT_IDS_SESSION_KEY, []);
|
||||||
|
$map = is_array($map) ? $map : [];
|
||||||
|
|
||||||
|
$map[(string) $workspaceId] = $tenantId;
|
||||||
|
|
||||||
|
$session->put(self::LAST_TENANT_IDS_SESSION_KEY, $map);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lastTenantId(?Request $request = null): ?int
|
||||||
|
{
|
||||||
|
$workspaceId = $this->currentWorkspaceId($request);
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
||||||
|
|
||||||
|
$map = $session->get(self::LAST_TENANT_IDS_SESSION_KEY, []);
|
||||||
|
$map = is_array($map) ? $map : [];
|
||||||
|
|
||||||
|
$id = $map[(string) $workspaceId] ?? null;
|
||||||
|
|
||||||
|
return is_int($id) ? $id : (is_numeric($id) ? (int) $id : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearLastTenantId(?Request $request = null): void
|
||||||
|
{
|
||||||
|
$workspaceId = $this->currentWorkspaceId($request);
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
||||||
|
|
||||||
|
$map = $session->get(self::LAST_TENANT_IDS_SESSION_KEY, []);
|
||||||
|
$map = is_array($map) ? $map : [];
|
||||||
|
|
||||||
|
unset($map[(string) $workspaceId]);
|
||||||
|
|
||||||
|
$session->put(self::LAST_TENANT_IDS_SESSION_KEY, $map);
|
||||||
|
}
|
||||||
|
|
||||||
public function clearCurrentWorkspace(?User $user = null, ?Request $request = null): void
|
public function clearCurrentWorkspace(?User $user = null, ?Request $request = null): void
|
||||||
{
|
{
|
||||||
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
||||||
|
|||||||
126
app/Support/Workspaces/WorkspaceIntendedUrl.php
Normal file
126
app/Support/Workspaces/WorkspaceIntendedUrl.php
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Workspaces;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Session\Store;
|
||||||
|
|
||||||
|
final class WorkspaceIntendedUrl
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Store a safe intended URL (path + query) for returning after workspace selection.
|
||||||
|
*/
|
||||||
|
public static function store(string $pathWithQuery, ?Request $request = null): void
|
||||||
|
{
|
||||||
|
$pathWithQuery = trim($pathWithQuery);
|
||||||
|
|
||||||
|
if ($pathWithQuery === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! self::isAllowed($pathWithQuery)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = self::session($request);
|
||||||
|
|
||||||
|
if (! $session instanceof Store) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->put(WorkspaceContext::INTENDED_URL_SESSION_KEY, $pathWithQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the intended URL derived from the current request.
|
||||||
|
*/
|
||||||
|
public static function storeFromRequest(Request $request): void
|
||||||
|
{
|
||||||
|
if (! $request->isMethod('GET')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = '/'.ltrim($request->path(), '/');
|
||||||
|
|
||||||
|
$queryString = $request->getQueryString();
|
||||||
|
$pathWithQuery = $queryString ? "{$path}?{$queryString}" : $path;
|
||||||
|
|
||||||
|
self::store($pathWithQuery, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume (read + forget) the intended URL. Returns null if missing or unsafe.
|
||||||
|
*/
|
||||||
|
public static function consume(?Request $request = null): ?string
|
||||||
|
{
|
||||||
|
$session = self::session($request);
|
||||||
|
|
||||||
|
if (! $session instanceof Store) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $session->pull(WorkspaceContext::INTENDED_URL_SESSION_KEY);
|
||||||
|
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
if ($value === '' || ! self::isAllowed($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function clear(?Request $request = null): void
|
||||||
|
{
|
||||||
|
$session = self::session($request);
|
||||||
|
|
||||||
|
if (! $session instanceof Store) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->forget(WorkspaceContext::INTENDED_URL_SESSION_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function session(?Request $request = null): ?Store
|
||||||
|
{
|
||||||
|
$session = ($request && $request->hasSession())
|
||||||
|
? $request->session()
|
||||||
|
: session()->driver();
|
||||||
|
|
||||||
|
return $session instanceof Store ? $session : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isAllowed(string $pathWithQuery): bool
|
||||||
|
{
|
||||||
|
if (str_contains($pathWithQuery, "\n") || str_contains($pathWithQuery, "\r")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^https?://#i', $pathWithQuery) === 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($pathWithQuery, '//')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_starts_with($pathWithQuery, '/admin')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = parse_url($pathWithQuery, PHP_URL_PATH);
|
||||||
|
$path = '/'.ltrim((string) ($path ?? ''), '/');
|
||||||
|
|
||||||
|
if (in_array($path, ['/admin/choose-workspace', '/admin/no-access'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Alerts is reserved for future work.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
@ -1,3 +1,36 @@
|
|||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
|
<x-filament::tabs label="Operations tabs">
|
||||||
|
<x-filament::tabs.item
|
||||||
|
:active="$this->activeTab === 'all'"
|
||||||
|
wire:click="$set('activeTab', 'all')"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</x-filament::tabs.item>
|
||||||
|
<x-filament::tabs.item
|
||||||
|
:active="$this->activeTab === 'active'"
|
||||||
|
wire:click="$set('activeTab', 'active')"
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</x-filament::tabs.item>
|
||||||
|
<x-filament::tabs.item
|
||||||
|
:active="$this->activeTab === 'succeeded'"
|
||||||
|
wire:click="$set('activeTab', 'succeeded')"
|
||||||
|
>
|
||||||
|
Succeeded
|
||||||
|
</x-filament::tabs.item>
|
||||||
|
<x-filament::tabs.item
|
||||||
|
:active="$this->activeTab === 'partial'"
|
||||||
|
wire:click="$set('activeTab', 'partial')"
|
||||||
|
>
|
||||||
|
Partial
|
||||||
|
</x-filament::tabs.item>
|
||||||
|
<x-filament::tabs.item
|
||||||
|
:active="$this->activeTab === 'failed'"
|
||||||
|
wire:click="$set('activeTab', 'failed')"
|
||||||
|
>
|
||||||
|
Failed
|
||||||
|
</x-filament::tabs.item>
|
||||||
|
</x-filament::tabs>
|
||||||
|
|
||||||
{{ $this->table }}
|
{{ $this->table }}
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|||||||
102
resources/views/filament/partials/context-bar.blade.php
Normal file
102
resources/views/filament/partials/context-bar.blade.php
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
@php
|
||||||
|
use App\Filament\Pages\ChooseWorkspace;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
|
||||||
|
/** @var WorkspaceContext $workspaceContext */
|
||||||
|
$workspaceContext = app(WorkspaceContext::class);
|
||||||
|
|
||||||
|
$workspace = $workspaceContext->currentWorkspace(request());
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
$tenants = collect();
|
||||||
|
if ($user instanceof User) {
|
||||||
|
$tenants = collect($user->getTenants(Filament::getCurrentOrDefaultPanel()));
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentTenant = Filament::getTenant();
|
||||||
|
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
|
||||||
|
|
||||||
|
$lastTenantId = $workspaceContext->lastTenantId(request());
|
||||||
|
$canClearTenantContext = $currentTenantName !== null || $lastTenantId !== null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href="{{ ChooseWorkspace::getUrl() }}"
|
||||||
|
class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
|
||||||
|
>
|
||||||
|
<span class="font-medium">Workspace:</span>
|
||||||
|
<span>{{ $workspace?->name ?? '—' }}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700"></div>
|
||||||
|
|
||||||
|
<x-filament::dropdown placement="bottom-start" teleport>
|
||||||
|
<x-slot name="trigger">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
|
||||||
|
>
|
||||||
|
<span class="font-medium">Tenant:</span>
|
||||||
|
<span>{{ $currentTenantName ?? '—' }}</span>
|
||||||
|
</button>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<x-filament::dropdown.list>
|
||||||
|
<div class="px-3 py-2 space-y-2" x-data="{ query: '' }">
|
||||||
|
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Tenant context</div>
|
||||||
|
|
||||||
|
@if (! $workspace)
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Choose a workspace first.</div>
|
||||||
|
@elseif ($tenants->isEmpty())
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">No tenants you can access in this workspace.</div>
|
||||||
|
@else
|
||||||
|
<div class="space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="fi-input fi-text-input w-full"
|
||||||
|
placeholder="Select tenant…"
|
||||||
|
x-model="query"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="max-h-64 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
@foreach ($tenants as $tenant)
|
||||||
|
<form method="POST" action="{{ route('admin.select-tenant') }}">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="tenant_id" value="{{ (int) $tenant->getKey() }}" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
|
data-search="{{ (string) str($tenant->getFilamentName())->lower() }}"
|
||||||
|
x-show="query === '' || ($el.dataset.search ?? '').includes(query.toLowerCase())"
|
||||||
|
>
|
||||||
|
{{ $tenant->getFilamentName() }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($canClearTenantContext)
|
||||||
|
<form method="POST" action="{{ route('admin.clear-tenant-context') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<x-filament::button color="gray" size="sm" outlined>
|
||||||
|
Clear tenant context
|
||||||
|
</x-filament::button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Switching tenants is explicit. Canonical monitoring URLs do not change tenant context.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-filament::dropdown.list>
|
||||||
|
</x-filament::dropdown>
|
||||||
|
</div>
|
||||||
@ -25,7 +25,7 @@
|
|||||||
<form method="POST" action="{{ route('admin.switch-workspace') }}" class="space-y-2">
|
<form method="POST" action="{{ route('admin.switch-workspace') }}" class="space-y-2">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Workspace</div>
|
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Switch workspace</div>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
name="workspace_id"
|
name="workspace_id"
|
||||||
@ -40,7 +40,7 @@ class="fi-input fi-select w-full"
|
|||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">Switch workspace</div>
|
<div class="text-xs text-gray-500 dark:text-gray-400">Select a workspace to switch context.</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</x-filament::dropdown.list>
|
</x-filament::dropdown.list>
|
||||||
|
|||||||
@ -0,0 +1,55 @@
|
|||||||
|
@php
|
||||||
|
/** @var ?\App\Models\Tenant $tenant */
|
||||||
|
/** @var \Illuminate\Support\Collection<int, \App\Models\OperationRun> $runs */
|
||||||
|
/** @var string $operationsIndexUrl */
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="text-sm font-semibold">Recent operations</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="{{ $operationsIndexUrl }}"
|
||||||
|
class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||||
|
>
|
||||||
|
View all operations
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($runs->isEmpty())
|
||||||
|
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No operations yet.
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<ul class="mt-3 divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
@foreach ($runs as $run)
|
||||||
|
<li class="flex items-center justify-between gap-3 py-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="truncate text-sm font-medium">
|
||||||
|
{{ \App\Support\OperationCatalog::label((string) $run->type) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $run->created_at?->diffForHumans() ?? '—' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 items-center gap-3">
|
||||||
|
<div class="text-right text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<div>{{ (string) $run->status }}</div>
|
||||||
|
<div>{{ (string) $run->outcome }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="{{ \App\Support\OperationRunLinks::tenantlessView($run) }}"
|
||||||
|
class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
@ -3,6 +3,7 @@
|
|||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Http\Controllers\AdminConsentCallbackController;
|
use App\Http\Controllers\AdminConsentCallbackController;
|
||||||
use App\Http\Controllers\Auth\EntraController;
|
use App\Http\Controllers\Auth\EntraController;
|
||||||
|
use App\Http\Controllers\ClearTenantContextController;
|
||||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||||
use App\Http\Controllers\SelectTenantController;
|
use App\Http\Controllers\SelectTenantController;
|
||||||
use App\Http\Controllers\SwitchWorkspaceController;
|
use App\Http\Controllers\SwitchWorkspaceController;
|
||||||
@ -101,6 +102,10 @@
|
|||||||
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
|
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
|
||||||
->post('/admin/select-tenant', SelectTenantController::class)
|
->post('/admin/select-tenant', SelectTenantController::class)
|
||||||
->name('admin.select-tenant');
|
->name('admin.select-tenant');
|
||||||
|
|
||||||
|
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
|
||||||
|
->post('/admin/clear-tenant-context', ClearTenantContextController::class)
|
||||||
|
->name('admin.clear-tenant-context');
|
||||||
Route::bind('workspace', function (string $value): Workspace {
|
Route::bind('workspace', function (string $value): Workspace {
|
||||||
/** @var WorkspaceResolver $resolver */
|
/** @var WorkspaceResolver $resolver */
|
||||||
$resolver = app(WorkspaceResolver::class);
|
$resolver = app(WorkspaceResolver::class);
|
||||||
@ -132,6 +137,48 @@
|
|||||||
->get('/admin/onboarding', \App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class)
|
->get('/admin/onboarding', \App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class)
|
||||||
->name('admin.onboarding');
|
->name('admin.onboarding');
|
||||||
|
|
||||||
|
Route::middleware([
|
||||||
|
'web',
|
||||||
|
'panel:admin',
|
||||||
|
'ensure-correct-guard:web',
|
||||||
|
DenyNonMemberTenantAccess::class,
|
||||||
|
DisableBladeIconComponents::class,
|
||||||
|
DispatchServingFilamentEvent::class,
|
||||||
|
FilamentAuthenticate::class,
|
||||||
|
'ensure-workspace-selected',
|
||||||
|
'ensure-filament-tenant-selected',
|
||||||
|
])
|
||||||
|
->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class)
|
||||||
|
->name('admin.operations.index');
|
||||||
|
|
||||||
|
Route::middleware([
|
||||||
|
'web',
|
||||||
|
'panel:admin',
|
||||||
|
'ensure-correct-guard:web',
|
||||||
|
DenyNonMemberTenantAccess::class,
|
||||||
|
DisableBladeIconComponents::class,
|
||||||
|
DispatchServingFilamentEvent::class,
|
||||||
|
FilamentAuthenticate::class,
|
||||||
|
'ensure-workspace-selected',
|
||||||
|
'ensure-filament-tenant-selected',
|
||||||
|
])
|
||||||
|
->get('/admin/alerts', \App\Filament\Pages\Monitoring\Alerts::class)
|
||||||
|
->name('admin.monitoring.alerts');
|
||||||
|
|
||||||
|
Route::middleware([
|
||||||
|
'web',
|
||||||
|
'panel:admin',
|
||||||
|
'ensure-correct-guard:web',
|
||||||
|
DenyNonMemberTenantAccess::class,
|
||||||
|
DisableBladeIconComponents::class,
|
||||||
|
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');
|
||||||
|
|
||||||
Route::middleware([
|
Route::middleware([
|
||||||
'web',
|
'web',
|
||||||
'panel:admin',
|
'panel:admin',
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Workspace-first Navigation & Monitoring Hub
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-02-06
|
||||||
|
**Feature**: [specs/077-workspace-nav-monitoring-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
|
||||||
|
|
||||||
|
- Validation pass on first iteration.
|
||||||
|
- URLs are treated as product behavior (not implementation details).
|
||||||
67
specs/077-workspace-nav-monitoring-hub/contracts/routes.md
Normal file
67
specs/077-workspace-nav-monitoring-hub/contracts/routes.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# Contracts — Routes & Semantics (077)
|
||||||
|
|
||||||
|
**Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](../spec.md)
|
||||||
|
|
||||||
|
This feature is an admin UI/navigation refactor. Contracts are expressed as web route semantics + access rules.
|
||||||
|
|
||||||
|
## Canonical routes
|
||||||
|
|
||||||
|
### Workspace context
|
||||||
|
|
||||||
|
- `GET /admin/choose-workspace`
|
||||||
|
- Purpose: select active workspace context
|
||||||
|
- Access: authenticated user
|
||||||
|
- Visibility: shows only workspaces where the user is a member
|
||||||
|
|
||||||
|
- `POST /admin/switch-workspace`
|
||||||
|
- Purpose: update workspace context
|
||||||
|
- Access: authenticated user
|
||||||
|
- Security:
|
||||||
|
- If user is not a member of the selected workspace → 404 (deny-as-not-found)
|
||||||
|
|
||||||
|
### Workspace management (CRUD)
|
||||||
|
|
||||||
|
- `GET /admin/workspaces`
|
||||||
|
- `GET /admin/workspaces/{workspace}`
|
||||||
|
- `GET /admin/workspaces/{workspace}/edit`
|
||||||
|
- `GET /admin/workspaces/create`
|
||||||
|
|
||||||
|
Contract semantics:
|
||||||
|
|
||||||
|
- Index lists only workspaces the user is a member of.
|
||||||
|
- If user attempts to access a workspace record they are not a member of → 404 (deny-as-not-found)
|
||||||
|
- If user is a member but lacks the required capability for a protected action/screen (create/edit/membership management) → 403
|
||||||
|
- If user is authorized → normal Filament behavior
|
||||||
|
|
||||||
|
### Monitoring hub — Operations
|
||||||
|
|
||||||
|
- `GET /admin/operations`
|
||||||
|
- Canonical operations index (tenantless URL)
|
||||||
|
- Behavior:
|
||||||
|
- If tenant context is active: default filter state = current tenant (removable)
|
||||||
|
- If tenant context is not active: workspace-wide list
|
||||||
|
|
||||||
|
- `GET /admin/operations/{run}`
|
||||||
|
- Canonical run deep link
|
||||||
|
- Security:
|
||||||
|
- If run belongs to a workspace the user is not a member of → 404
|
||||||
|
|
||||||
|
### Monitoring hub — Reserved surfaces (placeholders)
|
||||||
|
|
||||||
|
- `GET /admin/alerts`
|
||||||
|
- Reserved placeholder page
|
||||||
|
- Access: workspace members (workspace context required)
|
||||||
|
|
||||||
|
- `GET /admin/audit-log`
|
||||||
|
- Reserved placeholder page
|
||||||
|
- Access: workspace members (workspace context required)
|
||||||
|
|
||||||
|
## Status code rules (summary)
|
||||||
|
|
||||||
|
- Non-member / not entitled to the workspace scope → 404
|
||||||
|
- Member but missing capability (workspace-scoped protected actions) → 403
|
||||||
|
|
||||||
|
## Non-leakage requirements
|
||||||
|
|
||||||
|
- Global search must not list inaccessible workspaces/tenants/runs.
|
||||||
|
- Navigation labels and groups must not imply the existence of admin-only surfaces.
|
||||||
66
specs/077-workspace-nav-monitoring-hub/data-model.md
Normal file
66
specs/077-workspace-nav-monitoring-hub/data-model.md
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# Data Model — Workspace-first Navigation & Monitoring Hub (077)
|
||||||
|
|
||||||
|
**Date**: 2026-02-06
|
||||||
|
**Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
|
||||||
|
|
||||||
|
This feature is primarily information architecture + context enforcement. No new tables are required; the design depends on existing entities and their relationships.
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### Workspace
|
||||||
|
|
||||||
|
Represents a portfolio / customer container (primary context).
|
||||||
|
|
||||||
|
- Key fields (existing, relevant):
|
||||||
|
- `id`
|
||||||
|
- `name`
|
||||||
|
- `slug` (optional)
|
||||||
|
- `archived_at` (nullable)
|
||||||
|
|
||||||
|
### WorkspaceMembership
|
||||||
|
|
||||||
|
Entitlement relationship between a user and a workspace.
|
||||||
|
|
||||||
|
- Key fields (existing, relevant):
|
||||||
|
- `workspace_id`
|
||||||
|
- `user_id`
|
||||||
|
- `role` (e.g. owner/operator/etc; actual role semantics are managed by the capability resolver)
|
||||||
|
|
||||||
|
### Tenant (Managed Tenant)
|
||||||
|
|
||||||
|
Represents a Microsoft/Intune tenant belonging to a workspace (secondary context via Filament tenancy).
|
||||||
|
|
||||||
|
- Key fields (existing, relevant):
|
||||||
|
- `id`
|
||||||
|
- `workspace_id` (foreign key to Workspace)
|
||||||
|
- `external_id` (used in Filament tenancy route `/admin/t/{tenant}`)
|
||||||
|
- `status` (e.g., active)
|
||||||
|
|
||||||
|
### OperationRun
|
||||||
|
|
||||||
|
Canonical monitoring record (workspace-level entity; may optionally be linked to a tenant).
|
||||||
|
|
||||||
|
- Key fields (existing, relevant):
|
||||||
|
- `id`
|
||||||
|
- `workspace_id` (required for access control)
|
||||||
|
- `tenant_id` (nullable; used for default filtering and “recent operations”)
|
||||||
|
- `type`, `status`, `outcome`
|
||||||
|
- timestamps (created/started/completed)
|
||||||
|
- `context` (JSON)
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- Workspace has many WorkspaceMemberships.
|
||||||
|
- Workspace has many Tenants.
|
||||||
|
- Workspace has many OperationRuns.
|
||||||
|
- Tenant belongs to Workspace.
|
||||||
|
- OperationRun belongs to Workspace.
|
||||||
|
- OperationRun optionally belongs to Tenant.
|
||||||
|
|
||||||
|
## Invariants / Rules enforced by this feature
|
||||||
|
|
||||||
|
- Workspace context (`current_workspace_id`) is required for workspace-scoped navigation and access control.
|
||||||
|
- Tenant context must be consistent with workspace context:
|
||||||
|
- If tenant is not in current workspace, tenant context is cleared (continue tenantless).
|
||||||
|
- OperationRun access is controlled by membership in the run’s `workspace_id`.
|
||||||
|
|
||||||
219
specs/077-workspace-nav-monitoring-hub/plan.md
Normal file
219
specs/077-workspace-nav-monitoring-hub/plan.md
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
# Implementation Plan: Workspace-first Navigation & Monitoring Hub
|
||||||
|
|
||||||
|
**Branch**: `077-workspace-nav-monitoring-hub` | **Date**: 2026-02-06 | **Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Resolve workspace navigation ambiguity and formalize a workspace-first context model:
|
||||||
|
|
||||||
|
- Unambiguous labels: **Switch workspace** (`/admin/choose-workspace`) vs **Manage workspaces** (`/admin/workspaces`).
|
||||||
|
- Monitoring → **Operations** remains canonical and tenantless (`/admin/operations`, `/admin/operations/{run}`).
|
||||||
|
- Tenant context influences Operations only via **server-side default filter state** (removable), never via routing.
|
||||||
|
- Strict non-leaking security semantics:
|
||||||
|
- Non-member workspace scope → 404 (deny-as-not-found)
|
||||||
|
- Workspace member missing capability (protected actions/screens) → 403
|
||||||
|
- Accessing a workspace record outside membership → 404 (deny-as-not-found)
|
||||||
|
|
||||||
|
Supporting artifacts:
|
||||||
|
|
||||||
|
- [research.md](research.md)
|
||||||
|
- [data-model.md](data-model.md)
|
||||||
|
- [contracts/routes.md](contracts/routes.md)
|
||||||
|
- [quickstart.md](quickstart.md)
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.x
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
|
||||||
|
**Storage**: PostgreSQL (Sail)
|
||||||
|
**Testing**: Pest v4
|
||||||
|
**Target Platform**: Web (Filament admin panels)
|
||||||
|
**Project Type**: Laravel monolith
|
||||||
|
**Performance Goals**: Operations pages remain DB-only at render; list/detail stay fast on large run tables (pagination + indexed filters)
|
||||||
|
**Constraints**: Filament-native patterns only; canonical URLs must not depend on tenant context; strict 404/403 non-leakage semantics
|
||||||
|
**Scale/Scope**: Multi-workspace MSP use; many tenants and many operation runs
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- Inventory-first: N/A (no inventory semantics changes)
|
||||||
|
- Read/write separation: PASS (no write operations introduced)
|
||||||
|
- Graph contract path: N/A (no Graph calls)
|
||||||
|
- Deterministic capabilities: PASS (capability gating uses existing resolver/registry patterns)
|
||||||
|
- RBAC-UX: PASS (explicit 404 vs 403 rules)
|
||||||
|
- RBAC-UX destructive confirmation: N/A (no destructive actions introduced)
|
||||||
|
- RBAC-UX global search: N/A (no new searchable resources; no changes to global search)
|
||||||
|
- Tenant isolation: PASS (workspace membership is isolation boundary; tenant context auto-cleared when invalid)
|
||||||
|
- Run observability: N/A (no new operations/jobs)
|
||||||
|
- Automation: N/A
|
||||||
|
- Data minimization: N/A
|
||||||
|
- Badge semantics (BADGE-001): N/A
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/077-workspace-nav-monitoring-hub/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── routes.md
|
||||||
|
└── checklists/
|
||||||
|
└── requirements.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ └── ChooseWorkspace.php
|
||||||
|
│ └── Resources/
|
||||||
|
│ ├── OperationRunResource.php
|
||||||
|
│ └── OperationRunResource/
|
||||||
|
│ └── Pages/
|
||||||
|
│ └── ListOperationRuns.php
|
||||||
|
├── Http/
|
||||||
|
│ └── Middleware/
|
||||||
|
│ └── EnsureWorkspaceSelected.php
|
||||||
|
├── Providers/
|
||||||
|
│ └── Filament/
|
||||||
|
│ └── AdminPanelProvider.php
|
||||||
|
└── Support/
|
||||||
|
└── Middleware/
|
||||||
|
└── EnsureFilamentTenantSelected.php
|
||||||
|
|
||||||
|
resources/
|
||||||
|
└── views/
|
||||||
|
└── filament/
|
||||||
|
└── partials/
|
||||||
|
└── workspace-switcher.blade.php
|
||||||
|
|
||||||
|
routes/
|
||||||
|
└── web.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
└── Feature/
|
||||||
|
└── (new tests for navigation labels + 404/403 + operations default filter)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Laravel monolith using Filament resources/pages and Laravel middleware.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations.
|
||||||
|
|
||||||
|
## Phase 0 — Outline & Research (complete)
|
||||||
|
|
||||||
|
All unknowns/decisions have been resolved and recorded:
|
||||||
|
|
||||||
|
- Repo reality + ambiguity sources + decisions D1–D4: [research.md](research.md)
|
||||||
|
- No remaining NEEDS CLARIFICATION items in the spec.
|
||||||
|
|
||||||
|
## Phase 1 — Design & Contracts (complete)
|
||||||
|
|
||||||
|
- Data model: no new tables/columns required; behavior is implemented via middleware + Filament config: [data-model.md](data-model.md)
|
||||||
|
- Route/security contracts: [contracts/routes.md](contracts/routes.md)
|
||||||
|
- Manual validation steps + suggested test filters: [quickstart.md](quickstart.md)
|
||||||
|
|
||||||
|
Agent context update:
|
||||||
|
|
||||||
|
- Re-run `.specify/scripts/bash/update-agent-context.sh copilot` after finalizing this plan file (the earlier run happened while this file contained placeholders).
|
||||||
|
|
||||||
|
## Phase 2 — Implementation Plan (ready for tasks)
|
||||||
|
|
||||||
|
### Step 1 — Navigation labels: “one label, one meaning”
|
||||||
|
|
||||||
|
- Update admin navigation to include:
|
||||||
|
- **Switch workspace** → `/admin/choose-workspace`
|
||||||
|
- **Manage workspaces** → `/admin/workspaces`
|
||||||
|
- Remove/replace any navigation items labeled only “Workspaces”.
|
||||||
|
|
||||||
|
Implementation targets:
|
||||||
|
|
||||||
|
- Update [app/Support/Middleware/EnsureFilamentTenantSelected.php](../../app/Support/Middleware/EnsureFilamentTenantSelected.php) navigation builder:
|
||||||
|
- Change the label from `Workspaces` to `Switch workspace` for the choose-workspace link.
|
||||||
|
- Ensure this fallback navigation does not accidentally imply CRUD management.
|
||||||
|
- Update [app/Providers/Filament/AdminPanelProvider.php](../../app/Providers/Filament/AdminPanelProvider.php) nav item label for workspace CRUD to `Manage workspaces`.
|
||||||
|
- Update [resources/views/filament/partials/workspace-switcher.blade.php](../../resources/views/filament/partials/workspace-switcher.blade.php) text/links to consistently say “Switch workspace”.
|
||||||
|
- Add reserved Monitoring navigation surfaces for **Alerts** and **Audit Log** as placeholder pages (non-functional “coming soon”) to satisfy FR-011.
|
||||||
|
|
||||||
|
### Step 2 — Enforce workspace-scoped RBAC semantics for `/admin/workspaces`
|
||||||
|
|
||||||
|
- `/admin/workspaces` stays tenantless and workspace-scoped.
|
||||||
|
- Enforce strict non-leakage semantics:
|
||||||
|
- Non-member attempting to access a workspace record → **404** (deny-as-not-found)
|
||||||
|
- Member missing required capability for protected actions/screens → **403**
|
||||||
|
|
||||||
|
Implementation targets:
|
||||||
|
|
||||||
|
- Scope the Workspaces query (index) to only workspaces the user is a member of.
|
||||||
|
- Ensure `WorkspacePolicy` returns 404 semantics for non-members (record access).
|
||||||
|
- Gate create/edit/membership-management behind canonical workspace capabilities (no raw strings).
|
||||||
|
- Hide “Manage workspaces” navigation unless the user can manage something workspace-admin related (capability-based).
|
||||||
|
|
||||||
|
### Step 3 — Workspace selection redirect + return-to-intended
|
||||||
|
|
||||||
|
Requirement: visiting any workspace-scoped page without a selected workspace MUST redirect to `/admin/choose-workspace` and then return to the originally requested URL.
|
||||||
|
|
||||||
|
Implementation targets:
|
||||||
|
|
||||||
|
- Update [app/Http/Middleware/EnsureWorkspaceSelected.php](../../app/Http/Middleware/EnsureWorkspaceSelected.php):
|
||||||
|
- When redirecting to `/admin/choose-workspace`, store the intended URL (path + query) in session.
|
||||||
|
- Preserve the existing exemptions for auth routes and for `/admin/operations/{run}` and Livewire update referers.
|
||||||
|
- Update both workspace-selection entrypoints to honor intended URLs:
|
||||||
|
- [app/Filament/Pages/ChooseWorkspace.php](../../app/Filament/Pages/ChooseWorkspace.php)
|
||||||
|
- [app/Http/Controllers/SwitchWorkspaceController.php](../../app/Http/Controllers/SwitchWorkspaceController.php)
|
||||||
|
- After setting the workspace, redirect to the stored intended URL (if present and safe), otherwise keep the existing behavior (onboarding / choose-tenant / tenant dashboard).
|
||||||
|
|
||||||
|
### Step 4 — Auto-clear invalid tenant context on workspace change
|
||||||
|
|
||||||
|
Requirement: if tenant context is active but does not belong to the current workspace, auto-clear tenant context and continue on tenantless workspace pages.
|
||||||
|
|
||||||
|
Implementation targets:
|
||||||
|
|
||||||
|
- In [app/Support/Middleware/EnsureFilamentTenantSelected.php](../../app/Support/Middleware/EnsureFilamentTenantSelected.php) (or a dedicated middleware used for tenantless pages):
|
||||||
|
- Detect a persisted Filament tenant that does not match `WorkspaceContext::currentWorkspaceId()`.
|
||||||
|
- Clear the persisted Filament tenant context (confirm the correct Filament v5 mechanism during implementation).
|
||||||
|
|
||||||
|
### Step 5 — Operations: move tenant scoping from query to removable default filter
|
||||||
|
|
||||||
|
Requirement: `/admin/operations` stays canonical; if tenant context is active, default to that tenant using server-side default filter state with a visible removable chip.
|
||||||
|
|
||||||
|
Implementation targets:
|
||||||
|
|
||||||
|
- Update [app/Filament/Resources/OperationRunResource.php](../../app/Filament/Resources/OperationRunResource.php):
|
||||||
|
- Remove tenant-context filtering from `getEloquentQuery()`.
|
||||||
|
- Update [app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php](../../app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php):
|
||||||
|
- Add a tenant filter (select) over available tenants in the current workspace.
|
||||||
|
- Default the filter state from the current tenant context when valid.
|
||||||
|
- Ensure the filter chip is visible and can be cleared to view workspace-wide operations.
|
||||||
|
|
||||||
|
### Step 6 — Tests (Pest) + formatting
|
||||||
|
|
||||||
|
Add/adjust tests to cover the strict semantics:
|
||||||
|
|
||||||
|
- Navigation labels: “Switch workspace” vs “Manage workspaces” (no ambiguous “Workspaces”).
|
||||||
|
- `/admin/workspaces`:
|
||||||
|
- non-member record access → 404
|
||||||
|
- member missing capability for a protected action/screen → 403
|
||||||
|
- EnsureWorkspaceSelected:
|
||||||
|
- visiting `/admin/operations` without workspace → redirects to choose-workspace
|
||||||
|
- after selecting workspace → returns to intended URL
|
||||||
|
- Operations default filter:
|
||||||
|
- with tenant context active → tenant filter default set
|
||||||
|
- clearing filter → shows workspace-wide results
|
||||||
|
|
||||||
|
Tooling:
|
||||||
|
|
||||||
|
- Run `./vendor/bin/sail bin pint --dirty`.
|
||||||
|
- Run focused tests via `./vendor/bin/sail artisan test --compact --filter=...`.
|
||||||
50
specs/077-workspace-nav-monitoring-hub/quickstart.md
Normal file
50
specs/077-workspace-nav-monitoring-hub/quickstart.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Quickstart — Workspace-first Navigation & Monitoring Hub (077)
|
||||||
|
|
||||||
|
**Audience**: Devs and reviewers validating the feature on staging/local
|
||||||
|
**Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
|
||||||
|
|
||||||
|
## Local setup
|
||||||
|
|
||||||
|
- Start containers: `./vendor/bin/sail up -d`
|
||||||
|
- Install dependencies if needed: `./vendor/bin/sail composer install` and `./vendor/bin/sail npm install`
|
||||||
|
- Run migrations: `./vendor/bin/sail artisan migrate`
|
||||||
|
|
||||||
|
## Manual validation checklist
|
||||||
|
|
||||||
|
### Navigation separation
|
||||||
|
|
||||||
|
1. Open `/admin` and sign in.
|
||||||
|
2. In the user menu, confirm there is an explicit entry labeled **"Switch workspace"** that navigates to `/admin/choose-workspace`.
|
||||||
|
3. In the sidebar, confirm **"Manage workspaces"** exists only when authorized.
|
||||||
|
4. Confirm there is no navigation item labeled simply **"Workspaces"** that ambiguously points to both concepts.
|
||||||
|
|
||||||
|
### Operations canonical + default tenant filter
|
||||||
|
|
||||||
|
1. Visit `/admin/operations` with no tenant context selected.
|
||||||
|
- Expect: page loads and shows workspace-wide runs.
|
||||||
|
2. Activate tenant context (`/admin/t/{tenant}`), then navigate to `/admin/operations`.
|
||||||
|
- Expect: default tenant filter applied, visible filter chip, chip can be cleared.
|
||||||
|
3. Visit a run deep link `/admin/operations/{run}` from both tenantless and tenant context.
|
||||||
|
- Expect: same canonical page, no tenant-route dependency.
|
||||||
|
|
||||||
|
### Security semantics
|
||||||
|
|
||||||
|
- Non-member accessing operations for another workspace: expect **404**.
|
||||||
|
- Workspace member but missing capability for a protected action/screen: expect **403**.
|
||||||
|
- Accessing `/admin/workspaces` for a workspace you are not a member of: expect **404**.
|
||||||
|
|
||||||
|
## Test execution
|
||||||
|
|
||||||
|
Run focused tests:
|
||||||
|
|
||||||
|
- US1 (nav separation): `./vendor/bin/sail artisan test --compact --filter=WorkspaceNavigationHub`
|
||||||
|
- US2 (canonical ops URLs): `./vendor/bin/sail artisan test --compact --filter=OperationsCanonicalUrls`
|
||||||
|
- US3 (non-leakage): `./vendor/bin/sail artisan test --compact --filter=NonLeakageWorkspaceOperations`
|
||||||
|
|
||||||
|
Run a targeted suite for the feature area:
|
||||||
|
|
||||||
|
- `./vendor/bin/sail artisan test --compact tests/Feature/Workspaces tests/Feature/Monitoring tests/Feature/OpsUx`
|
||||||
|
|
||||||
|
Run formatting before finalizing:
|
||||||
|
|
||||||
|
- `./vendor/bin/sail pint --dirty`
|
||||||
58
specs/077-workspace-nav-monitoring-hub/research.md
Normal file
58
specs/077-workspace-nav-monitoring-hub/research.md
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# Research — Workspace-first Navigation & Monitoring Hub (077)
|
||||||
|
|
||||||
|
**Date**: 2026-02-06
|
||||||
|
**Branch**: 077-workspace-nav-monitoring-hub
|
||||||
|
**Spec**: [specs/077-workspace-nav-monitoring-hub/spec.md](spec.md)
|
||||||
|
|
||||||
|
## Repo Reality Check (what exists today)
|
||||||
|
|
||||||
|
- Admin panel exists at `/admin` via [app/Providers/Filament/AdminPanelProvider.php](../../app/Providers/Filament/AdminPanelProvider.php).
|
||||||
|
- System panel exists at `/system` with a separate auth guard (`platform`) via [app/Providers/Filament/SystemPanelProvider.php](../../app/Providers/Filament/SystemPanelProvider.php).
|
||||||
|
- Workspace context selection exists:
|
||||||
|
- Page `/admin/choose-workspace` via [app/Filament/Pages/ChooseWorkspace.php](../../app/Filament/Pages/ChooseWorkspace.php)
|
||||||
|
- POST switch endpoint `/admin/switch-workspace` via [routes/web.php](../../routes/web.php)
|
||||||
|
- Workspace switcher UI in the user menu via [resources/views/filament/partials/workspace-switcher.blade.php](../../resources/views/filament/partials/workspace-switcher.blade.php)
|
||||||
|
- Navigation ambiguity is currently real:
|
||||||
|
- When no tenant is selected, navigation is replaced with a single item labeled **"Workspaces"** linking to the choose-workspace page via [app/Support/Middleware/EnsureFilamentTenantSelected.php](../../app/Support/Middleware/EnsureFilamentTenantSelected.php).
|
||||||
|
- Separately, the sidebar includes another **"Workspaces"** item linking to `/admin/workspaces` (workspace CRUD) via [app/Providers/Filament/AdminPanelProvider.php](../../app/Providers/Filament/AdminPanelProvider.php).
|
||||||
|
- Operations is already canonical and tenantless:
|
||||||
|
- Resource slug is `/admin/operations` via [app/Filament/Resources/OperationRunResource.php](../../app/Filament/Resources/OperationRunResource.php).
|
||||||
|
- Detail page is `/admin/operations/{record}`.
|
||||||
|
|
||||||
|
## Decisions (resolved)
|
||||||
|
|
||||||
|
### D1 — Manage workspaces stays on `/admin/workspaces` and follows workspace RBAC semantics (404 for non-members, 403 for missing capability)
|
||||||
|
|
||||||
|
- Decision: Treat `/admin/workspaces` as a **workspace-scoped** management surface in the tenant plane (`/admin`, Entra users):
|
||||||
|
- Non-members (or out-of-scope workspace records) → **404** (deny-as-not-found)
|
||||||
|
- Members missing required capabilities for protected actions/screens → **403**
|
||||||
|
- Rationale: Aligns with the constitution RBAC-UX model (membership is the isolation boundary; capability denial is 403 after membership is established) while still preventing cross-workspace leakage.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Move management into `/system` panel: rejected because this feature targets the tenant plane IA. (If workspace CRUD becomes platform-admin only later, that should be handled as a separate migration spec.)
|
||||||
|
|
||||||
|
### D2 — Tenant context influences Operations via server-side default filter state, not querystring
|
||||||
|
|
||||||
|
- Decision: Apply the tenant default filter server-side while keeping the canonical URL `/admin/operations` unchanged.
|
||||||
|
- Rationale: Matches Spec 077 clarification (Q2=A). Prevents link-sharing surprises and keeps canonical monitoring routes stable.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Querystring-based default filtering (e.g. `?tenant_id=`): rejected as it makes filtered URLs the de-facto navigation target.
|
||||||
|
|
||||||
|
### D3 — Missing workspace context redirects to `/admin/choose-workspace` and returns to the requested URL
|
||||||
|
|
||||||
|
- Decision: When a workspace-scoped page is visited without an active workspace selection, redirect to `/admin/choose-workspace` and then return.
|
||||||
|
- Rationale: Matches Spec 077 clarification (Q3=A) and aligns with existing `/admin` root override behavior in [routes/web.php](../../routes/web.php).
|
||||||
|
|
||||||
|
### D4 — Invalid tenant context (tenant not in current workspace) is auto-cleared
|
||||||
|
|
||||||
|
- Decision: If tenant context is active but does not belong to the current workspace, clear tenant context and continue on workspace-level pages.
|
||||||
|
- Rationale: Matches Spec 077 clarification (Q4=A). Reduces “ghost tenant” behavior after a workspace switch.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Hard 404: rejected as too confusing during normal context switching.
|
||||||
|
|
||||||
|
## Key Implementation Implications (for planning)
|
||||||
|
|
||||||
|
- **Rename navigation labels** to satisfy “one label, one meaning”:
|
||||||
|
- The “Workspaces” navigation item that points to the choose-workspace page must become **"Switch workspace"**.
|
||||||
|
- The “Workspaces” navigation item that points to CRUD must become **"Manage workspaces"** and be capability-gated with workspace RBAC semantics (404 for non-members; 403 for missing capability).
|
||||||
|
- **Operations filter chip/removal**: current behavior filters by `Tenant::current()` inside `OperationRunResource::getEloquentQuery()`, which is not user-removable. The plan should move this behavior into a table filter with default state.
|
||||||
|
- **No render-time external calls**: monitoring pages must remain DB-only at render (already consistent with constitution).
|
||||||
162
specs/077-workspace-nav-monitoring-hub/spec.md
Normal file
162
specs/077-workspace-nav-monitoring-hub/spec.md
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
# Feature Specification: Workspace-first Navigation & Monitoring Hub
|
||||||
|
|
||||||
|
**Feature Branch**: `077-workspace-nav-monitoring-hub`
|
||||||
|
**Created**: 2026-02-06
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Workspace-first navigation and monitoring hub for an enterprise admin suite: remove workspace navigation ambiguity, lock canonical operations deep links, apply tenant context only as default filters, and enforce strict 404/403 access semantics without information leakage."
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-02-06
|
||||||
|
|
||||||
|
- Q: What is the authorization plane + status-code rule for `/admin/workspaces` ("Manage workspaces")? → A: Tenant plane (`/admin`, Entra users). Workspace management is workspace-scoped: non-members receive 404 (deny-as-not-found); members missing required capabilities receive 403.
|
||||||
|
- Q: How should the tenant-context default filter on `/admin/operations` be implemented? → A: Server-side default state with a removable filter chip; URL remains `/admin/operations`.
|
||||||
|
- Q: What happens when a user visits a workspace-scoped page (e.g. `/admin/operations`) with no `current_workspace_id` selected? → A: Redirect to `/admin/choose-workspace` and return to the originally requested URL after selection.
|
||||||
|
- Q: If tenant context is active but the tenant is not in the current workspace (e.g., user switches workspaces), what should happen? → A: Auto-clear tenant context and continue on tenantless workspace pages.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Switch workspace without ambiguity (Priority: P1)
|
||||||
|
|
||||||
|
As an operator/admin, I need to switch my active workspace (portfolio) using a clear, single-purpose entry point, so that I never confuse "switch workspace" with "manage workspaces".
|
||||||
|
|
||||||
|
**Why this priority**: Workspace context is foundational. If it’s confusing, every other module becomes harder to use and support.
|
||||||
|
|
||||||
|
**Independent Test**: A user can find "Switch workspace", select a workspace they are a member of, and the application context updates while workspace management remains separate.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** I am signed in and belong to multiple workspaces, **When** I choose "Switch workspace", **Then** I see only workspaces I am a member of and can select one.
|
||||||
|
2. **Given** I can manage workspaces, **When** I open "Manage workspaces", **Then** I can access workspace CRUD screens and breadcrumbs stay within the management area.
|
||||||
|
3. **Given** I cannot manage workspaces, **When** I look at navigation, **Then** I do not see "Manage workspaces".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Use Monitoring hub from canonical links (Priority: P2)
|
||||||
|
|
||||||
|
As an operator, I need monitoring pages (starting with Operations) to be reachable via stable, shareable links that never depend on tenant context, so that support, alerts, and notifications can deep-link reliably.
|
||||||
|
|
||||||
|
**Why this priority**: Monitoring must be dependable across contexts; deep links are critical for incident response and support.
|
||||||
|
|
||||||
|
**Independent Test**: Visiting the canonical operations URLs works with and without tenant context, and the system enforces membership checks.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** I am a member of a workspace, **When** I visit `/admin/operations`, **Then** I can view a workspace-wide list of operations.
|
||||||
|
2. **Given** I have an active tenant context, **When** I visit `/admin/operations`, **Then** operations are pre-filtered to that tenant but the URL remains `/admin/operations`.
|
||||||
|
3. **Given** I have a run link `/admin/operations/{run}`, **When** I open it, **Then** I see the run detail regardless of tenant context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Navigate and search without leaking inaccessible data (Priority: P3)
|
||||||
|
|
||||||
|
As a user, I should never learn about workspaces/tenants/runs I cannot access through navigation labels, breadcrumbs, counts, or global search results.
|
||||||
|
|
||||||
|
**Why this priority**: Preventing information leakage is a core enterprise requirement and reduces risk in multi-tenant MSP environments.
|
||||||
|
|
||||||
|
**Independent Test**: An unauthorized user receives not-found responses for out-of-scope resources and does not see them in search.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** I am not a member of a workspace, **When** I attempt to access that workspace’s monitoring data or runs, **Then** I receive a not-found response.
|
||||||
|
2. **Given** I am a workspace member but lack a capability for a protected workspace-scoped screen or action, **When** I attempt to access it directly, **Then** I receive a forbidden response.
|
||||||
|
3. **Given** I use global search, **When** I search for entities outside my scope, **Then** they do not appear in results (no partial hints).
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- User is a member of zero workspaces.
|
||||||
|
- User loses workspace membership while having an active session.
|
||||||
|
- Tenant context is active but the tenant does not belong to the current workspace.
|
||||||
|
- A run is referenced by an external deep link after it was deleted or moved.
|
||||||
|
- User can view monitoring but cannot perform mutations (e.g., cancel/retry) if those actions exist.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature changes navigation and authorization behavior but does not introduce new external API calls or background jobs. Any mutation actions added later (e.g., cancel/retry) must follow the platform’s safety gates (confirmation/audit) and be covered by authorization tests.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):**
|
||||||
|
|
||||||
|
- **Authorization plane(s) involved**:
|
||||||
|
- **Tenant plane (Entra users)** only.
|
||||||
|
- **Platform plane (`/system`) is out of scope** for this feature.
|
||||||
|
- **Authorization planes**:
|
||||||
|
- Workspace-level pages (e.g., monitoring hub, workspace management) are governed by workspace membership and workspace capabilities.
|
||||||
|
- Tenant context is secondary and must not change canonical routing for monitoring pages.
|
||||||
|
- **Isolation model note (workspace scope)**:
|
||||||
|
- “Workspace-scoped” monitoring is an explicit, access-checked aggregation scope over the managed tenants that belong to the selected workspace.
|
||||||
|
- All reads remain bounded to the current workspace; there is no cross-workspace monitoring view in this feature.
|
||||||
|
- **404 vs 403 semantics (strict)**:
|
||||||
|
- Non-member / not entitled to the workspace scope → **404** (deny-as-not-found)
|
||||||
|
- Workspace member but missing the required capability for a protected screen/action → **403**
|
||||||
|
- **Server-side enforcement**: Navigation visibility must not be treated as authorization; all access control is enforced on the server for every protected page and every mutation.
|
||||||
|
- **Global search non-leakage**: Global search must not show titles, counts, or partial matches for inaccessible workspaces/tenants/runs. Inaccessible entities behave as not-found.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001 (One label, one meaning)**: The application MUST provide two distinct, clearly-labeled entry points:
|
||||||
|
- "Switch workspace" for selecting the active workspace context.
|
||||||
|
- "Manage workspaces" for workspace CRUD/administration.
|
||||||
|
- **FR-002 (Canonical workspace switch route)**: "Switch workspace" MUST navigate to `/admin/choose-workspace`.
|
||||||
|
- **FR-003 (Canonical workspace management route)**: "Manage workspaces" MUST navigate to `/admin/workspaces` and MUST NOT be labeled simply "Workspaces".
|
||||||
|
- **FR-004 (Breadcrumb correctness)**: Breadcrumbs in workspace management MUST point back to `/admin/workspaces` and must not send users to the workspace switcher.
|
||||||
|
- **FR-005 (Monitoring is workspace-level)**: Monitoring pages MUST be workspace-scoped and reachable without tenant context.
|
||||||
|
- **FR-006 (Canonical Operations URLs)**: Operations MUST remain canonical and tenantless:
|
||||||
|
- index: `/admin/operations`
|
||||||
|
- detail: `/admin/operations/{run}`
|
||||||
|
- **FR-007 (Tenant context affects defaults, not routing)**: If tenant context is active, the operations index MUST default to showing runs for that tenant using **server-side default filter state**, and users MUST be able to clear that default to view workspace-wide operations. The canonical URL MUST remain `/admin/operations` and the default MUST present a visible, removable filter chip (no required querystring parameters).
|
||||||
|
- **FR-008 (Tenant shortcut to operations)**: Tenant detail screens MUST offer a "Recent operations" summary and a "View all operations" call-to-action that leads to the canonical operations index.
|
||||||
|
- **FR-009 (Membership gating)**: Users MUST be a member of a workspace to access workspace-scoped pages. Non-members MUST receive a not-found response.
|
||||||
|
- **FR-010 (Capability gating for management)**: Workspace-scoped management/mutations MUST be restricted to users with the appropriate capability/capabilities (from the canonical registry). Unauthorized workspace members MUST receive a forbidden response.
|
||||||
|
- Canonical capabilities used by this feature:
|
||||||
|
- `workspace.manage` (Capabilities::WORKSPACE_MANAGE): create/edit workspace fields.
|
||||||
|
- `workspace_membership.manage` (Capabilities::WORKSPACE_MEMBERSHIP_MANAGE): add/remove members and change roles.
|
||||||
|
- **FR-011 (Monitoring hub IA)**: The sidebar MUST provide a "Monitoring" area that is the canonical home for Operations now, with reserved surfaces for future Alerts and Audit Log.
|
||||||
|
- **FR-012 (Deep-link stability)**: Any monitoring entity intended for support workflows MUST have a stable deep link that does not depend on tenant context.
|
||||||
|
- **FR-013 (No workspace selected)**: If a user visits a workspace-scoped page without a selected workspace context, the system MUST redirect to `/admin/choose-workspace` and then return the user to their originally requested URL after a successful selection.
|
||||||
|
- **FR-014 (Invalid tenant context)**: If tenant context is active but the tenant does not belong to the current workspace, the system MUST auto-clear tenant context and continue on workspace-level pages without tenant scoping.
|
||||||
|
|
||||||
|
- **FR-077-016 (Header context bar)**: The header MUST provide an always-available context bar for Suite navigation:
|
||||||
|
- **FR-077-016-A (Workspace visible)**: If a workspace is selected, show `Workspace: <name>` and allow the user to open the existing workspace switcher (`/admin/choose-workspace`).
|
||||||
|
- **FR-077-016-B (Tenant accessible on tenantless pages)**: The header MUST surface tenant context even on tenantless pages (e.g., `/admin/operations`). If there is an active tenant context, show `Tenant: <tenant name>` (fallback to a safe identifier). If there is no active tenant but there is a last-selected tenant in the current workspace session, show it.
|
||||||
|
- **FR-077-016-C (No implicit switching)**: Canonical pages MUST NOT silently switch tenant or workspace. The context bar is an explicit control only.
|
||||||
|
- **FR-077-016-D (No leakage)**: Tenant picker contents MUST include only tenants the user is entitled to view within the current workspace. Unauthorized tenant selection via direct URL MUST remain deny-as-not-found (404).
|
||||||
|
- **FR-077-016-E (Filament-native)**: Implementation MUST use Filament v5 mechanisms (topbar/user-menu render hooks + Filament tenancy) and Livewire v4 where needed.
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Workspace**: Primary context container for a customer/portfolio.
|
||||||
|
- **Workspace Membership**: The relationship that entitles a user to a workspace.
|
||||||
|
- **Managed Tenant**: Secondary context within a workspace; used for scoping defaults and tenant workflows.
|
||||||
|
- **Operation Run**: A record representing an operational execution that belongs to a workspace and may be associated with a tenant.
|
||||||
|
- **Capability**: A named permission that gates management/mutation behavior.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001 (Reduced confusion)**: In a moderated test with new users, at least 90% correctly choose the right destination (switch vs manage) on first attempt.
|
||||||
|
- **SC-002 (Faster workspace switching)**: Users can switch to a known workspace in under 15 seconds without using search.
|
||||||
|
- **SC-003 (Reliable deep links)**: Support can open `/admin/operations/{run}` successfully regardless of tenant context in 100% of tested cases.
|
||||||
|
- **SC-004 (No leakage regressions)**: Security regression tests confirm 0 instances of inaccessible workspaces/tenants/runs appearing in navigation or global search.
|
||||||
|
|
||||||
|
## Acceptance details (pinned)
|
||||||
|
|
||||||
|
### Recent operations summary (FR-008)
|
||||||
|
|
||||||
|
- Show the most recent **5** operation runs for the current tenant, ordered by `created_at` descending (fallback: `id` descending).
|
||||||
|
- Display, at minimum: `type` (label), `status`, `outcome`, `created_at` (or since), and a link to the run detail.
|
||||||
|
- Provide a "View all operations" CTA that navigates to canonical `/admin/operations` (no tenant prefix / no required query params).
|
||||||
|
|
||||||
|
### Header context bar (FR-077-016)
|
||||||
|
|
||||||
|
- The header shows a stable, compact context bar:
|
||||||
|
- `Workspace: <name>` (clickable)
|
||||||
|
- `Tenant: <name>` (picker)
|
||||||
|
- Tenant picker is available on tenantless pages.
|
||||||
|
- No automatic tenant selection occurs when opening canonical URLs.
|
||||||
|
|
||||||
|
## Mandatory Tests (pinned)
|
||||||
|
|
||||||
|
- **T-077-016-1 (Tenant dropdown on tenantless pages)**: With a selected workspace and an active tenant context, visiting `/admin/operations` shows the tenant picker and selecting a tenant navigates to tenant home.
|
||||||
|
- **T-077-016-2 (Security filtering)**: Only entitled tenants within the current workspace appear in the picker; posting /navigating to an unauthorized tenant results in 404.
|
||||||
|
- **T-077-016-3 (No implicit switching)**: Visiting `/admin/operations/{run}` from a deep link MUST NOT auto-switch tenant.
|
||||||
215
specs/077-workspace-nav-monitoring-hub/tasks.md
Normal file
215
specs/077-workspace-nav-monitoring-hub/tasks.md
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
description: "Task list for Spec 077 implementation"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: Workspace-first Navigation & Monitoring Hub (077)
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/077-workspace-nav-monitoring-hub/`
|
||||||
|
|
||||||
|
**Prerequisites**:
|
||||||
|
- Required: [spec.md](spec.md), [plan.md](plan.md)
|
||||||
|
- Optional (used): [research.md](research.md), [data-model.md](data-model.md), [contracts/routes.md](contracts/routes.md), [quickstart.md](quickstart.md)
|
||||||
|
|
||||||
|
**Tests**: REQUIRED (Pest) — this feature changes runtime behavior (navigation + authorization + filtering).
|
||||||
|
|
||||||
|
**Livewire/Filament compatibility**: Filament v5 + Livewire v4 only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Prepare the minimal scaffolding for safe, test-first delivery.
|
||||||
|
|
||||||
|
- [X] T001 Create new Pest test file for workspace navigation in tests/Feature/Workspaces/WorkspaceNavigationHubTest.php
|
||||||
|
- [X] T002 Create new Pest test file for operations canonical routing in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
|
||||||
|
- [X] T003 [P] Create new Pest test file for non-leakage semantics in tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Shared plumbing needed by multiple stories.
|
||||||
|
|
||||||
|
- [X] T004 Add intended-URL session key constant in app/Support/Workspaces/WorkspaceContext.php
|
||||||
|
- [X] T005 Implement “store intended URL” helper in app/Support/Workspaces/WorkspaceIntendedUrl.php
|
||||||
|
- [X] T006 [P] Add tests for intended-URL helper in tests/Feature/Workspaces/WorkspaceIntendedUrlTest.php
|
||||||
|
- [X] T007 Update middleware to use intended-URL helper in app/Http/Middleware/EnsureWorkspaceSelected.php
|
||||||
|
- [X] T008 [P] Add safe-redirect allowlist for intended URLs in app/Support/Workspaces/WorkspaceIntendedUrl.php
|
||||||
|
|
||||||
|
**Checkpoint**: Intended redirect plumbing exists and is covered by tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — Switch workspace without ambiguity (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Clear separation between “Switch workspace” and “Manage workspaces”, with correct 404/403 behavior.
|
||||||
|
|
||||||
|
**Independent Test**: A signed-in user can switch workspaces via “Switch workspace”, and “Manage workspaces” is only visible/accessible when authorized.
|
||||||
|
|
||||||
|
### Tests for User Story 1 (write first)
|
||||||
|
|
||||||
|
- [X] T009 [P] [US1] Assert nav label “Switch workspace” appears when tenant is not selected in tests/Feature/Workspaces/WorkspaceNavigationHubTest.php
|
||||||
|
- [X] T010 [P] [US1] Assert no ambiguous “Workspaces” nav item exists in tests/Feature/Workspaces/WorkspaceNavigationHubTest.php
|
||||||
|
- [X] T011 [P] [US1] Assert `/admin/workspaces` is tenantless and reachable for a workspace owner in tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php
|
||||||
|
- [X] T012 [P] [US1] Assert `/admin/workspaces/{record}` is deny-as-not-found for non-members (404) in tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T013 [US1] Rename fallback nav item to “Switch workspace” in app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||||
|
- [X] T014 [US1] Update user-menu copy/CTA to “Switch workspace” in resources/views/filament/partials/workspace-switcher.blade.php
|
||||||
|
- [X] T015 [US1] Rename admin sidebar item to “Manage workspaces” in app/Providers/Filament/AdminPanelProvider.php
|
||||||
|
- [X] T016 [US1] Gate “Manage workspaces” navigation visibility via capability in app/Providers/Filament/AdminPanelProvider.php
|
||||||
|
- [X] T017 [US1] Enforce workspace-scoped RBAC semantics for workspace management (404 non-member, 403 missing capability) in app/Policies/WorkspacePolicy.php
|
||||||
|
- [X] T018 [US1] Ensure workspace management breadcrumbs point to `/admin/workspaces` in app/Filament/Resources/Workspaces/WorkspaceResource.php
|
||||||
|
- [X] T019 [US1] Ensure `/admin/workspaces` routes do not require tenant context in app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||||
|
- [X] T020 [US1] Run focused tests for US1 via `./vendor/bin/sail artisan test --compact --filter=WorkspaceNavigationHub` (document in specs/077-workspace-nav-monitoring-hub/quickstart.md)
|
||||||
|
|
||||||
|
**Checkpoint**: UI uses unambiguous labels; `/admin/workspaces` follows workspace RBAC semantics (no leakage).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — Use Monitoring hub from canonical links (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: `/admin/operations` and `/admin/operations/{run}` work regardless of tenant context; tenant context only sets removable default filters.
|
||||||
|
|
||||||
|
**Independent Test**: Visiting `/admin/operations` works tenantless (workspace-selected), and in tenant context it defaults to that tenant via a removable filter chip.
|
||||||
|
|
||||||
|
### Tests for User Story 2 (write first)
|
||||||
|
|
||||||
|
- [X] T021 [P] [US2] Assert `/admin/operations` is reachable without tenant context in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
|
||||||
|
- [X] T022 [P] [US2] Assert `/admin/operations/{run}` works with and without tenant context in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
|
||||||
|
- [X] T023 [P] [US2] Assert operations list defaults to current tenant (filter state) when tenant context active in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
|
||||||
|
- [X] T024 [P] [US2] Assert clearing tenant filter shows workspace-wide runs in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T025 [US2] Allow `/admin/operations` (index) through tenancy-enforcing middleware without auto-setting tenant in app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||||
|
- [X] T026 [US2] Ensure workspace selection is required for `/admin/operations` and stores intended URL for return flow in app/Http/Middleware/EnsureWorkspaceSelected.php
|
||||||
|
- [X] T027 [US2] Redirect back to intended URL after workspace selection in both app/Filament/Pages/ChooseWorkspace.php and app/Http/Controllers/SwitchWorkspaceController.php
|
||||||
|
- [X] T028 [US2] Remove hard tenant scoping from query in app/Filament/Resources/OperationRunResource.php
|
||||||
|
- [X] T029 [US2] Add tenant SelectFilter with removable chip and server-side default state in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
||||||
|
- [X] T030 [US2] Scope selectable tenants in the filter to current workspace in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
||||||
|
- [X] T031 [US2] Add “Recent operations” summary (last 5 by created_at) + “View all operations” CTA on tenant view page in app/Filament/Resources/TenantResource/Pages/ViewTenant.php
|
||||||
|
- [X] T032 [US2] Ensure “View all operations” CTA routes to canonical `/admin/operations` in app/Filament/Resources/TenantResource/Pages/ViewTenant.php
|
||||||
|
- [X] T033 [US2] Ensure operations pages remain DB-only (no Graph calls) by extending existing checks in tests/Feature/MonitoringOperationsTest.php
|
||||||
|
- [X] T034 [US2] Run focused tests for US2 via `./vendor/bin/sail artisan test --compact --filter=OperationsCanonicalUrls` and update specs/077-workspace-nav-monitoring-hub/quickstart.md
|
||||||
|
|
||||||
|
**Checkpoint**: Canonical operations URLs work; tenant context only affects default filter state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 — Navigate and search without leaking inaccessible data (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Enforce strict 404 vs 403 semantics without leaking admin surfaces or cross-workspace/tenant data.
|
||||||
|
|
||||||
|
**Independent Test**: Non-members get 404; members missing capability get 403, and no navigation labels hint at inaccessible features.
|
||||||
|
|
||||||
|
### Tests for User Story 3 (write first)
|
||||||
|
|
||||||
|
- [X] T035 [P] [US3] Assert non-member access to another workspace’s operations is 404 in tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
|
||||||
|
- [X] T036 [P] [US3] Assert member missing `workspace.manage` gets 403 on `/admin/workspaces/{record}/edit` in tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
|
||||||
|
- [X] T037 [P] [US3] Assert invalid tenant context is auto-cleared when switching workspace in tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php
|
||||||
|
- [X] T038 [P] [US3] Assert reserved Monitoring placeholder pages exist (`/admin/alerts`, `/admin/audit-log`) in tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T039 [US3] Implement “auto-clear invalid tenant context” check in app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||||
|
- [X] T040 [US3] Confirm and implement correct Filament v5 mechanism for clearing persisted tenant state in app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||||
|
- [X] T041 [US3] Implement reserved Monitoring placeholder pages (Alerts, Audit Log) as Filament pages under app/Filament/Pages/Monitoring/**
|
||||||
|
- [X] T042 [US3] Ensure navigation does not expose admin-only surfaces to unauthorized users in app/Providers/Filament/AdminPanelProvider.php
|
||||||
|
- [X] T043 [US3] Verify global search does not introduce new leakage for operations/workspaces and, if needed, disable global search for resources without view/edit pages in app/Filament/**
|
||||||
|
- [X] T044 [US3] Run focused tests for US3 via `./vendor/bin/sail artisan test --compact --filter=NonLeakageWorkspaceOperations`
|
||||||
|
|
||||||
|
**Checkpoint**: 404/403 behavior matches spec; no cross-scope leaks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Stabilize, format, and validate end-to-end.
|
||||||
|
|
||||||
|
- [X] T045 Run formatter on touched files via `./vendor/bin/sail bin pint --dirty`
|
||||||
|
- [X] T046 Run targeted full suite for touched areas via `./vendor/bin/sail artisan test --compact tests/Feature/Workspaces tests/Feature/Monitoring tests/Feature/OpsUx`
|
||||||
|
- [X] T047 [P] Confirm manual quickstart steps still match UI labels and routes in specs/077-workspace-nav-monitoring-hub/quickstart.md
|
||||||
|
- [X] T048 [P] Confirm route semantics still match contracts in specs/077-workspace-nav-monitoring-hub/contracts/routes.md
|
||||||
|
- [X] T049 Ensure Filament v5 + Livewire v4 APIs are used (no v3/v4 Filament APIs) in app/Filament/**
|
||||||
|
- [X] T050 Run full suite (optional) via `./vendor/bin/sail artisan test --compact`
|
||||||
|
|
||||||
|
### Post-implementation bugfixes
|
||||||
|
|
||||||
|
- [X] T058 Fix route conflict so Operations “View” consistently hits canonical `/admin/operations/{run}` by moving Filament resource view route to `/admin/operations/r/{record}` in app/Filament/Resources/OperationRunResource.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Addendum — Header Context Bar (FR-077-016)
|
||||||
|
|
||||||
|
**Goal**: Always-visible context bar for Workspace + Tenant, usable on tenantless pages without implicit switching.
|
||||||
|
|
||||||
|
### Tests (write first)
|
||||||
|
|
||||||
|
- [X] T051 [P] [FR-077-016] Assert tenant picker renders on `/admin/operations` in tests/Feature/Monitoring/HeaderContextBarTest.php
|
||||||
|
- [X] T052 [P] [FR-077-016] Assert tenant picker lists only entitled tenants in tests/Feature/Monitoring/HeaderContextBarTest.php
|
||||||
|
- [X] T053 [P] [FR-077-016] Assert deep link `/admin/operations/{run}` does not auto-switch tenant in tests/Feature/Monitoring/HeaderContextBarTest.php
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [X] T054 [FR-077-016] Render context bar in topbar via render hook in app/Providers/Filament/AdminPanelProvider.php
|
||||||
|
- [X] T055 [FR-077-016] Add context bar partial view in resources/views/filament/partials/context-bar.blade.php
|
||||||
|
- [X] T056 [FR-077-016] Remove implicit tenant auto-selection behavior while preserving deny-as-not-found semantics in app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||||
|
- [X] T057 [FR-077-016] Persist last-selected tenant per workspace session in app/Support/Workspaces/WorkspaceContext.php and controllers/pages that select tenants
|
||||||
|
|
||||||
|
**Checkpoint**: Tenant picker usable on tenantless pages; no silent tenant switching.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- Phase 1 (Setup) → Phase 2 (Foundational)
|
||||||
|
- Phase 2 (Foundational) → Phase 3+ (User stories)
|
||||||
|
- Phase 3 (US1) is the MVP and should be delivered first.
|
||||||
|
- Phase 4 (US2) depends on the clarified navigation + intended-URL plumbing (Phases 1–2).
|
||||||
|
- Phase 5 (US3) depends on the implemented behavior from US1/US2 so it can assert non-leakage.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- US1 → US2: soft dependency (naming + intended redirect improves US2 flows)
|
||||||
|
- US2 → US3: recommended dependency (US3 asserts final 404/403 and filter semantics)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Execution Examples
|
||||||
|
|
||||||
|
### US1 parallelizable work
|
||||||
|
|
||||||
|
- T009, T010, T011, T012 can be written in parallel (different assertions/files)
|
||||||
|
- T013, T014, T015 can be implemented in parallel (different files)
|
||||||
|
|
||||||
|
### US2 parallelizable work
|
||||||
|
|
||||||
|
- T021–T024 can be written in parallel
|
||||||
|
- T028 (resource query) and T029 (table filters) can be implemented in parallel
|
||||||
|
- T031–T032 can be implemented in parallel with operations filter work (different file)
|
||||||
|
|
||||||
|
### US3 parallelizable work
|
||||||
|
|
||||||
|
- T035–T038 can be written in parallel
|
||||||
|
- T039–T042 can be implemented in parallel (different files)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP scope (recommended)
|
||||||
|
|
||||||
|
- Deliver Phase 1–3 (US1) only.
|
||||||
|
- Validate with `./vendor/bin/sail artisan test --compact --filter=WorkspaceNavigationHub`.
|
||||||
|
- Demo “Switch workspace” vs “Manage workspaces” clarity + correct 404/403 behavior.
|
||||||
|
|
||||||
|
### Incremental delivery
|
||||||
|
|
||||||
|
- Add US2 (canonical operations URLs + removable tenant default filter)
|
||||||
|
- Add US3 (non-leakage regression guards)
|
||||||
|
- Finish with Phase 6 polish and a full suite run
|
||||||
110
tests/Feature/Monitoring/HeaderContextBarTest.php
Normal file
110
tests/Feature/Monitoring/HeaderContextBarTest.php
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
|
||||||
|
it('renders the tenant context picker on tenantless Monitoring → Operations', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||||
|
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||||
|
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->get('/admin/operations')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Workspace:')
|
||||||
|
->assertSee('Tenant:')
|
||||||
|
->assertSee('Select tenant…')
|
||||||
|
->assertSee('admin/select-tenant')
|
||||||
|
->assertSee('Clear tenant context')
|
||||||
|
->assertSee($tenant->getFilamentName());
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||||
|
])
|
||||||
|
->post(route('admin.select-tenant'), ['tenant_id' => (int) $tenant->getKey()])
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters the header tenant picker to tenants the user can access', function (): void {
|
||||||
|
$tenantA = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||||
|
|
||||||
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'name' => 'ZZZ-UNAUTHORIZED-TENANT-NAME-12345',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||||
|
])
|
||||||
|
->get('/admin/operations')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee($tenantA->getFilamentName())
|
||||||
|
->assertDontSee($tenantB->getFilamentName());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not implicitly switch tenant when opening canonical operation deep links', function (): void {
|
||||||
|
$tenantA = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||||
|
|
||||||
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenantB->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$runA = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenantA->getKey(),
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'initiator_name' => 'TenantA',
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenantB->getKey(),
|
||||||
|
'workspace_id' => (int) $tenantB->workspace_id,
|
||||||
|
'type' => 'inventory.sync',
|
||||||
|
'initiator_name' => 'TenantB',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||||
|
])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $runA->getKey()]))
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
expect(Filament::getTenant())->toBeNull();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||||
|
])
|
||||||
|
->get('/admin/operations')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Policy sync')
|
||||||
|
->assertSee('Inventory sync')
|
||||||
|
->assertSee('TenantA')
|
||||||
|
->assertSee('TenantB');
|
||||||
|
});
|
||||||
@ -1,7 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Support\Facades\Bus;
|
use Illuminate\Support\Facades\Bus;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
@ -21,8 +22,11 @@
|
|||||||
Bus::fake();
|
Bus::fake();
|
||||||
Queue::fake();
|
Queue::fake();
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
assertNoOutboundHttp(function () use ($tenant) {
|
assertNoOutboundHttp(function () use ($tenant) {
|
||||||
$this->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get('/admin/operations')
|
||||||
->assertOk();
|
->assertOk();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
142
tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
Normal file
142
tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\OperationRunResource\Pages\ListOperationRuns;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('serves /admin/operations without tenant context (workspace-wide)', function (): void {
|
||||||
|
$tenantA = Tenant::factory()->create();
|
||||||
|
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||||
|
|
||||||
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenantB->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$runA = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenantA->getKey(),
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'initiator_name' => 'TenantA',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$runB = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenantB->getKey(),
|
||||||
|
'workspace_id' => (int) $tenantB->workspace_id,
|
||||||
|
'type' => 'inventory.sync',
|
||||||
|
'initiator_name' => 'TenantB',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||||
|
->get('/admin/operations')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Policy sync')
|
||||||
|
->assertSee('Inventory sync')
|
||||||
|
->assertSee('TenantA')
|
||||||
|
->assertSee('TenantB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves /admin/operations/{run} with and without tenant context', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Operation run');
|
||||||
|
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Operation run');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults the tenant filter from tenant context and can be cleared', function (): void {
|
||||||
|
$tenantA = Tenant::factory()->create();
|
||||||
|
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||||
|
|
||||||
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenantB->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$runA = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenantA->getKey(),
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'initiator_name' => 'TenantA',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$runB = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenantB->getKey(),
|
||||||
|
'workspace_id' => (int) $tenantB->workspace_id,
|
||||||
|
'type' => 'inventory.sync',
|
||||||
|
'initiator_name' => 'TenantB',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant($tenantA, true);
|
||||||
|
|
||||||
|
$this->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||||
|
]);
|
||||||
|
session([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)
|
||||||
|
->test(ListOperationRuns::class)
|
||||||
|
->assertCanSeeTableRecords([$runA])
|
||||||
|
->assertCanNotSeeTableRecords([$runB]);
|
||||||
|
|
||||||
|
$component
|
||||||
|
->filterTable('tenant_id', null)
|
||||||
|
->assertCanSeeTableRecords([$runA, $runB]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has reserved Monitoring placeholder pages for Alerts and Audit Log', 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/alerts')
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get('/admin/audit-log')
|
||||||
|
->assertOk();
|
||||||
|
});
|
||||||
@ -1,7 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Support\Facades\Bus;
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
|
||||||
it('renders Monitoring → Operations index DB-only (no outbound HTTP, no background work)', function () {
|
it('renders Monitoring → Operations index DB-only (no outbound HTTP, no background work)', function () {
|
||||||
@ -19,8 +20,11 @@
|
|||||||
|
|
||||||
Bus::fake();
|
Bus::fake();
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
assertNoOutboundHttp(function () use ($tenant) {
|
assertNoOutboundHttp(function () use ($tenant) {
|
||||||
$this->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get('/admin/operations')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Total Runs (30 days)')
|
->assertSee('Total Runs (30 days)')
|
||||||
->assertSee('Active Runs')
|
->assertSee('Active Runs')
|
||||||
@ -51,10 +55,13 @@
|
|||||||
|
|
||||||
Bus::fake();
|
Bus::fake();
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
assertNoOutboundHttp(function () use ($tenant, $run) {
|
assertNoOutboundHttp(function () use ($tenant, $run) {
|
||||||
$this->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
|
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Policy sync');
|
->assertSee('Operation run');
|
||||||
});
|
});
|
||||||
|
|
||||||
Bus::assertNothingDispatched();
|
Bus::assertNothingDispatched();
|
||||||
|
|||||||
@ -1,18 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Filament\Resources\OperationRunResource\Pages\ListOperationRuns;
|
use App\Filament\Resources\OperationRunResource\Pages\ListOperationRuns;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
it('scopes Monitoring → Operations list to the active tenant', function () {
|
it('defaults Monitoring → Operations list to the active tenant when tenant context is set', function () {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
$tenantB = Tenant::factory()->create();
|
$tenantB = Tenant::factory()->create();
|
||||||
|
|
||||||
[$user] = createUserWithTenant($tenantA, role: 'owner');
|
[$user] = createUserWithTenant($tenantA, role: 'owner');
|
||||||
|
|
||||||
|
$tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save();
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
$user->tenants()->syncWithoutDetaching([
|
||||||
$tenantB->getKey() => ['role' => 'owner'],
|
$tenantB->getKey() => ['role' => 'owner'],
|
||||||
]);
|
]);
|
||||||
@ -33,8 +35,11 @@
|
|||||||
'initiator_name' => 'TenantB',
|
'initiator_name' => 'TenantB',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant($tenantA, true);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(OperationRunResource::getUrl('index', tenant: $tenantA))
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||||
|
->get('/admin/operations')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Policy sync')
|
->assertSee('Policy sync')
|
||||||
->assertSee('TenantA')
|
->assertSee('TenantA')
|
||||||
@ -48,6 +53,8 @@
|
|||||||
|
|
||||||
[$user] = createUserWithTenant($tenantA, role: 'owner');
|
[$user] = createUserWithTenant($tenantA, role: 'owner');
|
||||||
|
|
||||||
|
$tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save();
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
$user->tenants()->syncWithoutDetaching([
|
||||||
$tenantB->getKey() => ['role' => 'owner'],
|
$tenantB->getKey() => ['role' => 'owner'],
|
||||||
]);
|
]);
|
||||||
@ -103,6 +110,13 @@
|
|||||||
$tenantA->makeCurrent();
|
$tenantA->makeCurrent();
|
||||||
Filament::setTenant($tenantA, true);
|
Filament::setTenant($tenantA, true);
|
||||||
|
|
||||||
|
$this->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||||
|
]);
|
||||||
|
session([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ListOperationRuns::class)
|
->test(ListOperationRuns::class)
|
||||||
->assertCanSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runFailedA])
|
->assertCanSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runFailedA])
|
||||||
@ -121,16 +135,12 @@
|
|||||||
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runActiveB, $runFailedB]);
|
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runActiveB, $runFailedB]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prevents cross-tenant access to Monitoring → Operations detail', function () {
|
it('prevents cross-workspace access to Monitoring → Operations detail', function () {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
|
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||||
|
|
||||||
$tenantB = Tenant::factory()->create();
|
$tenantB = Tenant::factory()->create();
|
||||||
|
|
||||||
[$user] = createUserWithTenant($tenantA, role: 'owner');
|
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenantB->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$runB = OperationRun::factory()->create([
|
$runB = OperationRun::factory()->create([
|
||||||
'tenant_id' => $tenantB->getKey(),
|
'tenant_id' => $tenantB->getKey(),
|
||||||
'type' => 'inventory.sync',
|
'type' => 'inventory.sync',
|
||||||
@ -140,6 +150,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(OperationRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA))
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $runB->getKey()]))
|
||||||
->assertNotFound();
|
->assertNotFound();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,17 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
|
||||||
it('allows access to monitoring page for tenant members', function () {
|
it('allows access to Monitoring → Operations for workspace members', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
|
|
||||||
$run = OperationRun::create([
|
OperationRun::factory()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'type' => 'policy.sync',
|
'type' => 'policy.sync',
|
||||||
'status' => 'queued',
|
'status' => 'queued',
|
||||||
'outcome' => 'pending',
|
'outcome' => 'pending',
|
||||||
@ -19,18 +23,22 @@
|
|||||||
'run_identity_hash' => 'hash123',
|
'run_identity_hash' => 'hash123',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get('/admin/operations')
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Policy sync');
|
->assertSee('Policy sync');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders monitoring pages DB-only (never calls Graph)', function () {
|
it('renders Monitoring → Operations pages DB-only (never calls Graph)', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
|
|
||||||
$run = OperationRun::create([
|
$run = OperationRun::factory()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'type' => 'policy.sync',
|
'type' => 'policy.sync',
|
||||||
'status' => 'queued',
|
'status' => 'queued',
|
||||||
'outcome' => 'pending',
|
'outcome' => 'pending',
|
||||||
@ -47,59 +55,62 @@
|
|||||||
$mock->shouldReceive('request')->never();
|
$mock->shouldReceive('request')->never();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get('/admin/operations')
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows runs only for current tenant', function () {
|
it('defaults the operations list to the active tenant when tenant context is set', function (): void {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
$tenantB = Tenant::factory()->create();
|
|
||||||
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||||
|
|
||||||
// We must simulate being in tenant context
|
$tenantB = Tenant::factory()->create([
|
||||||
$this->actingAs($user);
|
'status' => 'active',
|
||||||
// Filament::setTenant($tenantA); // This is usually handled by middleware on routes, but in Livewire test we might need manual set or route visit.
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
// Easier approach: visit the page for tenantA
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenantB->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
OperationRun::create([
|
OperationRun::factory()->create([
|
||||||
'tenant_id' => $tenantA->id,
|
'tenant_id' => (int) $tenantA->getKey(),
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
'type' => 'policy.sync',
|
'type' => 'policy.sync',
|
||||||
'status' => 'queued',
|
'initiator_name' => 'TenantA',
|
||||||
'outcome' => 'pending',
|
|
||||||
'initiator_name' => 'System',
|
|
||||||
'run_identity_hash' => 'hashA',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
OperationRun::create([
|
OperationRun::factory()->create([
|
||||||
'tenant_id' => $tenantB->id,
|
'tenant_id' => (int) $tenantB->getKey(),
|
||||||
|
'workspace_id' => (int) $tenantB->workspace_id,
|
||||||
'type' => 'inventory.sync',
|
'type' => 'inventory.sync',
|
||||||
'status' => 'queued',
|
'initiator_name' => 'TenantB',
|
||||||
'outcome' => 'pending',
|
|
||||||
'initiator_name' => 'System',
|
|
||||||
'run_identity_hash' => 'hashB',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Livewire::test needs to know the tenant if the component relies on it.
|
Filament::setTenant($tenantA, true);
|
||||||
// However, the component relies on `Filament::getTenant()`.
|
|
||||||
// The cleanest way is to just GET the page URL, which runs middleware.
|
|
||||||
|
|
||||||
$this->get(OperationRunResource::getUrl('index', tenant: $tenantA))
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||||
|
->get('/admin/operations')
|
||||||
->assertSee('Policy sync')
|
->assertSee('Policy sync')
|
||||||
->assertDontSee('Inventory sync');
|
->assertDontSee('Inventory sync');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows readonly users to view operations list and detail', function () {
|
it('allows readonly users to view operations list and detail', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly');
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly');
|
||||||
|
|
||||||
$run = OperationRun::create([
|
$run = OperationRun::factory()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'type' => 'policy.sync',
|
'type' => 'policy.sync',
|
||||||
'status' => 'queued',
|
'status' => 'queued',
|
||||||
'outcome' => 'pending',
|
'outcome' => 'pending',
|
||||||
@ -107,30 +118,27 @@
|
|||||||
'run_identity_hash' => 'hash123',
|
'run_identity_hash' => 'hash123',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get('/admin/operations')
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Policy sync');
|
->assertSee('Policy sync');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Policy sync');
|
->assertSee('Operation run');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('denies access to unauthorized users', function () {
|
it('returns 404 when viewing an operation run outside workspace membership', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$run = OperationRun::factory()->create();
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
// Not attached to tenant
|
|
||||||
|
|
||||||
// In a multitenant app, if you try to access a tenant route you are not part of,
|
$this->actingAs($user)
|
||||||
// Filament typically returns 404 (Not Found) if it can't find the tenant-user relationship, or 403.
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
// The previous fail said "Received 404". This confirms Filament couldn't find the tenant for this user scope or just hides it.
|
->assertNotFound();
|
||||||
// We should accept 404 or 403.
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
|
||||||
->get(OperationRunResource::getUrl('index', tenant: $tenant));
|
|
||||||
|
|
||||||
// Allow either 403 or 404 as "Denied"
|
|
||||||
$this->assertTrue(in_array($response->status(), [403, 404]));
|
|
||||||
});
|
});
|
||||||
|
|||||||
59
tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
Normal file
59
tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
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 Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('returns 404 when a non-member tries to view another workspace operation run', function (): void {
|
||||||
|
$workspaceA = Workspace::factory()->create();
|
||||||
|
$workspaceB = Workspace::factory()->create();
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $workspaceA->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $workspaceB->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$runB = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenantB->getKey(),
|
||||||
|
'workspace_id' => (int) $workspaceB->getKey(),
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'initiator_name' => 'WorkspaceB',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey()])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $runB->getKey()]))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 when a workspace member without workspace.manage tries to edit a workspace', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $workspace->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'manager',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
|
->get('/admin/workspaces/'.(int) $workspace->getKey().'/edit')
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
@ -1,71 +1,93 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
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;
|
||||||
|
|
||||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
test('operation runs are listed for the active tenant', function () {
|
test('operation runs default to the active tenant when tenant context is set', function (): void {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
$tenantB = Tenant::factory()->create();
|
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
||||||
|
|
||||||
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenantB->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
OperationRun::factory()->create([
|
OperationRun::factory()->create([
|
||||||
'tenant_id' => $tenantA->getKey(),
|
'tenant_id' => (int) $tenantA->getKey(),
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
'type' => 'policy.sync',
|
'type' => 'policy.sync',
|
||||||
'status' => 'queued',
|
'status' => 'queued',
|
||||||
'outcome' => 'pending',
|
'outcome' => 'pending',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
OperationRun::factory()->create([
|
OperationRun::factory()->create([
|
||||||
'tenant_id' => $tenantB->getKey(),
|
'tenant_id' => (int) $tenantB->getKey(),
|
||||||
|
'workspace_id' => (int) $tenantB->workspace_id,
|
||||||
'type' => 'inventory.sync',
|
'type' => 'inventory.sync',
|
||||||
'status' => 'queued',
|
'status' => 'queued',
|
||||||
'outcome' => 'pending',
|
'outcome' => 'pending',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
Filament::setTenant($tenantA, true);
|
||||||
|
|
||||||
$tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenantB->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(OperationRunResource::getUrl('index', tenant: $tenantA))
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||||
|
->get('/admin/operations')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Policy sync')
|
->assertSee('Policy sync')
|
||||||
->assertDontSee('Inventory sync');
|
->assertDontSee('Inventory sync');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('operation run view is not accessible cross-tenant', function () {
|
test('operation run view is not accessible cross-workspace', function (): void {
|
||||||
$tenantA = Tenant::factory()->create();
|
$workspaceA = Workspace::factory()->create();
|
||||||
$tenantB = Tenant::factory()->create();
|
$workspaceB = Workspace::factory()->create();
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspaceA->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $workspaceB->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
$runB = OperationRun::factory()->create([
|
$runB = OperationRun::factory()->create([
|
||||||
'tenant_id' => $tenantB->getKey(),
|
'tenant_id' => (int) $tenantB->getKey(),
|
||||||
|
'workspace_id' => (int) $workspaceB->getKey(),
|
||||||
'type' => 'inventory.sync',
|
'type' => 'inventory.sync',
|
||||||
'status' => 'queued',
|
'status' => 'queued',
|
||||||
'outcome' => 'pending',
|
'outcome' => 'pending',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner');
|
|
||||||
|
|
||||||
$tenantB->forceFill(['workspace_id' => (int) $tenantA->workspace_id])->save();
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
$tenantB->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(OperationRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA))
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey()])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $runB->getKey()]))
|
||||||
->assertNotFound();
|
->assertNotFound();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('readonly users can view operation runs for their tenant', function () {
|
test('readonly users can view operation runs in their workspace', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
$run = OperationRun::factory()->create([
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'type' => 'drift.generate',
|
'type' => 'drift.generate',
|
||||||
'status' => 'queued',
|
'status' => 'queued',
|
||||||
'outcome' => 'pending',
|
'outcome' => 'pending',
|
||||||
@ -73,13 +95,17 @@
|
|||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly');
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly');
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get('/admin/operations')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Drift generation');
|
->assertSee('Drift generation');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Drift generation');
|
->assertSee('Operation run');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -58,6 +58,8 @@
|
|||||||
'tenant_id' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
'tenant_id' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$tenantInOther->makeCurrent();
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
$user->tenants()->syncWithoutDetaching([
|
||||||
$tenantInOther->getKey() => ['role' => 'owner'],
|
$tenantInOther->getKey() => ['role' => 'owner'],
|
||||||
]);
|
]);
|
||||||
|
|||||||
40
tests/Feature/Workspaces/WorkspaceIntendedUrlTest.php
Normal file
40
tests/Feature/Workspaces/WorkspaceIntendedUrlTest.php
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use App\Support\Workspaces\WorkspaceIntendedUrl;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('stores and consumes an intended admin URL (path + query)', function (): void {
|
||||||
|
session()->forget(WorkspaceContext::INTENDED_URL_SESSION_KEY);
|
||||||
|
|
||||||
|
WorkspaceIntendedUrl::store('/admin/operations?tab=active');
|
||||||
|
|
||||||
|
expect(session(WorkspaceContext::INTENDED_URL_SESSION_KEY))->toBe('/admin/operations?tab=active');
|
||||||
|
|
||||||
|
$consumed = WorkspaceIntendedUrl::consume();
|
||||||
|
|
||||||
|
expect($consumed)->toBe('/admin/operations?tab=active');
|
||||||
|
expect(session()->has(WorkspaceContext::INTENDED_URL_SESSION_KEY))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-admin intended URLs', function (): void {
|
||||||
|
session()->forget(WorkspaceContext::INTENDED_URL_SESSION_KEY);
|
||||||
|
|
||||||
|
WorkspaceIntendedUrl::store('/logout');
|
||||||
|
|
||||||
|
expect(session()->has(WorkspaceContext::INTENDED_URL_SESSION_KEY))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects absolute URLs and protocol-relative URLs', function (): void {
|
||||||
|
session()->forget(WorkspaceContext::INTENDED_URL_SESSION_KEY);
|
||||||
|
|
||||||
|
WorkspaceIntendedUrl::store('https://example.com/admin/operations');
|
||||||
|
expect(session()->has(WorkspaceContext::INTENDED_URL_SESSION_KEY))->toBeFalse();
|
||||||
|
|
||||||
|
WorkspaceIntendedUrl::store('//example.com/admin/operations');
|
||||||
|
expect(session()->has(WorkspaceContext::INTENDED_URL_SESSION_KEY))->toBeFalse();
|
||||||
|
});
|
||||||
39
tests/Feature/Workspaces/WorkspaceNavigationHubTest.php
Normal file
39
tests/Feature/Workspaces/WorkspaceNavigationHubTest.php
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('shows "Switch workspace" navigation when no tenant is selected', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $workspace->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
|
->get('/admin/operations')
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$panel = Filament::getCurrentOrDefaultPanel();
|
||||||
|
|
||||||
|
$labels = collect($panel->getNavigationItems())
|
||||||
|
->map(static fn ($item): string => $item->getLabel())
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($labels)->toContain('Switch workspace');
|
||||||
|
expect($labels)->not->toContain('Workspaces');
|
||||||
|
});
|
||||||
@ -73,3 +73,21 @@
|
|||||||
->get('/admin/t/11111111-1111-1111-1111-111111111111/workspaces')
|
->get('/admin/t/11111111-1111-1111-1111-111111111111/workspaces')
|
||||||
->assertNotFound();
|
->assertNotFound();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 404 when accessing a workspace record outside membership', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$workspaceA = Workspace::factory()->create(['slug' => 'acme-a']);
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $workspaceA->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$workspaceB = Workspace::factory()->create(['slug' => 'acme-b']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey()])
|
||||||
|
->get('/admin/workspaces/'.(int) $workspaceB->getKey())
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user