TenantAtlas/app/Filament/Pages/Settings/WorkspaceSettings.php
ahmido 7ac53f4cc4 feat(111): findings workflow + SLA settings (#135)
Implements spec 111 (Findings workflow + SLA) and fixes Workspace findings SLA settings UX/validation.

Key changes:
- Findings workflow service + SLA policy and alerting.
- Workspace settings: allow partial SLA overrides without auto-filling unset severities in the UI; effective values still resolve via defaults.
- New migrations, jobs, command, UI/resource updates, and comprehensive test coverage.

Tests:
- `vendor/bin/sail artisan test --compact` (1779 passed, 8 skipped).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #135
2026-02-25 01:48:01 +00:00

979 lines
33 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace App\Filament\Pages\Settings;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
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\KeyValue;
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\Carbon;
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'],
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', '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'],
];
/**
* Fields rendered as Filament KeyValue components (array state, not JSON string).
*
* @var array<int, string>
*/
private const KEYVALUE_FIELDS = [
'drift_severity_mapping',
];
/**
* Findings SLA days are decomposed into individual form fields per severity.
*
* @var array<string, string>
*/
private const SLA_SUB_FIELDS = [
'findings_sla_critical' => 'critical',
'findings_sla_high' => 'high',
'findings_sla_medium' => 'medium',
'findings_sla_low' => 'low',
];
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 = [];
/**
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
*
* @var array<string, array{user_name: string, updated_at: Carbon}>
*/
public array $domainLastModified = [];
/**
* @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($this->sectionDescription('backup', '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)')
->suffix('versions')
->hint('1 365')
->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)')
->suffix('versions')
->hint('1 365')
->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($this->sectionDescription('drift', 'Map finding types to severity levels. Allowed severities: critical, high, medium, low.'))
->schema([
KeyValue::make('drift_severity_mapping')
->label('Severity mapping')
->keyLabel('Finding type')
->valueLabel('Severity')
->keyPlaceholder('e.g. drift')
->valuePlaceholder('critical, high, medium, or low')
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->helperTextFor('drift_severity_mapping'))
->hintAction($this->makeResetAction('drift_severity_mapping')),
]),
Section::make('Findings settings')
->key('findings_section')
->description($this->sectionDescription('findings', 'Configure workspace-wide SLA days by severity. Set one or more, or leave all empty to use the system default. Unset severities use their default.'))
->columns(2)
->afterHeader([
$this->makeResetAction('findings_sla_days')->label('Reset all SLA')->size('sm'),
])
->schema([
TextInput::make('findings_sla_critical')
->label('Critical severity')
->placeholder('Unset (uses default)')
->suffix('days')
->hint('1 3,650')
->numeric()
->integer()
->minValue(1)
->maxValue(3650)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->slaFieldHelperText('critical')),
TextInput::make('findings_sla_high')
->label('High severity')
->placeholder('Unset (uses default)')
->suffix('days')
->hint('1 3,650')
->numeric()
->integer()
->minValue(1)
->maxValue(3650)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->slaFieldHelperText('high')),
TextInput::make('findings_sla_medium')
->label('Medium severity')
->placeholder('Unset (uses default)')
->suffix('days')
->hint('1 3,650')
->numeric()
->integer()
->minValue(1)
->maxValue(3650)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->slaFieldHelperText('medium')),
TextInput::make('findings_sla_low')
->label('Low severity')
->placeholder('Unset (uses default)')
->suffix('days')
->hint('1 3,650')
->numeric()
->integer()
->minValue(1)
->maxValue(3650)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->slaFieldHelperText('low')),
]),
Section::make('Operations settings')
->description($this->sectionDescription('operations', 'Workspace controls for operations retention and thresholds.'))
->schema([
TextInput::make('operations_operation_run_retention_days')
->label('Operation run retention')
->placeholder('Unset (uses default)')
->suffix('days')
->hint('7 3,650')
->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')
->placeholder('Unset (uses default)')
->suffix('minutes')
->hint('0 10,080')
->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);
$this->resetValidation();
$this->composeSlaSubFieldsIntoData();
[$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
? (in_array($field, self::KEYVALUE_FIELDS, true) ? [] : null)
: $this->formatValueForInput($field, $workspaceValue);
}
$this->decomposeSlaSubFields($data, $workspaceOverrides, $resolvedSettings);
$this->data = $data;
$this->workspaceOverrides = $workspaceOverrides;
$this->resolvedSettings = $resolvedSettings;
$this->loadDomainLastModified();
}
/**
* Load per-domain "last modified" metadata from workspace_settings.
*/
private function loadDomainLastModified(): void
{
$domains = array_unique(array_column(self::SETTING_FIELDS, 'domain'));
$records = WorkspaceSetting::query()
->where('workspace_id', (int) $this->workspace->getKey())
->whereIn('domain', $domains)
->whereNotNull('updated_by_user_id')
->with('updatedByUser:id,name')
->get();
$domainInfo = [];
foreach ($records as $record) {
/** @var WorkspaceSetting $record */
$domain = $record->domain;
$updatedAt = $record->updated_at;
if (! $updatedAt instanceof Carbon) {
continue;
}
if (isset($domainInfo[$domain]) && $domainInfo[$domain]['updated_at']->gte($updatedAt)) {
continue;
}
$user = $record->updatedByUser;
$domainInfo[$domain] = [
'user_name' => $user instanceof User ? $user->name : 'Unknown',
'updated_at' => $updatedAt,
];
}
$this->domainLastModified = $domainInfo;
}
/**
* Build a section description that appends "last modified" info when available.
*/
private function sectionDescription(string $domain, string $baseDescription): string
{
$meta = $this->domainLastModified[$domain] ?? null;
if (! is_array($meta)) {
return $baseDescription;
}
/** @var Carbon $updatedAt */
$updatedAt = $meta['updated_at'];
return sprintf(
'%s — Last modified by %s, %s.',
$baseDescription,
$meta['user_name'],
$updatedAt->diffForHumans(),
);
}
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);
}
private function slaFieldHelperText(string $severity): string
{
$resolved = $this->resolvedSettings['findings_sla_days'] ?? null;
if (! is_array($resolved)) {
return '';
}
$effectiveValue = is_array($resolved['value'] ?? null)
? (int) ($resolved['value'][$severity] ?? 0)
: 0;
$systemDefault = is_array($resolved['system_default'] ?? null)
? (int) ($resolved['system_default'][$severity] ?? 0)
: 0;
if (! $this->hasWorkspaceOverride('findings_sla_days')) {
return sprintf('Default: %d days.', $systemDefault);
}
return sprintf('Effective: %d days.', $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;
}
}
if ($field === 'findings_sla_days') {
$severityToField = array_flip(self::SLA_SUB_FIELDS);
$targeted = false;
foreach ($messages as $message) {
if (preg_match('/include "(?<severity>critical|high|medium|low)"/i', $message, $matches) === 1) {
$severity = strtolower((string) $matches['severity']);
$subField = $severityToField[$severity] ?? null;
if (is_string($subField)) {
$validationErrors['data.'.$subField] ??= [];
$validationErrors['data.'.$subField][] = $message;
$targeted = true;
}
}
}
if (! $targeted) {
foreach (self::SLA_SUB_FIELDS as $subField => $_severity) {
$validationErrors['data.'.$subField] = $messages !== []
? $messages
: ['Invalid value.'];
}
}
continue;
}
$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 (is_array($value) && $value === []) {
return null;
}
if ($setting['type'] === 'json') {
$value = $this->normalizeJsonInput($value);
if (in_array($field, self::KEYVALUE_FIELDS, true)) {
$value = $this->normalizeKeyValueInput($value);
if ($value === []) {
return null;
}
}
}
$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']);
}
/**
* Normalize KeyValue component state.
*
* Filament's KeyValue UI keeps an empty row by default, which can submit as
* ['' => ''] and would otherwise fail validation. We treat empty rows as unset.
*
* @param array<mixed> $value
* @return array<string, mixed>
*/
private function normalizeKeyValueInput(array $value): array
{
$normalized = [];
foreach ($value as $key => $item) {
if (is_array($item) && array_key_exists('key', $item)) {
$rowKey = $item['key'];
$rowValue = $item['value'] ?? null;
if (! is_string($rowKey)) {
continue;
}
$trimmedKey = trim($rowKey);
if ($trimmedKey === '') {
continue;
}
if (is_string($rowValue)) {
$trimmedValue = trim($rowValue);
if ($trimmedValue === '') {
continue;
}
$normalized[$trimmedKey] = $trimmedValue;
continue;
}
if ($rowValue === null) {
continue;
}
$normalized[$trimmedKey] = $rowValue;
continue;
}
if (! is_string($key)) {
continue;
}
$trimmedKey = trim($key);
if ($trimmedKey === '') {
continue;
}
if (is_string($item)) {
$trimmedValue = trim($item);
if ($trimmedValue === '') {
continue;
}
$normalized[$trimmedKey] = $trimmedValue;
continue;
}
if ($item === null) {
continue;
}
$normalized[$trimmedKey] = $item;
}
return $normalized;
}
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;
}
if (in_array($field, self::KEYVALUE_FIELDS, true)) {
return $value;
}
$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
{
return $this->workspaceOverrides[$field] ?? null;
}
/**
* Decompose the findings_sla_days JSON setting into individual SLA sub-fields.
*
* @param array<string, mixed> $data
* @param array<string, mixed> $workspaceOverrides
* @param array<string, array{source: string, value: mixed, system_default: mixed}> $resolvedSettings
*/
private function decomposeSlaSubFields(array &$data, array &$workspaceOverrides, array &$resolvedSettings): void
{
$slaOverride = $workspaceOverrides['findings_sla_days'] ?? null;
$slaResolved = $resolvedSettings['findings_sla_days'] ?? null;
foreach (self::SLA_SUB_FIELDS as $subField => $severity) {
$data[$subField] = is_array($slaOverride) && isset($slaOverride[$severity])
? (int) $slaOverride[$severity]
: null;
}
}
/**
* Re-compose individual SLA sub-fields back into the findings_sla_days data key before save.
*/
private function composeSlaSubFieldsIntoData(): void
{
$values = [];
$hasAnyValue = false;
foreach (self::SLA_SUB_FIELDS as $subField => $severity) {
$val = $this->data[$subField] ?? null;
if ($val !== null && (is_string($val) ? trim($val) !== '' : true)) {
$values[$severity] = (int) $val;
$hasAnyValue = true;
}
}
$this->data['findings_sla_days'] = $hasAnyValue ? $values : null;
}
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);
}
}
}