TenantAtlas/app/Filament/Pages/Settings/WorkspaceSettings.php
ahmido c57f680f39 feat: Workspace settings slices v1 (backup, drift, operations) (#120)
Implements Spec 098: workspace-level settings slices for Backup retention, Drift severity mapping, and Operations retention/threshold.

Spec
- specs/098-settings-slices-v1-backup-drift-ops/spec.md

What changed
- Workspace Settings page: grouped Backup/Drift/Operations sections, unset-input UX w/ helper text, per-setting reset actions (confirmed)
- Settings registry: adds/updates validation + normalization (incl. drift severity mapping normalization to lowercase)
- Backup retention: adds workspace default + floor clamp; job clamps effective keep-last up to floor
- Drift findings: optional workspace severity mapping; adds `critical` severity support + badge mapping
- Operations pruning: retention computed per workspace via settings; scheduler unchanged; stuck threshold is storage-only

Safety / Compliance notes
- Filament v5 / Livewire v4: no Livewire v3 usage; relies on existing Filament v5 + Livewire v4 stack
- Provider registration unchanged (Laravel 11+/12 uses bootstrap/providers.php)
- Destructive actions: per-setting reset uses Filament actions with confirmation
- Global search: not affected (no resource changes)
- Assets: no new assets registered; no `filament:assets` changes

Tests
- vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php \
  tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php \
  tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php \
  tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php \
  tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php \
  tests/Unit/Badges/FindingBadgesTest.php

Formatting
- vendor/bin/sail bin pint --dirty

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #120
2026-02-16 03:18:33 +00:00

634 lines
21 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Pages\Settings;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Settings\SettingsResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\Capabilities;
use App\Support\Settings\SettingDefinition;
use App\Support\Settings\SettingsRegistry;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use UnitEnum;
class WorkspaceSettings extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'settings/workspace';
protected static ?string $title = 'Workspace settings';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
protected static ?int $navigationSort = 20;
/**
* @var array<string, array{domain: string, key: string, type: 'int'|'json'}>
*/
private const SETTING_FIELDS = [
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
];
public Workspace $workspace;
/**
* @var array<string, mixed>
*/
public array $data = [];
/**
* @var array<string, mixed>
*/
public array $workspaceOverrides = [];
/**
* @var array<string, array{source: string, value: mixed, system_default: mixed}>
*/
public array $resolvedSettings = [];
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('save')
->label('Save')
->action(function (): void {
$this->save();
})
->disabled(fn (): bool => ! $this->currentUserCanManage())
->tooltip(fn (): ?string => $this->currentUserCanManage()
? null
: 'You do not have permission to manage workspace settings.'),
];
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action saves settings; each setting includes a confirmed reset action.')
->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace settings are edited as a singleton form without a record inspect action.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The page does not render table rows with secondary actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The page has no bulk actions because it manages a single settings scope.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'The settings form is always rendered and has no list empty state.');
}
public function mount(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
$this->redirect('/admin/choose-workspace');
return;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
abort(404);
}
$this->workspace = $workspace;
$this->authorizeWorkspaceView($user);
$this->loadFormState();
}
public function content(Schema $schema): Schema
{
return $schema
->statePath('data')
->schema([
Section::make('Backup settings')
->description('Workspace defaults used when a schedule has no explicit value.')
->schema([
TextInput::make('backup_retention_keep_last_default')
->label('Default retention keep-last')
->placeholder('Unset (uses default)')
->numeric()
->integer()
->minValue(1)
->maxValue(365)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->helperTextFor('backup_retention_keep_last_default'))
->hintAction($this->makeResetAction('backup_retention_keep_last_default')),
TextInput::make('backup_retention_min_floor')
->label('Minimum retention floor')
->placeholder('Unset (uses default)')
->numeric()
->integer()
->minValue(1)
->maxValue(365)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->helperTextFor('backup_retention_min_floor'))
->hintAction($this->makeResetAction('backup_retention_min_floor')),
]),
Section::make('Drift settings')
->description('Map finding types to severities as JSON.')
->schema([
Textarea::make('drift_severity_mapping')
->label('Severity mapping (JSON object)')
->rows(8)
->placeholder("{\n \"drift\": \"critical\"\n}")
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->helperTextFor('drift_severity_mapping'))
->hintAction($this->makeResetAction('drift_severity_mapping')),
]),
Section::make('Operations settings')
->description('Workspace controls for operations retention and thresholds.')
->schema([
TextInput::make('operations_operation_run_retention_days')
->label('Operation run retention (days)')
->placeholder('Unset (uses default)')
->numeric()
->integer()
->minValue(7)
->maxValue(3650)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->helperTextFor('operations_operation_run_retention_days'))
->hintAction($this->makeResetAction('operations_operation_run_retention_days')),
TextInput::make('operations_stuck_run_threshold_minutes')
->label('Stuck run threshold (minutes)')
->placeholder('Unset (uses default)')
->numeric()
->integer()
->minValue(0)
->maxValue(10080)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->helperTextFor('operations_stuck_run_threshold_minutes'))
->hintAction($this->makeResetAction('operations_stuck_run_threshold_minutes')),
]),
]);
}
public function save(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceManage($user);
[$normalizedValues, $validationErrors] = $this->normalizedInputValues();
if ($validationErrors !== []) {
throw ValidationException::withMessages($validationErrors);
}
$writer = app(SettingsWriter::class);
$changedSettingsCount = 0;
foreach (self::SETTING_FIELDS as $field => $setting) {
$incomingValue = $normalizedValues[$field] ?? null;
$currentOverride = $this->workspaceOverrideForField($field);
if ($incomingValue === null) {
if ($currentOverride === null) {
continue;
}
$writer->resetWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: $setting['domain'],
key: $setting['key'],
);
$changedSettingsCount++;
continue;
}
if ($this->valuesEqual($incomingValue, $currentOverride)) {
continue;
}
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: $setting['domain'],
key: $setting['key'],
value: $incomingValue,
);
$changedSettingsCount++;
}
$this->loadFormState();
Notification::make()
->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save')
->success()
->send();
}
public function resetSetting(string $field): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceManage($user);
$setting = $this->settingForField($field);
if ($this->workspaceOverrideForField($field) === null) {
Notification::make()
->title('Setting already uses default')
->success()
->send();
return;
}
app(SettingsWriter::class)->resetWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: $setting['domain'],
key: $setting['key'],
);
$this->loadFormState();
Notification::make()
->title('Workspace setting reset to default')
->success()
->send();
}
private function loadFormState(): void
{
$resolver = app(SettingsResolver::class);
$data = [];
$workspaceOverrides = [];
$resolvedSettings = [];
foreach (self::SETTING_FIELDS as $field => $setting) {
$resolved = $resolver->resolveDetailed(
workspace: $this->workspace,
domain: $setting['domain'],
key: $setting['key'],
);
$workspaceValue = $resolved['workspace_value'];
$workspaceOverrides[$field] = $workspaceValue;
$resolvedSettings[$field] = [
'source' => $resolved['source'],
'value' => $resolved['value'],
'system_default' => $resolved['system_default'],
];
$data[$field] = $workspaceValue === null
? null
: $this->formatValueForInput($field, $workspaceValue);
}
$this->data = $data;
$this->workspaceOverrides = $workspaceOverrides;
$this->resolvedSettings = $resolvedSettings;
}
private function makeResetAction(string $field): Action
{
return Action::make('reset_'.$field)
->label('Reset')
->color('danger')
->requiresConfirmation()
->action(function () use ($field): void {
$this->resetSetting($field);
})
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->hasWorkspaceOverride($field))
->tooltip(function () use ($field): ?string {
if (! $this->currentUserCanManage()) {
return 'You do not have permission to manage workspace settings.';
}
if (! $this->hasWorkspaceOverride($field)) {
return 'No workspace override to reset.';
}
return null;
});
}
private function helperTextFor(string $field): string
{
$resolved = $this->resolvedSettings[$field] ?? null;
if (! is_array($resolved)) {
return '';
}
$effectiveValue = $this->formatValueForDisplay($field, $resolved['value'] ?? null);
if (! $this->hasWorkspaceOverride($field)) {
return sprintf(
'Unset. Effective value: %s (%s).',
$effectiveValue,
$this->sourceLabel((string) ($resolved['source'] ?? 'system_default')),
);
}
return sprintf('Effective value: %s.', $effectiveValue);
}
/**
* @return array{0: array<string, mixed>, 1: array<string, array<int, string>>}
*/
private function normalizedInputValues(): array
{
$normalizedValues = [];
$validationErrors = [];
foreach (self::SETTING_FIELDS as $field => $_setting) {
try {
$normalizedValues[$field] = $this->normalizeFieldInput(
field: $field,
value: $this->data[$field] ?? null,
);
} catch (ValidationException $exception) {
$messages = [];
foreach ($exception->errors() as $errorMessages) {
foreach ((array) $errorMessages as $message) {
$messages[] = (string) $message;
}
}
$validationErrors['data.'.$field] = $messages !== []
? $messages
: ['Invalid value.'];
}
}
return [$normalizedValues, $validationErrors];
}
private function normalizeFieldInput(string $field, mixed $value): mixed
{
$setting = $this->settingForField($field);
if ($value === null) {
return null;
}
if (is_string($value) && trim($value) === '') {
return null;
}
if ($setting['type'] === 'json') {
$value = $this->normalizeJsonInput($value);
}
$definition = $this->settingDefinition($field);
$validator = Validator::make(
data: ['value' => $value],
rules: ['value' => $definition->rules],
);
if ($validator->fails()) {
throw ValidationException::withMessages($validator->errors()->toArray());
}
return $definition->normalize($validator->validated()['value']);
}
private function normalizeJsonInput(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (! is_string($value)) {
throw ValidationException::withMessages([
'value' => ['The value must be valid JSON.'],
]);
}
$decoded = json_decode($value, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw ValidationException::withMessages([
'value' => ['The value must be valid JSON.'],
]);
}
if (! is_array($decoded)) {
throw ValidationException::withMessages([
'value' => ['The value must be a JSON object.'],
]);
}
return $decoded;
}
private function valuesEqual(mixed $left, mixed $right): bool
{
if ($left === null || $right === null) {
return $left === $right;
}
if (is_array($left) && is_array($right)) {
return $this->encodeCanonicalArray($left) === $this->encodeCanonicalArray($right);
}
if (is_numeric($left) && is_numeric($right)) {
return (int) $left === (int) $right;
}
return $left === $right;
}
private function encodeCanonicalArray(array $value): string
{
$encoded = json_encode($this->sortNestedArray($value));
return is_string($encoded) ? $encoded : '';
}
/**
* @param array<mixed> $value
* @return array<mixed>
*/
private function sortNestedArray(array $value): array
{
foreach ($value as $key => $item) {
if (! is_array($item)) {
continue;
}
$value[$key] = $this->sortNestedArray($item);
}
ksort($value);
return $value;
}
private function formatValueForInput(string $field, mixed $value): mixed
{
$setting = $this->settingForField($field);
if ($setting['type'] === 'json') {
if (! is_array($value)) {
return null;
}
$encoded = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
return is_string($encoded) ? $encoded : null;
}
return is_numeric($value) ? (int) $value : null;
}
private function formatValueForDisplay(string $field, mixed $value): string
{
$setting = $this->settingForField($field);
if ($setting['type'] === 'json') {
if (! is_array($value) || $value === []) {
return '{}';
}
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES);
return is_string($encoded) ? $encoded : '{}';
}
return is_numeric($value) ? (string) (int) $value : 'null';
}
private function sourceLabel(string $source): string
{
return match ($source) {
'workspace_override' => 'workspace override',
'tenant_override' => 'tenant override',
default => 'system default',
};
}
/**
* @return array{domain: string, key: string, type: 'int'|'json'}
*/
private function settingForField(string $field): array
{
if (! isset(self::SETTING_FIELDS[$field])) {
throw ValidationException::withMessages([
'data' => [sprintf('Unknown settings field: %s', $field)],
]);
}
return self::SETTING_FIELDS[$field];
}
private function settingDefinition(string $field): SettingDefinition
{
$setting = $this->settingForField($field);
return app(SettingsRegistry::class)->require($setting['domain'], $setting['key']);
}
private function hasWorkspaceOverride(string $field): bool
{
return $this->workspaceOverrideForField($field) !== null;
}
private function workspaceOverrideForField(string $field): mixed
{
$setting = $this->settingForField($field);
$resolved = app(SettingsResolver::class)->resolveDetailed(
workspace: $this->workspace,
domain: $setting['domain'],
key: $setting['key'],
);
return $resolved['workspace_value'];
}
private function currentUserCanManage(): bool
{
$user = auth()->user();
if (! $user instanceof User || ! $this->workspace instanceof Workspace) {
return false;
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
return $resolver->isMember($user, $this->workspace)
&& $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE);
}
private function authorizeWorkspaceView(User $user): void
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $this->workspace)) {
abort(404);
}
if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_VIEW)) {
abort(403);
}
}
private function authorizeWorkspaceManage(User $user): void
{
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $this->workspace)) {
abort(404);
}
if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) {
abort(403);
}
}
}