TenantAtlas/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php
2026-03-09 07:25:40 +01:00

330 lines
12 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';
/**
* @var array<int, array{baseline_profile_id:int, baseline_profile_name:string}>|null
*/
protected ?array $tenantAssignmentSummaries = null;
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
->defaultSort('created_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
->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()
->hidden(fn (): bool => $this->getOwnerRecord()->tenantAssignments()->doesntExist()),
])
->actions([
$this->removeAssignmentAction(),
])
->emptyStateHeading('No tenants assigned')
->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.')
->emptyStateActions([
$this->assignTenantAction()->name('assignEmpty'),
]);
}
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->getTenantOptions())
->disableOptionWhen(fn (string $value): bool => array_key_exists((int) $value, $this->getTenantAssignmentSummaries()))
->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) {
$assignedBaselineName = $this->getAssignedBaselineNameForTenant($tenantId);
Notification::make()
->title('Tenant already assigned')
->body($assignedBaselineName === null
? 'This tenant already has a baseline assignment in this workspace.'
: "This tenant is already assigned to baseline: {$assignedBaselineName}.")
->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();
$this->forgetTenantAssignmentSummaries();
});
}
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();
$this->forgetTenantAssignmentSummaries();
});
}
/**
* @return array<int, string>
*/
private function getTenantOptions(): array
{
/** @var BaselineProfile $profile */
$profile = $this->getOwnerRecord();
$assignmentSummaries = $this->getTenantAssignmentSummaries();
return Tenant::query()
->where('workspace_id', $profile->workspace_id)
->orderBy('name')
->get(['id', 'name'])
->mapWithKeys(function (Tenant $tenant) use ($assignmentSummaries): array {
$tenantId = (int) $tenant->getKey();
$assignmentSummary = $assignmentSummaries[$tenantId] ?? null;
return [
$tenantId => $this->formatTenantOptionLabel($tenant, $assignmentSummary),
];
})
->all();
}
/**
* @return array<int, array{baseline_profile_id:int, baseline_profile_name:string}>
*/
private function getTenantAssignmentSummaries(): array
{
if (is_array($this->tenantAssignmentSummaries)) {
return $this->tenantAssignmentSummaries;
}
/** @var BaselineProfile $profile */
$profile = $this->getOwnerRecord();
$this->tenantAssignmentSummaries = BaselineTenantAssignment::query()
->where('workspace_id', (int) $profile->workspace_id)
->with('baselineProfile:id,name')
->get(['tenant_id', 'baseline_profile_id'])
->mapWithKeys(function (BaselineTenantAssignment $assignment): array {
$baselineProfile = $assignment->baselineProfile;
return [
(int) $assignment->tenant_id => [
'baseline_profile_id' => (int) $assignment->baseline_profile_id,
'baseline_profile_name' => $baselineProfile instanceof BaselineProfile
? (string) $baselineProfile->name
: 'another baseline profile',
],
];
})
->all();
return $this->tenantAssignmentSummaries;
}
/**
* @param array{baseline_profile_id:int, baseline_profile_name:string}|null $assignmentSummary
*/
private function formatTenantOptionLabel(
Tenant $tenant,
?array $assignmentSummary,
): string {
$tenantName = (string) $tenant->name;
if ($assignmentSummary === null) {
return $tenantName;
}
return "{$tenantName} (assigned to baseline: {$assignmentSummary['baseline_profile_name']})";
}
private function getAssignedBaselineNameForTenant(int $tenantId): ?string
{
$assignmentSummary = $this->getTenantAssignmentSummaries()[$tenantId] ?? null;
if ($assignmentSummary === null) {
return null;
}
return $assignmentSummary['baseline_profile_name'];
}
private function forgetTenantAssignmentSummaries(): void
{
$this->tenantAssignmentSummaries = null;
}
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);
}
}