Summary: - Baseline Compare landing: enterprise UI (stats grid, critical drift banner, better actions), navigation grouping under Governance, and Action Surface Contract declaration. - Baseline Profile view page: switches from disabled form fields to proper Infolist entries for a clean read-only view. - Fixes tenant name column usages (`display_name` → `name`) in baseline assignment flows. - Dashboard: improved baseline governance widget with severity breakdown + last compared. Notes: - Filament v5 / Livewire v4 compatible. - Destructive actions remain confirmed (`->requiresConfirmation()`). Tests: - `vendor/bin/sail artisan test --compact tests/Feature/Baselines` - `vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #123
247 lines
8.5 KiB
PHP
247 lines
8.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Resources\BaselineProfileResource\RelationManagers;
|
|
|
|
use App\Models\BaselineProfile;
|
|
use App\Models\BaselineTenantAssignment;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Filament\Actions\Action;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\RelationManagers\RelationManager;
|
|
use Filament\Tables;
|
|
use Filament\Tables\Table;
|
|
|
|
class BaselineTenantAssignmentsRelationManager extends RelationManager
|
|
{
|
|
protected static string $relationship = 'tenantAssignments';
|
|
|
|
protected static ?string $title = 'Tenant assignments';
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Assign tenant (manage-gated).')
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 assignments have no row-level actions beyond delete.')
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for assignments in v1.')
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state encourages assigning a tenant.');
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->columns([
|
|
Tables\Columns\TextColumn::make('tenant.name')
|
|
->label('Tenant')
|
|
->searchable(),
|
|
Tables\Columns\TextColumn::make('assignedByUser.name')
|
|
->label('Assigned by')
|
|
->placeholder('—'),
|
|
Tables\Columns\TextColumn::make('created_at')
|
|
->label('Assigned at')
|
|
->dateTime()
|
|
->sortable(),
|
|
])
|
|
->headerActions([
|
|
$this->assignTenantAction(),
|
|
])
|
|
->actions([
|
|
$this->removeAssignmentAction(),
|
|
])
|
|
->emptyStateHeading('No tenants assigned')
|
|
->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.')
|
|
->emptyStateActions([
|
|
$this->assignTenantAction(),
|
|
]);
|
|
}
|
|
|
|
private function assignTenantAction(): Action
|
|
{
|
|
return Action::make('assign')
|
|
->label('Assign Tenant')
|
|
->icon('heroicon-o-plus')
|
|
->visible(fn (): bool => $this->hasManageCapability())
|
|
->form([
|
|
Select::make('tenant_id')
|
|
->label('Tenant')
|
|
->options(fn (): array => $this->getAvailableTenantOptions())
|
|
->required()
|
|
->searchable(),
|
|
])
|
|
->action(function (array $data): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User || ! $this->hasManageCapability()) {
|
|
Notification::make()
|
|
->title('Permission denied')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
/** @var BaselineProfile $profile */
|
|
$profile = $this->getOwnerRecord();
|
|
$tenantId = (int) $data['tenant_id'];
|
|
|
|
$existing = BaselineTenantAssignment::query()
|
|
->where('workspace_id', $profile->workspace_id)
|
|
->where('tenant_id', $tenantId)
|
|
->first();
|
|
|
|
if ($existing instanceof BaselineTenantAssignment) {
|
|
Notification::make()
|
|
->title('Tenant already assigned')
|
|
->body('This tenant already has a baseline assignment in this workspace.')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$assignment = BaselineTenantAssignment::create([
|
|
'workspace_id' => (int) $profile->workspace_id,
|
|
'tenant_id' => $tenantId,
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
'assigned_by_user_id' => (int) $user->getKey(),
|
|
]);
|
|
|
|
$this->auditAssignment($profile, $assignment, $user, 'created');
|
|
|
|
Notification::make()
|
|
->title('Tenant assigned')
|
|
->success()
|
|
->send();
|
|
});
|
|
}
|
|
|
|
private function removeAssignmentAction(): Action
|
|
{
|
|
return Action::make('remove')
|
|
->label('Remove')
|
|
->icon('heroicon-o-trash')
|
|
->color('danger')
|
|
->visible(fn (): bool => $this->hasManageCapability())
|
|
->requiresConfirmation()
|
|
->modalHeading('Remove tenant assignment')
|
|
->modalDescription('Are you sure you want to remove this tenant assignment? This will not delete any existing findings.')
|
|
->action(function (BaselineTenantAssignment $record): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User || ! $this->hasManageCapability()) {
|
|
Notification::make()
|
|
->title('Permission denied')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
/** @var BaselineProfile $profile */
|
|
$profile = $this->getOwnerRecord();
|
|
|
|
$this->auditAssignment($profile, $record, $user, 'removed');
|
|
|
|
$record->delete();
|
|
|
|
Notification::make()
|
|
->title('Assignment removed')
|
|
->success()
|
|
->send();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function getAvailableTenantOptions(): array
|
|
{
|
|
/** @var BaselineProfile $profile */
|
|
$profile = $this->getOwnerRecord();
|
|
|
|
$assignedTenantIds = BaselineTenantAssignment::query()
|
|
->where('workspace_id', $profile->workspace_id)
|
|
->pluck('tenant_id')
|
|
->all();
|
|
|
|
$query = Tenant::query()
|
|
->where('workspace_id', $profile->workspace_id)
|
|
->orderBy('name');
|
|
|
|
if (! empty($assignedTenantIds)) {
|
|
$query->whereNotIn('id', $assignedTenantIds);
|
|
}
|
|
|
|
return $query->pluck('name', 'id')->all();
|
|
}
|
|
|
|
private function auditAssignment(
|
|
BaselineProfile $profile,
|
|
BaselineTenantAssignment $assignment,
|
|
User $user,
|
|
string $action,
|
|
): void {
|
|
$workspace = Workspace::query()->find($profile->workspace_id);
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return;
|
|
}
|
|
|
|
$tenant = Tenant::query()->find($assignment->tenant_id);
|
|
|
|
$auditLogger = app(WorkspaceAuditLogger::class);
|
|
|
|
$auditLogger->log(
|
|
workspace: $workspace,
|
|
action: 'baseline.assignment.'.$action,
|
|
context: [
|
|
'baseline_profile_id' => (int) $profile->getKey(),
|
|
'baseline_profile_name' => (string) $profile->name,
|
|
'tenant_id' => (int) $assignment->tenant_id,
|
|
'tenant_name' => $tenant instanceof Tenant ? (string) $tenant->display_name : '—',
|
|
],
|
|
actor: $user,
|
|
resourceType: 'baseline_profile',
|
|
resourceId: (string) $profile->getKey(),
|
|
);
|
|
}
|
|
|
|
private function hasManageCapability(): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
if ($workspaceId === null) {
|
|
return false;
|
|
}
|
|
|
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return false;
|
|
}
|
|
|
|
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
|
|
|
|
return $resolver->isMember($user, $workspace)
|
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
|
}
|
|
}
|