Feature branch PR for Spec 114. This branch contains the merged agent session work (see merge commit on branch). Tests - `vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #139
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),
|
|
];
|
|
}
|
|
}
|