Implements Spec 114 System Console Control Tower pages, widgets, triage actions, directory views, and enterprise polish (badges, repair workspace owners table, health indicator).
279 lines
10 KiB
PHP
279 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\System\Pages;
|
|
|
|
use App\Filament\System\Widgets\RepairWorkspaceOwnersStats;
|
|
use App\Models\AuditLog;
|
|
use App\Models\PlatformUser;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Models\WorkspaceMembership;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Services\Auth\BreakGlassSession;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Auth\PlatformCapabilities;
|
|
use App\Support\Auth\WorkspaceRole;
|
|
use Filament\Actions\Action;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Concerns\InteractsWithTable;
|
|
use Filament\Tables\Contracts\HasTable;
|
|
use Filament\Tables\Table;
|
|
use Filament\Widgets\Widget;
|
|
use Filament\Widgets\WidgetConfiguration;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
|
|
class RepairWorkspaceOwners extends Page implements HasTable
|
|
{
|
|
use InteractsWithTable;
|
|
|
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
|
|
|
|
protected static ?string $navigationLabel = 'Repair workspace owners';
|
|
|
|
protected static string|\UnitEnum|null $navigationGroup = 'Recovery';
|
|
|
|
protected string $view = 'filament.system.pages.repair-workspace-owners';
|
|
|
|
public static function canAccess(): bool
|
|
{
|
|
$user = auth('platform')->user();
|
|
|
|
if (! $user instanceof PlatformUser) {
|
|
return false;
|
|
}
|
|
|
|
return $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
|
|
}
|
|
|
|
public static function getNavigationBadge(): ?string
|
|
{
|
|
$total = Workspace::query()->count();
|
|
$withOwners = WorkspaceMembership::query()
|
|
->where('role', WorkspaceRole::Owner->value)
|
|
->distinct('workspace_id')
|
|
->count('workspace_id');
|
|
|
|
$ownerless = $total - $withOwners;
|
|
|
|
return $ownerless > 0 ? (string) $ownerless : null;
|
|
}
|
|
|
|
public static function getNavigationBadgeColor(): string|array|null
|
|
{
|
|
return 'danger';
|
|
}
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->mountInteractsWithTable();
|
|
}
|
|
|
|
/**
|
|
* @return array<class-string<Widget>|WidgetConfiguration>
|
|
*/
|
|
protected function getHeaderWidgets(): array
|
|
{
|
|
return [
|
|
RepairWorkspaceOwnersStats::class,
|
|
];
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->heading('Workspaces')
|
|
->description('Current workspace ownership status.')
|
|
->defaultSort('name', 'asc')
|
|
->query(function (): Builder {
|
|
return Workspace::query()
|
|
->withCount([
|
|
'memberships as owner_count' => function (Builder $query): void {
|
|
$query->where('role', WorkspaceRole::Owner->value);
|
|
},
|
|
'memberships as member_count',
|
|
'tenants as tenant_count',
|
|
]);
|
|
})
|
|
->columns([
|
|
TextColumn::make('name')
|
|
->label('Workspace')
|
|
->searchable()
|
|
->sortable(),
|
|
TextColumn::make('owner_count')
|
|
->label('Owners')
|
|
->badge()
|
|
->color(fn (int $state): string => $state > 0 ? 'success' : 'danger')
|
|
->sortable(),
|
|
TextColumn::make('member_count')
|
|
->label('Members')
|
|
->sortable(),
|
|
TextColumn::make('tenant_count')
|
|
->label('Tenants')
|
|
->sortable(),
|
|
TextColumn::make('updated_at')
|
|
->label('Last activity')
|
|
->since()
|
|
->sortable(),
|
|
])
|
|
->emptyStateHeading('No workspaces')
|
|
->emptyStateDescription('No workspaces exist in the system yet.')
|
|
->bulkActions([]);
|
|
}
|
|
|
|
/**
|
|
* @return array<array{action: string, actor: string|null, workspace: string|null, recorded_at: string}>
|
|
*/
|
|
public function getRecentBreakGlassActions(): array
|
|
{
|
|
return AuditLog::query()
|
|
->where('action', 'like', '%break_glass%')
|
|
->orderByDesc('recorded_at')
|
|
->limit(10)
|
|
->get()
|
|
->map(fn (AuditLog $log): array => [
|
|
'action' => (string) $log->action,
|
|
'actor' => $log->actor_email ?: 'Unknown',
|
|
'workspace' => $log->metadata['metadata']['workspace_id'] ?? null
|
|
? Workspace::query()->whereKey((int) $log->metadata['metadata']['workspace_id'])->value('name')
|
|
: null,
|
|
'recorded_at' => $log->recorded_at?->diffForHumans() ?? 'Unknown',
|
|
])
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<Action>
|
|
*/
|
|
protected function getHeaderActions(): array
|
|
{
|
|
$breakGlass = app(BreakGlassSession::class);
|
|
|
|
return [
|
|
Action::make('assign_owner')
|
|
->label('Emergency: Assign Owner')
|
|
->icon('heroicon-o-shield-exclamation')
|
|
->color('danger')
|
|
->requiresConfirmation()
|
|
->modalHeading('Assign workspace owner')
|
|
->modalDescription('This is a recovery action. It is audited and should only be used when the workspace owner set is broken.')
|
|
->form([
|
|
Select::make('workspace_id')
|
|
->label('Workspace')
|
|
->required()
|
|
->searchable()
|
|
->getSearchResultsUsing(function (string $search): array {
|
|
return Workspace::query()
|
|
->where('name', 'like', "%{$search}%")
|
|
->orderBy('name')
|
|
->limit(25)
|
|
->pluck('name', 'id')
|
|
->all();
|
|
})
|
|
->getOptionLabelUsing(function ($value): ?string {
|
|
if (! is_numeric($value)) {
|
|
return null;
|
|
}
|
|
|
|
return Workspace::query()->whereKey((int) $value)->value('name');
|
|
}),
|
|
|
|
Select::make('target_user_id')
|
|
->label('User')
|
|
->required()
|
|
->searchable()
|
|
->getSearchResultsUsing(function (string $search): array {
|
|
return User::query()
|
|
->where('email', 'like', "%{$search}%")
|
|
->orderBy('email')
|
|
->limit(25)
|
|
->pluck('email', 'id')
|
|
->all();
|
|
})
|
|
->getOptionLabelUsing(function ($value): ?string {
|
|
if (! is_numeric($value)) {
|
|
return null;
|
|
}
|
|
|
|
return User::query()->whereKey((int) $value)->value('email');
|
|
}),
|
|
|
|
Textarea::make('reason')
|
|
->label('Reason')
|
|
->required()
|
|
->minLength(5)
|
|
->maxLength(500)
|
|
->rows(4),
|
|
])
|
|
->action(function (array $data, BreakGlassSession $breakGlass, WorkspaceAuditLogger $auditLogger): void {
|
|
$platformUser = auth('platform')->user();
|
|
|
|
if (! $platformUser instanceof PlatformUser) {
|
|
abort(403);
|
|
}
|
|
|
|
if (! $platformUser->hasCapability(PlatformCapabilities::USE_BREAK_GLASS)) {
|
|
abort(403);
|
|
}
|
|
|
|
if (! $breakGlass->isActive()) {
|
|
abort(403);
|
|
}
|
|
|
|
$workspaceId = (int) ($data['workspace_id'] ?? 0);
|
|
$targetUserId = (int) ($data['target_user_id'] ?? 0);
|
|
$reason = (string) ($data['reason'] ?? '');
|
|
|
|
$workspace = Workspace::query()->whereKey($workspaceId)->firstOrFail();
|
|
$targetUser = User::query()->whereKey($targetUserId)->firstOrFail();
|
|
|
|
$membership = WorkspaceMembership::query()->firstOrNew([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'user_id' => (int) $targetUser->getKey(),
|
|
]);
|
|
|
|
$fromRole = $membership->exists ? (string) $membership->role : null;
|
|
|
|
$membership->forceFill([
|
|
'role' => WorkspaceRole::Owner->value,
|
|
])->save();
|
|
|
|
$auditLogger->log(
|
|
workspace: $workspace,
|
|
action: AuditActionId::WorkspaceMembershipBreakGlassAssignOwner->value,
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'actor_user_id' => (int) $platformUser->getKey(),
|
|
'target_user_id' => (int) $targetUser->getKey(),
|
|
'attempted_role' => WorkspaceRole::Owner->value,
|
|
'from_role' => $fromRole,
|
|
'reason' => trim($reason),
|
|
'source' => 'break_glass',
|
|
],
|
|
],
|
|
actor: null,
|
|
status: 'success',
|
|
resourceType: 'workspace',
|
|
resourceId: (string) $workspace->getKey(),
|
|
actorId: (int) $platformUser->getKey(),
|
|
actorEmail: $platformUser->email,
|
|
actorName: $platformUser->name,
|
|
);
|
|
|
|
Notification::make()
|
|
->title('Owner assigned')
|
|
->success()
|
|
->send();
|
|
})
|
|
->disabled(fn (): bool => ! $breakGlass->isActive())
|
|
->tooltip(fn (): ?string => ! $breakGlass->isActive() ? 'Activate break-glass mode on the Dashboard first.' : null),
|
|
];
|
|
}
|
|
}
|