*/ 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 */ public array $data = []; /** * @var array */ public array $workspaceOverrides = []; /** * @var array */ public array $resolvedSettings = []; /** * @return array */ 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, 1: array>} */ 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 $value * @return array */ 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); } } }