refactor: fix 7 CRUD system inconsistencies for robustness and maintainability
- Add user-assignment dependency check to DepartmentService.deleteByUuid (409 when users assigned)
- Standardize POST detection to isMethod('POST') in 10 create/edit actions
- Align RoleService code uniqueness to warning pattern (matching departments)
- Normalize repository create() return types from mixed to int|false (3 repos + 3 interfaces)
- Add JSON response branches to 4 non-user delete actions
- Document delete authorization ordering pattern
- Decompose AdminSettingsService.updateFromAdmin into domain-scoped private methods
Workflow: .agents/runs/CRUD-CONSISTENCY-001/
Reviewed by: Code Reviewer (pass), Security Reviewer (pass), Acceptance Tester (pass)
Quality gates: QG-001 PHPUnit pass, QG-002 PHPStan pass, QG-003 Architecture pass
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -514,6 +514,7 @@
|
||||
"Department not found": "Abteilung nicht gefunden",
|
||||
"Department can not be created": "Abteilung kann nicht erstellt werden",
|
||||
"Department can not be updated": "Abteilung kann nicht aktualisiert werden",
|
||||
"Department can not be deleted while users are assigned": "Abteilung kann nicht gelöscht werden, solange Benutzer zugewiesen sind",
|
||||
"Delete this department?": "Diese Abteilung wirklich löschen?",
|
||||
"Delete department": "Abteilung löschen",
|
||||
"This will permanently delete this department.": "Diese Abteilung wird dauerhaft gelöscht.",
|
||||
|
||||
@@ -514,6 +514,7 @@
|
||||
"Department not found": "Department not found",
|
||||
"Department can not be created": "Department can not be created",
|
||||
"Department can not be updated": "Department can not be updated",
|
||||
"Department can not be deleted while users are assigned": "Department can not be deleted while users are assigned",
|
||||
"Delete this department?": "Delete this department?",
|
||||
"Delete department": "Delete department",
|
||||
"This will permanently delete this department.": "This will permanently delete this department.",
|
||||
|
||||
@@ -119,7 +119,7 @@ class RoleRepository implements RoleRepositoryInterface
|
||||
return $count ? ((int) $count > 0) : false;
|
||||
}
|
||||
|
||||
public function create(array $data): mixed
|
||||
public function create(array $data): int|false
|
||||
{
|
||||
return DB::insert(
|
||||
'insert into roles (uuid, description, code, active, created_by, created) values (?,?,?,?,?,NOW())',
|
||||
|
||||
@@ -23,7 +23,7 @@ interface RoleRepositoryInterface
|
||||
|
||||
public function existsByCode(string $code, int $excludeId = 0): bool;
|
||||
|
||||
public function create(array $data): mixed;
|
||||
public function create(array $data): int|false;
|
||||
|
||||
public function update(int $id, array $data): bool;
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ class DepartmentRepository implements DepartmentRepositoryInterface
|
||||
return $count ? (int) $count : 0;
|
||||
}
|
||||
|
||||
public function create(array $data): mixed
|
||||
public function create(array $data): int|false
|
||||
{
|
||||
return DB::insert(
|
||||
'insert into departments (uuid, description, tenant_id, code, cost_center, active, created_by, created) values (?,?,?,?,?,?,?,NOW())',
|
||||
|
||||
@@ -27,7 +27,7 @@ interface DepartmentRepositoryInterface
|
||||
|
||||
public function countByTenantId(int $tenantId): int;
|
||||
|
||||
public function create(array $data): mixed;
|
||||
public function create(array $data): int|false;
|
||||
|
||||
public function update(int $id, array $data): bool;
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ class TenantRepository implements TenantRepositoryInterface
|
||||
return RepositoryArrayHelper::unwrap($row, 'tenants');
|
||||
}
|
||||
|
||||
public function create(array $data): mixed
|
||||
public function create(array $data): int|false
|
||||
{
|
||||
return DB::insert(
|
||||
'insert into tenants (uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, created) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())',
|
||||
|
||||
@@ -19,7 +19,7 @@ interface TenantRepositoryInterface
|
||||
|
||||
public function findByUuid(string $uuid): ?array;
|
||||
|
||||
public function create(array $data): mixed;
|
||||
public function create(array $data): int|false;
|
||||
|
||||
public function update(int $id, array $data): bool;
|
||||
|
||||
|
||||
@@ -43,10 +43,11 @@ class RoleService
|
||||
public function createFromAdmin(array $input, int $currentUserId = 0): array
|
||||
{
|
||||
$form = $this->sanitizeBase($input);
|
||||
$errors = $this->validateBase($form, 0);
|
||||
$errors = $this->validateBase($form);
|
||||
$warnings = $this->warningsForCode($form, 0);
|
||||
|
||||
if ($errors) {
|
||||
return ['ok' => false, 'errors' => $errors, 'form' => $form];
|
||||
return ['ok' => false, 'errors' => $errors, 'warnings' => $warnings, 'form' => $form];
|
||||
}
|
||||
|
||||
$createdId = $this->roleRepository->create([
|
||||
@@ -74,16 +75,17 @@ class RoleService
|
||||
'metadata' => ['active' => $form['active']],
|
||||
]);
|
||||
|
||||
return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId];
|
||||
return ['ok' => true, 'form' => $form, 'warnings' => $warnings, 'uuid' => $uuid, 'id' => (int) $createdId];
|
||||
}
|
||||
|
||||
public function updateFromAdmin(int $roleId, array $input, int $currentUserId = 0): array
|
||||
{
|
||||
$form = $this->sanitizeBase($input);
|
||||
$errors = $this->validateBase($form, $roleId);
|
||||
$errors = $this->validateBase($form);
|
||||
$warnings = $this->warningsForCode($form, $roleId);
|
||||
|
||||
if ($errors) {
|
||||
return ['ok' => false, 'errors' => $errors, 'form' => $form];
|
||||
return ['ok' => false, 'errors' => $errors, 'warnings' => $warnings, 'form' => $form];
|
||||
}
|
||||
|
||||
$existingBefore = $this->roleRepository->find($roleId) ?? [];
|
||||
@@ -108,7 +110,7 @@ class RoleService
|
||||
'after' => ['active' => $form['active']],
|
||||
]);
|
||||
|
||||
return ['ok' => true, 'form' => $form];
|
||||
return ['ok' => true, 'form' => $form, 'warnings' => $warnings];
|
||||
}
|
||||
|
||||
public function deleteByUuid(string $uuid): array
|
||||
@@ -150,17 +152,25 @@ class RoleService
|
||||
];
|
||||
}
|
||||
|
||||
private function validateBase(array $form, int $excludeId): array
|
||||
private function validateBase(array $form): array
|
||||
{
|
||||
$errors = [];
|
||||
if ($form['description'] === '') {
|
||||
$errors[] = t('Description cannot be empty');
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
// Duplicate codes are a warning, not a hard error — codes are optional identifiers,
|
||||
// not unique keys, so duplicates are allowed but surfaced to the user.
|
||||
private function warningsForCode(array $form, int $excludeId): array
|
||||
{
|
||||
$warnings = [];
|
||||
$code = $form['code'] ?? '';
|
||||
if ($code !== '' && $this->roleRepository->existsByCode($code, $excludeId)) {
|
||||
$errors[] = t('Role code already exists');
|
||||
$warnings[] = t('Role code already exists');
|
||||
}
|
||||
return $errors;
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
private function normalizeActive($value): int
|
||||
|
||||
@@ -235,14 +235,22 @@ class DepartmentService
|
||||
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$deleted = $this->departmentRepository->delete((int) $department['id']);
|
||||
$departmentId = (int) $department['id'];
|
||||
$userDeptRepo = $this->userServicesFactory->createUserDepartmentRepository();
|
||||
$counts = $userDeptRepo->countUsersByDepartmentIds([$departmentId]);
|
||||
$userCount = (int) ($counts[$departmentId] ?? 0);
|
||||
if ($userCount > 0) {
|
||||
return ['ok' => false, 'status' => 409, 'error' => 'department_has_users', 'user_count' => $userCount];
|
||||
}
|
||||
|
||||
$deleted = $this->departmentRepository->delete($departmentId);
|
||||
if (!$deleted) {
|
||||
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
|
||||
}
|
||||
|
||||
$this->systemAuditService->record('admin.departments.delete', 'success', [
|
||||
'target_type' => 'department',
|
||||
'target_id' => (int) $department['id'],
|
||||
'target_id' => $departmentId,
|
||||
'target_uuid' => (string) ($department['uuid'] ?? ''),
|
||||
]);
|
||||
|
||||
|
||||
@@ -124,42 +124,81 @@ class AdminSettingsService
|
||||
|
||||
public function updateFromAdmin(array $post): array
|
||||
{
|
||||
$defaultTenantId = (int) ($post['default_tenant_id'] ?? 0);
|
||||
$defaultRoleId = (int) ($post['default_role_id'] ?? 0);
|
||||
$defaultDepartmentId = (int) ($post['default_department_id'] ?? 0);
|
||||
$appTitle = trim((string) ($post['app_title'] ?? ''));
|
||||
$appLocale = trim((string) ($post['app_locale'] ?? ''));
|
||||
$appTheme = trim((string) ($post['app_theme'] ?? ''));
|
||||
$appThemeUser = isset($post['app_theme_user']);
|
||||
$appRegistration = isset($post['app_registration']);
|
||||
$apiTokenDefaultTtlDays = (int) ($post['api_token_default_ttl_days'] ?? 0);
|
||||
$apiTokenMaxTtlDays = (int) ($post['api_token_max_ttl_days'] ?? 0);
|
||||
$userInactivityDeactivateDays = (int) ($post['user_inactivity_deactivate_days'] ?? 0);
|
||||
$userInactivityDeleteDays = (int) ($post['user_inactivity_delete_days'] ?? 0);
|
||||
$sessionIdleTimeoutMinutes = (int) ($post['session_idle_timeout_minutes'] ?? 0);
|
||||
$sessionAbsoluteTimeoutHours = (int) ($post['session_absolute_timeout_hours'] ?? 0);
|
||||
$microsoftAutoRememberDefault = isset($post['microsoft_auto_remember_default']);
|
||||
$rememberTokenLifetimeDays = (int) ($post['remember_token_lifetime_days'] ?? 0);
|
||||
$apiCorsAllowedOrigins = $this->normalizeScalarText($post['api_cors_allowed_origins'] ?? '');
|
||||
$systemAuditEnabled = isset($post['system_audit_enabled']);
|
||||
$systemAuditRetentionDays = (int) ($post['system_audit_retention_days'] ?? 0);
|
||||
$frontendTelemetryEnabled = isset($post['frontend_telemetry_enabled']);
|
||||
$frontendTelemetrySampleRate = $this->normalizeScalarText($post['frontend_telemetry_sample_rate'] ?? '');
|
||||
$frontendTelemetryAllowedEvents = $post['frontend_telemetry_allowed_events'] ?? [];
|
||||
$appPrimaryColor = $this->normalizePrimaryColor($post['app_primary_color'] ?? '');
|
||||
$smtpHost = trim((string) ($post['smtp_host'] ?? ''));
|
||||
$smtpPort = (int) ($post['smtp_port'] ?? 0);
|
||||
$smtpUser = trim((string) ($post['smtp_user'] ?? ''));
|
||||
$smtpPassword = (string) ($post['smtp_password'] ?? '');
|
||||
$smtpSecure = trim((string) ($post['smtp_secure'] ?? ''));
|
||||
$smtpFrom = trim((string) ($post['smtp_from'] ?? ''));
|
||||
$smtpFromName = trim((string) ($post['smtp_from_name'] ?? ''));
|
||||
$microsoftSharedClientId = trim((string) ($post['microsoft_shared_client_id'] ?? ''));
|
||||
$microsoftSharedClientSecret = (string) ($post['microsoft_shared_client_secret'] ?? '');
|
||||
$microsoftAuthority = trim((string) ($post['microsoft_authority'] ?? ''));
|
||||
|
||||
$input = $this->sanitizeAdminInput($post);
|
||||
$beforeAudit = $this->captureAuditSnapshot();
|
||||
$errors = [];
|
||||
$beforeAudit = [
|
||||
|
||||
$this->applyDefaultsAndAppSettings($input);
|
||||
array_push($errors, ...$this->applyApiAndLifecycleSettings($input));
|
||||
array_push($errors, ...$this->applySessionAndLoginSettings($input));
|
||||
array_push($errors, ...$this->applyAuditAndTelemetrySettings($input));
|
||||
array_push($errors, ...$this->applyAppPrimaryColor($input));
|
||||
$this->applySmtpSettings($input);
|
||||
array_push($errors, ...$this->applyMicrosoftSettings($input));
|
||||
$this->updateSettingsCache($input);
|
||||
|
||||
$afterAudit = $this->captureAuditSnapshot();
|
||||
$this->recordAudit($errors, $beforeAudit, $afterAudit);
|
||||
|
||||
return ['errors' => $errors];
|
||||
}
|
||||
|
||||
private function sanitizeAdminInput(array $post): array
|
||||
{
|
||||
return array_merge(
|
||||
$this->sanitizeAppAndPolicyInput($post),
|
||||
$this->sanitizeIntegrationInput($post),
|
||||
);
|
||||
}
|
||||
|
||||
private function sanitizeAppAndPolicyInput(array $post): array
|
||||
{
|
||||
return [
|
||||
'default_tenant_id' => (int) ($post['default_tenant_id'] ?? 0),
|
||||
'default_role_id' => (int) ($post['default_role_id'] ?? 0),
|
||||
'default_department_id' => (int) ($post['default_department_id'] ?? 0),
|
||||
'app_title' => trim((string) ($post['app_title'] ?? '')),
|
||||
'app_locale' => trim((string) ($post['app_locale'] ?? '')),
|
||||
'app_theme' => trim((string) ($post['app_theme'] ?? '')),
|
||||
'app_theme_user' => isset($post['app_theme_user']),
|
||||
'app_registration' => isset($post['app_registration']),
|
||||
'app_primary_color' => $this->normalizePrimaryColor($post['app_primary_color'] ?? ''),
|
||||
'api_token_default_ttl_days' => (int) ($post['api_token_default_ttl_days'] ?? 0),
|
||||
'api_token_max_ttl_days' => (int) ($post['api_token_max_ttl_days'] ?? 0),
|
||||
'api_cors_allowed_origins' => $this->normalizeScalarText($post['api_cors_allowed_origins'] ?? ''),
|
||||
'user_inactivity_deactivate_days' => (int) ($post['user_inactivity_deactivate_days'] ?? 0),
|
||||
'user_inactivity_delete_days' => (int) ($post['user_inactivity_delete_days'] ?? 0),
|
||||
'session_idle_timeout_minutes' => (int) ($post['session_idle_timeout_minutes'] ?? 0),
|
||||
'session_absolute_timeout_hours' => (int) ($post['session_absolute_timeout_hours'] ?? 0),
|
||||
'microsoft_auto_remember_default' => isset($post['microsoft_auto_remember_default']),
|
||||
'remember_token_lifetime_days' => (int) ($post['remember_token_lifetime_days'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function sanitizeIntegrationInput(array $post): array
|
||||
{
|
||||
return [
|
||||
'system_audit_enabled' => isset($post['system_audit_enabled']),
|
||||
'system_audit_retention_days' => (int) ($post['system_audit_retention_days'] ?? 0),
|
||||
'frontend_telemetry_enabled' => isset($post['frontend_telemetry_enabled']),
|
||||
'frontend_telemetry_sample_rate' => $this->normalizeScalarText($post['frontend_telemetry_sample_rate'] ?? ''),
|
||||
'frontend_telemetry_allowed_events' => $post['frontend_telemetry_allowed_events'] ?? [],
|
||||
'smtp_host' => trim((string) ($post['smtp_host'] ?? '')),
|
||||
'smtp_port' => (int) ($post['smtp_port'] ?? 0),
|
||||
'smtp_user' => trim((string) ($post['smtp_user'] ?? '')),
|
||||
'smtp_password' => (string) ($post['smtp_password'] ?? ''),
|
||||
'smtp_secure' => trim((string) ($post['smtp_secure'] ?? '')),
|
||||
'smtp_from' => trim((string) ($post['smtp_from'] ?? '')),
|
||||
'smtp_from_name' => trim((string) ($post['smtp_from_name'] ?? '')),
|
||||
'microsoft_shared_client_id' => trim((string) ($post['microsoft_shared_client_id'] ?? '')),
|
||||
'microsoft_shared_client_secret' => (string) ($post['microsoft_shared_client_secret'] ?? ''),
|
||||
'microsoft_authority' => trim((string) ($post['microsoft_authority'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
private function captureAuditSnapshot(): array
|
||||
{
|
||||
return [
|
||||
'default_tenant_id' => $this->settingsDefaultsGateway->getDefaultTenantId(),
|
||||
'default_role_id' => $this->settingsDefaultsGateway->getDefaultRoleId(),
|
||||
'default_department_id' => $this->settingsDefaultsGateway->getDefaultDepartmentId(),
|
||||
@@ -182,155 +221,153 @@ class AdminSettingsService
|
||||
'frontend_telemetry_sample_rate' => $this->settingsFrontendTelemetryGateway->getFrontendTelemetrySampleRate(),
|
||||
'frontend_telemetry_allowed_events' => $this->settingsFrontendTelemetryGateway->getFrontendTelemetryAllowedEvents(),
|
||||
];
|
||||
}
|
||||
|
||||
$this->settingsDefaultsGateway->setDefaultTenantId($defaultTenantId > 0 ? $defaultTenantId : null);
|
||||
$this->settingsDefaultsGateway->setDefaultRoleId($defaultRoleId > 0 ? $defaultRoleId : null);
|
||||
$this->settingsDefaultsGateway->setDefaultDepartmentId($defaultDepartmentId > 0 ? $defaultDepartmentId : null);
|
||||
$this->settingsAppGateway->setAppTitle($appTitle);
|
||||
$this->settingsAppGateway->setAppLocale($appLocale);
|
||||
$this->settingsAppGateway->setAppTheme($appTheme);
|
||||
$this->settingsAppGateway->setUserThemeAllowed($appThemeUser);
|
||||
$this->settingsAppGateway->setRegistrationEnabled($appRegistration);
|
||||
private function applyDefaultsAndAppSettings(array $input): void
|
||||
{
|
||||
$this->settingsDefaultsGateway->setDefaultTenantId($input['default_tenant_id'] > 0 ? $input['default_tenant_id'] : null);
|
||||
$this->settingsDefaultsGateway->setDefaultRoleId($input['default_role_id'] > 0 ? $input['default_role_id'] : null);
|
||||
$this->settingsDefaultsGateway->setDefaultDepartmentId($input['default_department_id'] > 0 ? $input['default_department_id'] : null);
|
||||
$this->settingsAppGateway->setAppTitle($input['app_title']);
|
||||
$this->settingsAppGateway->setAppLocale($input['app_locale']);
|
||||
$this->settingsAppGateway->setAppTheme($input['app_theme']);
|
||||
$this->settingsAppGateway->setUserThemeAllowed($input['app_theme_user']);
|
||||
$this->settingsAppGateway->setRegistrationEnabled($input['app_registration']);
|
||||
}
|
||||
|
||||
$apiTokenMaxTtlOk = $this->settingsApiPolicyGateway->setApiTokenMaxTtlDays($apiTokenMaxTtlDays > 0 ? $apiTokenMaxTtlDays : null);
|
||||
if (!$apiTokenMaxTtlOk) {
|
||||
/** @return list<array{message: string, key: string}> */
|
||||
private function applyApiAndLifecycleSettings(array $input): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
if (!$this->settingsApiPolicyGateway->setApiTokenMaxTtlDays($input['api_token_max_ttl_days'] > 0 ? $input['api_token_max_ttl_days'] : null)) {
|
||||
$errors[] = ['message' => 'API token max lifetime is invalid', 'key' => 'api_token_max_ttl_invalid'];
|
||||
}
|
||||
$apiTokenTtlOk = $this->settingsApiPolicyGateway->setApiTokenDefaultTtlDays($apiTokenDefaultTtlDays > 0 ? $apiTokenDefaultTtlDays : null);
|
||||
if (!$apiTokenTtlOk) {
|
||||
if (!$this->settingsApiPolicyGateway->setApiTokenDefaultTtlDays($input['api_token_default_ttl_days'] > 0 ? $input['api_token_default_ttl_days'] : null)) {
|
||||
$errors[] = ['message' => 'API token default lifetime is invalid', 'key' => 'api_token_default_ttl_invalid'];
|
||||
}
|
||||
|
||||
$userDeactivateOk = $this->settingsUserLifecycleGateway->setUserInactivityDeactivateDays($userInactivityDeactivateDays);
|
||||
if (!$userDeactivateOk) {
|
||||
$errors[] = [
|
||||
'message' => 'User inactivity deactivation period is invalid',
|
||||
'key' => 'user_inactivity_deactivate_days_invalid',
|
||||
];
|
||||
if (!$this->settingsApiPolicyGateway->setApiCorsAllowedOrigins($input['api_cors_allowed_origins'])) {
|
||||
$errors[] = ['message' => 'CORS allowed origins are invalid', 'key' => 'api_cors_allowed_origins_invalid'];
|
||||
}
|
||||
|
||||
if ($userInactivityDeactivateDays > 0) {
|
||||
$userDeleteOk = $this->settingsUserLifecycleGateway->setUserInactivityDeleteDays($userInactivityDeleteDays);
|
||||
if (!$userDeleteOk) {
|
||||
$errors[] = [
|
||||
'message' => 'User inactivity deletion period is invalid',
|
||||
'key' => 'user_inactivity_delete_days_invalid',
|
||||
];
|
||||
if (!$this->settingsUserLifecycleGateway->setUserInactivityDeactivateDays($input['user_inactivity_deactivate_days'])) {
|
||||
$errors[] = ['message' => 'User inactivity deactivation period is invalid', 'key' => 'user_inactivity_deactivate_days_invalid'];
|
||||
}
|
||||
if ($input['user_inactivity_deactivate_days'] > 0) {
|
||||
if (!$this->settingsUserLifecycleGateway->setUserInactivityDeleteDays($input['user_inactivity_delete_days'])) {
|
||||
$errors[] = ['message' => 'User inactivity deletion period is invalid', 'key' => 'user_inactivity_delete_days_invalid'];
|
||||
}
|
||||
} else {
|
||||
if ($userInactivityDeleteDays > 0) {
|
||||
$errors[] = [
|
||||
'message' => 'Auto-delete is ignored while auto-deactivate is disabled',
|
||||
'key' => 'user_inactivity_delete_requires_deactivate',
|
||||
];
|
||||
if ($input['user_inactivity_delete_days'] > 0) {
|
||||
$errors[] = ['message' => 'Auto-delete is ignored while auto-deactivate is disabled', 'key' => 'user_inactivity_delete_requires_deactivate'];
|
||||
}
|
||||
$this->settingsUserLifecycleGateway->setUserInactivityDeleteDays(0);
|
||||
}
|
||||
|
||||
$sessionIdleOk = $this->settingsSessionGateway->setSessionIdleTimeoutMinutes(
|
||||
$sessionIdleTimeoutMinutes > 0 ? $sessionIdleTimeoutMinutes : null
|
||||
);
|
||||
if (!$sessionIdleOk) {
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/** @return list<array{message: string, key: string}> */
|
||||
private function applySessionAndLoginSettings(array $input): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
if (!$this->settingsSessionGateway->setSessionIdleTimeoutMinutes($input['session_idle_timeout_minutes'] > 0 ? $input['session_idle_timeout_minutes'] : null)) {
|
||||
$errors[] = ['message' => 'Session idle timeout is invalid', 'key' => 'session_idle_timeout_invalid'];
|
||||
}
|
||||
|
||||
$sessionAbsoluteOk = $this->settingsSessionGateway->setSessionAbsoluteTimeoutHours(
|
||||
$sessionAbsoluteTimeoutHours > 0 ? $sessionAbsoluteTimeoutHours : null
|
||||
);
|
||||
if (!$sessionAbsoluteOk) {
|
||||
if (!$this->settingsSessionGateway->setSessionAbsoluteTimeoutHours($input['session_absolute_timeout_hours'] > 0 ? $input['session_absolute_timeout_hours'] : null)) {
|
||||
$errors[] = ['message' => 'Session absolute timeout is invalid', 'key' => 'session_absolute_timeout_invalid'];
|
||||
}
|
||||
|
||||
$this->settingsLoginPersistenceGateway->setMicrosoftAutoRememberDefault($microsoftAutoRememberDefault);
|
||||
|
||||
$rememberTtlOk = $this->settingsLoginPersistenceGateway->setRememberTokenLifetimeDays(
|
||||
$rememberTokenLifetimeDays > 0 ? $rememberTokenLifetimeDays : null
|
||||
);
|
||||
if (!$rememberTtlOk) {
|
||||
$this->settingsLoginPersistenceGateway->setMicrosoftAutoRememberDefault($input['microsoft_auto_remember_default']);
|
||||
if (!$this->settingsLoginPersistenceGateway->setRememberTokenLifetimeDays($input['remember_token_lifetime_days'] > 0 ? $input['remember_token_lifetime_days'] : null)) {
|
||||
$errors[] = ['message' => 'Remember token lifetime is invalid', 'key' => 'remember_token_lifetime_days_invalid'];
|
||||
}
|
||||
|
||||
$apiCorsOriginsOk = $this->settingsApiPolicyGateway->setApiCorsAllowedOrigins($apiCorsAllowedOrigins);
|
||||
if (!$apiCorsOriginsOk) {
|
||||
$errors[] = ['message' => 'CORS allowed origins are invalid', 'key' => 'api_cors_allowed_origins_invalid'];
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
$systemAuditEnabledOk = $this->settingsSystemAuditGateway->setSystemAuditEnabled($systemAuditEnabled);
|
||||
if (!$systemAuditEnabledOk) {
|
||||
/** @return list<array{message: string, key: string}> */
|
||||
private function applyAuditAndTelemetrySettings(array $input): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
if (!$this->settingsSystemAuditGateway->setSystemAuditEnabled($input['system_audit_enabled'])) {
|
||||
$errors[] = ['message' => 'System audit setting is invalid', 'key' => 'system_audit_enabled_invalid'];
|
||||
}
|
||||
|
||||
$systemAuditRetentionOk = $this->settingsSystemAuditGateway->setSystemAuditRetentionDays(
|
||||
$systemAuditRetentionDays > 0 ? $systemAuditRetentionDays : null
|
||||
);
|
||||
if (!$systemAuditRetentionOk) {
|
||||
if (!$this->settingsSystemAuditGateway->setSystemAuditRetentionDays($input['system_audit_retention_days'] > 0 ? $input['system_audit_retention_days'] : null)) {
|
||||
$errors[] = ['message' => 'System audit retention is invalid', 'key' => 'system_audit_retention_invalid'];
|
||||
}
|
||||
|
||||
$frontendTelemetryEnabledOk = $this->settingsFrontendTelemetryGateway->setFrontendTelemetryEnabled($frontendTelemetryEnabled);
|
||||
if (!$frontendTelemetryEnabledOk) {
|
||||
if (!$this->settingsFrontendTelemetryGateway->setFrontendTelemetryEnabled($input['frontend_telemetry_enabled'])) {
|
||||
$errors[] = ['message' => 'Frontend telemetry enabled setting is invalid', 'key' => 'frontend_telemetry_enabled_invalid'];
|
||||
}
|
||||
|
||||
$frontendTelemetryRateValue = null;
|
||||
if ($frontendTelemetrySampleRate !== '') {
|
||||
$frontendTelemetryRateValue = is_numeric($frontendTelemetrySampleRate)
|
||||
? (float) $frontendTelemetrySampleRate
|
||||
$rateValue = null;
|
||||
if ($input['frontend_telemetry_sample_rate'] !== '') {
|
||||
$rateValue = is_numeric($input['frontend_telemetry_sample_rate'])
|
||||
? (float) $input['frontend_telemetry_sample_rate']
|
||||
: NAN;
|
||||
}
|
||||
$frontendTelemetrySampleRateOk = $this->settingsFrontendTelemetryGateway->setFrontendTelemetrySampleRate($frontendTelemetryRateValue);
|
||||
if (!$frontendTelemetrySampleRateOk) {
|
||||
if (!$this->settingsFrontendTelemetryGateway->setFrontendTelemetrySampleRate($rateValue)) {
|
||||
$errors[] = ['message' => 'Frontend telemetry sample rate is invalid', 'key' => 'frontend_telemetry_sample_rate_invalid'];
|
||||
}
|
||||
|
||||
$frontendTelemetryAllowedEventsOk = $this->settingsFrontendTelemetryGateway->setFrontendTelemetryAllowedEvents($frontendTelemetryAllowedEvents);
|
||||
if (!$frontendTelemetryAllowedEventsOk) {
|
||||
if (!$this->settingsFrontendTelemetryGateway->setFrontendTelemetryAllowedEvents($input['frontend_telemetry_allowed_events'])) {
|
||||
$errors[] = ['message' => 'Frontend telemetry allowed events are invalid', 'key' => 'frontend_telemetry_allowed_events_invalid'];
|
||||
}
|
||||
|
||||
$primaryOk = $this->settingsAppGateway->setAppPrimaryColor($appPrimaryColor);
|
||||
if (!$primaryOk) {
|
||||
$appPrimaryColor = '';
|
||||
$errors[] = ['message' => 'Primary color is invalid', 'key' => 'app_primary_invalid'];
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
$this->settingsSmtpGateway->setSmtpHost($smtpHost);
|
||||
$this->settingsSmtpGateway->setSmtpPort($smtpPort > 0 ? $smtpPort : null);
|
||||
$this->settingsSmtpGateway->setSmtpUser($smtpUser);
|
||||
if (trim($smtpPassword) !== '') {
|
||||
$this->settingsSmtpGateway->setSmtpPassword($smtpPassword);
|
||||
/** @return list<array{message: string, key: string}> */
|
||||
private function applyAppPrimaryColor(array &$input): array
|
||||
{
|
||||
if (!$this->settingsAppGateway->setAppPrimaryColor($input['app_primary_color'])) {
|
||||
$input['app_primary_color'] = '';
|
||||
return [['message' => 'Primary color is invalid', 'key' => 'app_primary_invalid']];
|
||||
}
|
||||
$this->settingsSmtpGateway->setSmtpSecure($smtpSecure);
|
||||
$this->settingsSmtpGateway->setSmtpFrom($smtpFrom);
|
||||
$this->settingsSmtpGateway->setSmtpFromName($smtpFromName);
|
||||
return [];
|
||||
}
|
||||
|
||||
$msClientIdOk = $this->settingsMicrosoftGateway->setMicrosoftSharedClientId($microsoftSharedClientId);
|
||||
if (!$msClientIdOk) {
|
||||
private function applySmtpSettings(array $input): void
|
||||
{
|
||||
$this->settingsSmtpGateway->setSmtpHost($input['smtp_host']);
|
||||
$this->settingsSmtpGateway->setSmtpPort($input['smtp_port'] > 0 ? $input['smtp_port'] : null);
|
||||
$this->settingsSmtpGateway->setSmtpUser($input['smtp_user']);
|
||||
if (trim($input['smtp_password']) !== '') {
|
||||
$this->settingsSmtpGateway->setSmtpPassword($input['smtp_password']);
|
||||
}
|
||||
$this->settingsSmtpGateway->setSmtpSecure($input['smtp_secure']);
|
||||
$this->settingsSmtpGateway->setSmtpFrom($input['smtp_from']);
|
||||
$this->settingsSmtpGateway->setSmtpFromName($input['smtp_from_name']);
|
||||
}
|
||||
|
||||
/** @return list<array{message: string, key: string}> */
|
||||
private function applyMicrosoftSettings(array $input): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
if (!$this->settingsMicrosoftGateway->setMicrosoftSharedClientId($input['microsoft_shared_client_id'])) {
|
||||
$errors[] = ['message' => 'Microsoft client ID is invalid', 'key' => 'microsoft_client_id_invalid'];
|
||||
}
|
||||
|
||||
$msAuthorityOk = $this->settingsMicrosoftGateway->setMicrosoftAuthority($microsoftAuthority);
|
||||
if (!$msAuthorityOk) {
|
||||
if (!$this->settingsMicrosoftGateway->setMicrosoftAuthority($input['microsoft_authority'])) {
|
||||
$errors[] = ['message' => 'Microsoft authority is invalid', 'key' => 'microsoft_authority_invalid'];
|
||||
}
|
||||
|
||||
if (trim($microsoftSharedClientSecret) !== '') {
|
||||
$msSecretOk = $this->settingsMicrosoftGateway->setMicrosoftSharedClientSecret($microsoftSharedClientSecret);
|
||||
if (!$msSecretOk) {
|
||||
$errors[] = [
|
||||
'message' => 'Microsoft client secret could not be encrypted',
|
||||
'key' => 'microsoft_client_secret_invalid',
|
||||
];
|
||||
if (trim($input['microsoft_shared_client_secret']) !== '') {
|
||||
if (!$this->settingsMicrosoftGateway->setMicrosoftSharedClientSecret($input['microsoft_shared_client_secret'])) {
|
||||
$errors[] = ['message' => 'Microsoft client secret could not be encrypted', 'key' => 'microsoft_client_secret_invalid'];
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
private function updateSettingsCache(array $input): void
|
||||
{
|
||||
$this->settingCacheService->update([
|
||||
'app_title' => $appTitle !== '' ? $appTitle : null,
|
||||
'app_locale' => $appLocale !== '' ? $appLocale : null,
|
||||
'app_theme' => $appTheme !== '' ? $appTheme : null,
|
||||
'app_theme_user' => $appThemeUser ? '1' : '0',
|
||||
'app_registration' => $appRegistration ? '1' : '0',
|
||||
'app_primary_color' => $appPrimaryColor !== '' ? $appPrimaryColor : null,
|
||||
'app_title' => $input['app_title'] !== '' ? $input['app_title'] : null,
|
||||
'app_locale' => $input['app_locale'] !== '' ? $input['app_locale'] : null,
|
||||
'app_theme' => $input['app_theme'] !== '' ? $input['app_theme'] : null,
|
||||
'app_theme_user' => $input['app_theme_user'] ? '1' : '0',
|
||||
'app_registration' => $input['app_registration'] ? '1' : '0',
|
||||
'app_primary_color' => $input['app_primary_color'] !== '' ? $input['app_primary_color'] : null,
|
||||
'api_token_default_ttl_days' => (string) $this->settingsApiPolicyGateway->getApiTokenDefaultTtlDays(),
|
||||
'api_token_max_ttl_days' => (string) $this->settingsApiPolicyGateway->getApiTokenMaxTtlDays(),
|
||||
'api_cors_allowed_origins' => $this->settingsMetadataGateway->getValue(SettingKeys::API_CORS_ALLOWED_ORIGINS_KEY),
|
||||
@@ -338,39 +375,17 @@ class AdminSettingsService
|
||||
'frontend_telemetry_sample_rate' => (string) $this->settingsFrontendTelemetryGateway->getFrontendTelemetrySampleRate(),
|
||||
'frontend_telemetry_allowed_events' => implode(',', $this->settingsFrontendTelemetryGateway->getFrontendTelemetryAllowedEvents()),
|
||||
]);
|
||||
}
|
||||
|
||||
$afterAudit = [
|
||||
'default_tenant_id' => $this->settingsDefaultsGateway->getDefaultTenantId(),
|
||||
'default_role_id' => $this->settingsDefaultsGateway->getDefaultRoleId(),
|
||||
'default_department_id' => $this->settingsDefaultsGateway->getDefaultDepartmentId(),
|
||||
'app_locale' => $this->settingsAppGateway->getAppLocale(),
|
||||
'app_theme' => $this->settingsAppGateway->getAppTheme(),
|
||||
'app_theme_user' => $this->settingsAppGateway->isUserThemeAllowed(),
|
||||
'app_registration' => $this->settingsAppGateway->isRegistrationEnabled(),
|
||||
'app_primary_color' => $this->settingsAppGateway->getAppPrimaryColor(),
|
||||
'api_token_default_ttl_days' => $this->settingsApiPolicyGateway->getApiTokenDefaultTtlDays(),
|
||||
'api_token_max_ttl_days' => $this->settingsApiPolicyGateway->getApiTokenMaxTtlDays(),
|
||||
'user_inactivity_deactivate_days' => $this->settingsUserLifecycleGateway->getUserInactivityDeactivateDays(),
|
||||
'user_inactivity_delete_days' => $this->settingsUserLifecycleGateway->getUserInactivityDeleteDays(),
|
||||
'session_idle_timeout_minutes' => $this->settingsSessionGateway->getSessionIdleTimeoutMinutes(),
|
||||
'session_absolute_timeout_hours' => $this->settingsSessionGateway->getSessionAbsoluteTimeoutHours(),
|
||||
'microsoft_auto_remember_default' => $this->settingsLoginPersistenceGateway->isMicrosoftAutoRememberDefault(),
|
||||
'remember_token_lifetime_days' => $this->settingsLoginPersistenceGateway->getRememberTokenLifetimeDays(),
|
||||
'system_audit_enabled' => $this->settingsSystemAuditGateway->isSystemAuditEnabled(),
|
||||
'system_audit_retention_days' => $this->settingsSystemAuditGateway->getSystemAuditRetentionDays(),
|
||||
'frontend_telemetry_enabled' => $this->settingsFrontendTelemetryGateway->isFrontendTelemetryEnabled(),
|
||||
'frontend_telemetry_sample_rate' => $this->settingsFrontendTelemetryGateway->getFrontendTelemetrySampleRate(),
|
||||
'frontend_telemetry_allowed_events' => $this->settingsFrontendTelemetryGateway->getFrontendTelemetryAllowedEvents(),
|
||||
];
|
||||
|
||||
/** @param list<array{message: string, key: string}> $errors */
|
||||
private function recordAudit(array $errors, array $beforeAudit, array $afterAudit): void
|
||||
{
|
||||
$outcome = $errors === [] ? 'success' : 'failed';
|
||||
$errorKeys = [];
|
||||
if ($errors !== []) {
|
||||
foreach ($errors as $error) {
|
||||
$key = trim((string) $error['key']);
|
||||
if ($key !== '') {
|
||||
$errorKeys[] = $key;
|
||||
}
|
||||
foreach ($errors as $error) {
|
||||
$key = trim((string) $error['key']);
|
||||
if ($key !== '') {
|
||||
$errorKeys[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,8 +395,6 @@ class AdminSettingsService
|
||||
'error_code' => $errors === [] ? null : 'validation_error',
|
||||
'metadata' => $errors === [] ? [] : ['error_keys' => $errorKeys],
|
||||
]);
|
||||
|
||||
return ['errors' => $errors];
|
||||
}
|
||||
|
||||
private function normalizeScalarText(mixed $value): string
|
||||
|
||||
@@ -56,7 +56,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request->hasBody('description')) {
|
||||
if ($request->isMethod('POST')) {
|
||||
$selectedTenantId = $request->bodyInt('tenant_id');
|
||||
$selectedTenantIds = $directoryScopeGateway->filterTenantIdsForUser([$selectedTenantId], $currentUserId);
|
||||
$selectedTenantId = (int) ($selectedTenantIds[0] ?? 0);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\Request;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\Access\DepartmentAuthorizationPolicy;
|
||||
@@ -19,6 +20,11 @@ if ($uuid === '') {
|
||||
$currentUserId = (int) ($session['user']['id'] ?? 0);
|
||||
$department = app(\MintyPHP\Service\Org\DepartmentService::class)->findByUuid($uuid);
|
||||
if (!$department) {
|
||||
if (Request::wantsJson()) {
|
||||
http_response_code(404);
|
||||
Router::json(['error' => 'not_found']);
|
||||
return;
|
||||
}
|
||||
Flash::error('Department not found', 'admin/departments', 'department_not_found');
|
||||
Router::redirect('admin/departments');
|
||||
return;
|
||||
@@ -30,13 +36,29 @@ $decision = $authorizationService->authorize(DepartmentAuthorizationPolicy::ABIL
|
||||
'target_department_id' => $departmentId,
|
||||
]);
|
||||
if (!$decision->isAllowed()) {
|
||||
if (Request::wantsJson()) {
|
||||
http_response_code($decision->status());
|
||||
Router::json(['error' => $decision->error()]);
|
||||
return;
|
||||
}
|
||||
Router::redirect('error/forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
$result = app(\MintyPHP\Service\Org\DepartmentService::class)->deleteByUuid($uuid);
|
||||
if (!($result['ok'] ?? false)) {
|
||||
Flash::error('Department not found', 'admin/departments', 'department_not_found');
|
||||
$error = $result['error'] ?? 'not_found';
|
||||
$status = $result['status'] ?? 500;
|
||||
if (Request::wantsJson()) {
|
||||
http_response_code($status);
|
||||
Router::json(['error' => $error]);
|
||||
return;
|
||||
}
|
||||
if ($error === 'department_has_users') {
|
||||
Flash::error(t('Department can not be deleted while users are assigned'), 'admin/departments', 'department_has_users');
|
||||
} else {
|
||||
Flash::error(t('Department not found'), 'admin/departments', 'department_not_found');
|
||||
}
|
||||
Router::redirect('admin/departments');
|
||||
return;
|
||||
}
|
||||
@@ -45,5 +67,11 @@ if ($currentUserId > 0) {
|
||||
app(\MintyPHP\Service\Auth\AuthService::class)->loadTenantDataIntoSession($currentUserId);
|
||||
}
|
||||
|
||||
if (Request::wantsJson()) {
|
||||
http_response_code(204);
|
||||
Router::json([]);
|
||||
return;
|
||||
}
|
||||
|
||||
Flash::success('Department deleted', 'admin/departments', 'department_deleted');
|
||||
Router::redirect('admin/departments');
|
||||
|
||||
@@ -87,7 +87,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request->hasBody('description')) {
|
||||
if ($request->isMethod('POST')) {
|
||||
$submitDecision = $authorizationService->authorize(DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_EDIT_SUBMIT, [
|
||||
'actor_user_id' => $currentUserId,
|
||||
'target_department_id' => $departmentId,
|
||||
|
||||
@@ -39,7 +39,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request->hasBody('key')) {
|
||||
if ($request->isMethod('POST')) {
|
||||
$result = app(\MintyPHP\Service\Access\PermissionService::class)->createFromAdmin($request->bodyAll());
|
||||
$form = $result['form'] ?? $form;
|
||||
$errorBag->merge($result['errors'] ?? []);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\Request;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\Access\PermissionAuthorizationPolicy;
|
||||
@@ -12,10 +13,18 @@ Guard::requireLogin();
|
||||
|
||||
$currentUserId = (int) ($session['user']['id'] ?? 0);
|
||||
$authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class);
|
||||
// Authorization is checked generically (can user delete ANY permission?) before loading the entity.
|
||||
// Context-aware policies (departments, tenants, users) load the entity first to pass target_id.
|
||||
// Both patterns are valid; context-aware is preferred for new code.
|
||||
$decision = $authorizationService->authorize(PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_DELETE, [
|
||||
'actor_user_id' => $currentUserId,
|
||||
]);
|
||||
if (!$decision->isAllowed()) {
|
||||
if (Request::wantsJson()) {
|
||||
http_response_code($decision->status());
|
||||
Router::json(['error' => $decision->error()]);
|
||||
return;
|
||||
}
|
||||
Guard::deny();
|
||||
}
|
||||
|
||||
@@ -30,18 +39,31 @@ $affectedUserIds = app(\MintyPHP\Repository\Access\UserRoleRepository::class)->l
|
||||
|
||||
$result = app(PermissionService::class)->deleteById($id);
|
||||
if (!($result['ok'] ?? false)) {
|
||||
$error = (string) ($result['error'] ?? '');
|
||||
$error = (string) ($result['error'] ?? 'not_found');
|
||||
$status = $result['status'] ?? 500;
|
||||
if (Request::wantsJson()) {
|
||||
http_response_code($status);
|
||||
Router::json(['error' => $error]);
|
||||
return;
|
||||
}
|
||||
if ($error === 'system_permission_protected') {
|
||||
Flash::error('System permission can not be deleted', 'admin/permissions', 'permission_system_protected');
|
||||
} else {
|
||||
Flash::error('Permission not found', 'admin/permissions', 'permission_not_found');
|
||||
}
|
||||
Router::redirect('admin/permissions');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($affectedUserIds) {
|
||||
app(\MintyPHP\Repository\User\UserWriteRepository::class)->bumpAuthzVersionByUserIds($affectedUserIds);
|
||||
}
|
||||
|
||||
if (Request::wantsJson()) {
|
||||
http_response_code(204);
|
||||
Router::json([]);
|
||||
return;
|
||||
}
|
||||
|
||||
Flash::success('Permission deleted', 'admin/permissions', 'permission_deleted');
|
||||
Router::redirect('admin/permissions');
|
||||
|
||||
@@ -54,7 +54,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request->hasBody('key')) {
|
||||
if ($request->isMethod('POST')) {
|
||||
$submitDecision = $authorizationService->authorize(PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_EDIT_SUBMIT, [
|
||||
'actor_user_id' => $currentUserId,
|
||||
'target_permission_id' => $id,
|
||||
|
||||
@@ -26,6 +26,7 @@ if (!$decision->isAllowed()) {
|
||||
|
||||
$errorBag = formErrors();
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
$form = [
|
||||
'description' => '',
|
||||
'code' => '',
|
||||
@@ -42,7 +43,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request->hasBody('description')) {
|
||||
if ($request->isMethod('POST')) {
|
||||
$dbSession = app(\MintyPHP\Repository\Support\DatabaseSessionRepository::class);
|
||||
$transactionStarted = false;
|
||||
|
||||
@@ -52,6 +53,7 @@ if ($request->hasBody('description')) {
|
||||
|
||||
$result = app(\MintyPHP\Service\Access\RoleService::class)->createFromAdmin($request->bodyAll(), $currentUserId);
|
||||
$form = $result['form'] ?? $form;
|
||||
$warnings = $result['warnings'] ?? [];
|
||||
$errorBag->merge($result['errors'] ?? []);
|
||||
$selectedPermissionIds = $request->body('permission_ids', $selectedPermissionIds);
|
||||
if (!is_array($selectedPermissionIds)) {
|
||||
@@ -75,15 +77,24 @@ if ($request->hasBody('description')) {
|
||||
|
||||
$action = (string) $request->body('action', 'create');
|
||||
if ($action === 'create_close') {
|
||||
if ($warnings) {
|
||||
Flash::info(implode(' ', $warnings), $closeTarget, 'role_warning');
|
||||
}
|
||||
Flash::success('Role created', $closeTarget, 'role_created');
|
||||
Router::redirect($closeTarget);
|
||||
} else {
|
||||
$uuid = (string) ($result['uuid'] ?? '');
|
||||
if ($uuid !== '') {
|
||||
$target = requestPathWithReturnTarget("admin/roles/edit/{$uuid}", $returnTarget);
|
||||
if ($warnings) {
|
||||
Flash::info(implode(' ', $warnings), $target, 'role_warning');
|
||||
}
|
||||
Flash::success('Role created', $target, 'role_created');
|
||||
Router::redirect($target);
|
||||
} else {
|
||||
if ($warnings) {
|
||||
Flash::info(implode(' ', $warnings), $closeTarget, 'role_warning');
|
||||
}
|
||||
Flash::success('Role created', $closeTarget, 'role_created');
|
||||
Router::redirect($closeTarget);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,15 @@
|
||||
$validationSummaryErrors = $validationSummaryErrors ?? ($errors ?? []);
|
||||
require templatePath('partials/app-details-validation-summary.phtml');
|
||||
?>
|
||||
<?php if (!empty($warnings)): ?>
|
||||
<div class="notice" data-variant="info">
|
||||
<ul>
|
||||
<?php foreach ($warnings as $warning): ?>
|
||||
<li><?php e($warning); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$values = $form ?? [];
|
||||
$detailsOpenAll = true;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\Request;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\Access\RoleAuthorizationPolicy;
|
||||
@@ -11,10 +12,18 @@ Guard::requireLogin();
|
||||
|
||||
$currentUserId = (int) ($session['user']['id'] ?? 0);
|
||||
$authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class);
|
||||
// Authorization is checked generically (can user delete ANY role?) before loading the entity.
|
||||
// Context-aware policies (departments, tenants, users) load the entity first to pass target_id.
|
||||
// Both patterns are valid; context-aware is preferred for new code.
|
||||
$decision = $authorizationService->authorize(RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_DELETE, [
|
||||
'actor_user_id' => $currentUserId,
|
||||
]);
|
||||
if (!$decision->isAllowed()) {
|
||||
if (Request::wantsJson()) {
|
||||
http_response_code($decision->status());
|
||||
Router::json(['error' => $decision->error()]);
|
||||
return;
|
||||
}
|
||||
Guard::deny();
|
||||
}
|
||||
|
||||
@@ -35,18 +44,31 @@ if ($role) {
|
||||
|
||||
$result = app(\MintyPHP\Service\Access\RoleService::class)->deleteByUuid($uuid);
|
||||
if (!($result['ok'] ?? false)) {
|
||||
$error = $result['error'] ?? 'unknown';
|
||||
$error = $result['error'] ?? 'not_found';
|
||||
$status = $result['status'] ?? 500;
|
||||
if (Request::wantsJson()) {
|
||||
http_response_code($status);
|
||||
Router::json(['error' => $error]);
|
||||
return;
|
||||
}
|
||||
if ($error === 'admin_role_protected') {
|
||||
Flash::error('The Admin role cannot be deleted', 'admin/roles', 'admin_role_protected');
|
||||
} else {
|
||||
Flash::error('Role not found', 'admin/roles', 'role_not_found');
|
||||
}
|
||||
Router::redirect('admin/roles');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($affectedUserIds) {
|
||||
app(\MintyPHP\Repository\User\UserWriteRepository::class)->bumpAuthzVersionByUserIds($affectedUserIds);
|
||||
}
|
||||
|
||||
if (Request::wantsJson()) {
|
||||
http_response_code(204);
|
||||
Router::json([]);
|
||||
return;
|
||||
}
|
||||
|
||||
Flash::success('Role deleted', 'admin/roles', 'role_deleted');
|
||||
Router::redirect('admin/roles');
|
||||
|
||||
@@ -44,6 +44,7 @@ app(\MintyPHP\Service\Audit\AuditMetadataEnricher::class)->enrich($role);
|
||||
|
||||
$errorBag = formErrors();
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
$form = $role;
|
||||
$permissions = $permissionRepository->listActive();
|
||||
$selectedPermissionIds = $rolePermissionRepository->listPermissionIdsByRoleId($roleId);
|
||||
@@ -58,7 +59,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request->hasBody('description')) {
|
||||
if ($request->isMethod('POST')) {
|
||||
$submitDecision = $authorizationService->authorize(RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_EDIT_SUBMIT, [
|
||||
'actor_user_id' => $currentUserId,
|
||||
'target_role_id' => $roleId,
|
||||
@@ -68,6 +69,7 @@ if ($request->hasBody('description')) {
|
||||
}
|
||||
$result = app(\MintyPHP\Service\Access\RoleService::class)->updateFromAdmin($roleId, $request->bodyAll(), $currentUserId);
|
||||
$form = $result['form'] ?? $form;
|
||||
$warnings = $result['warnings'] ?? [];
|
||||
$errorBag->merge($result['errors'] ?? []);
|
||||
$selectedPermissionIds = $request->body('permission_ids', $selectedPermissionIds);
|
||||
if (!is_array($selectedPermissionIds)) {
|
||||
@@ -108,9 +110,15 @@ if ($request->hasBody('description')) {
|
||||
if ($syncSucceeded) {
|
||||
$action = (string) $request->body('action', 'save');
|
||||
if ($action === 'save_close') {
|
||||
if ($warnings) {
|
||||
Flash::info(implode(' ', $warnings), $closeTarget, 'role_warning');
|
||||
}
|
||||
Flash::success('Role updated', $closeTarget, 'role_updated');
|
||||
Router::redirect($closeTarget);
|
||||
} else {
|
||||
if ($warnings) {
|
||||
Flash::info(implode(' ', $warnings), $editTarget, 'role_warning');
|
||||
}
|
||||
Flash::success('Role updated', $editTarget, 'role_updated');
|
||||
Router::redirect($editTarget);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,15 @@ $isReadOnly = !$canUpdateRole;
|
||||
$validationSummaryErrors = $validationSummaryErrors ?? ($errors ?? []);
|
||||
require templatePath('partials/app-details-validation-summary.phtml');
|
||||
?>
|
||||
<?php if (!empty($warnings)): ?>
|
||||
<div class="notice" data-variant="info">
|
||||
<ul>
|
||||
<?php foreach ($warnings as $warning): ?>
|
||||
<li><?php e($warning); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$detailsOpenAll = true;
|
||||
|
||||
@@ -62,7 +62,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request->hasBody('description')) {
|
||||
if ($request->isMethod('POST')) {
|
||||
$post = $request->bodyAll();
|
||||
$ssoFields = [
|
||||
'microsoft_enabled',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\Request;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\Access\TenantAuthorizationPolicy;
|
||||
@@ -18,6 +19,11 @@ if ($uuid === '') {
|
||||
|
||||
$tenant = app(\MintyPHP\Service\Tenant\TenantService::class)->findByUuid($uuid);
|
||||
if (!$tenant) {
|
||||
if (Request::wantsJson()) {
|
||||
http_response_code(404);
|
||||
Router::json(['error' => 'not_found']);
|
||||
return;
|
||||
}
|
||||
Flash::error('Tenant not found', 'admin/tenants', 'tenant_not_found');
|
||||
Router::redirect('admin/tenants');
|
||||
return;
|
||||
@@ -30,13 +36,25 @@ $decision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_
|
||||
'target_tenant_id' => $tenantId,
|
||||
]);
|
||||
if (!$decision->isAllowed()) {
|
||||
if (Request::wantsJson()) {
|
||||
http_response_code($decision->status());
|
||||
Router::json(['error' => $decision->error()]);
|
||||
return;
|
||||
}
|
||||
Router::redirect('error/forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
$result = app(\MintyPHP\Service\Tenant\TenantService::class)->deleteByUuid($uuid);
|
||||
if (!($result['ok'] ?? false)) {
|
||||
if (($result['error'] ?? '') === 'tenant_has_departments') {
|
||||
$error = $result['error'] ?? 'not_found';
|
||||
$status = $result['status'] ?? 500;
|
||||
if (Request::wantsJson()) {
|
||||
http_response_code($status);
|
||||
Router::json(['error' => $error]);
|
||||
return;
|
||||
}
|
||||
if ($error === 'tenant_has_departments') {
|
||||
Flash::error('Tenant can not be deleted while departments are assigned', 'admin/tenants', 'tenant_has_departments');
|
||||
Router::redirect('admin/tenants');
|
||||
return;
|
||||
@@ -51,5 +69,11 @@ if ($currentUserId > 0) {
|
||||
app(\MintyPHP\Service\Auth\AuthService::class)->loadTenantDataIntoSession($currentUserId);
|
||||
}
|
||||
|
||||
if (Request::wantsJson()) {
|
||||
http_response_code(204);
|
||||
Router::json([]);
|
||||
return;
|
||||
}
|
||||
|
||||
Flash::success('Tenant deleted', 'admin/tenants', 'tenant_deleted');
|
||||
Router::redirect('admin/tenants');
|
||||
|
||||
@@ -79,7 +79,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request->hasBody('description')) {
|
||||
if ($request->isMethod('POST')) {
|
||||
$post = $request->bodyAll();
|
||||
$submitDecision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_EDIT_SUBMIT, [
|
||||
'actor_user_id' => $currentUserId,
|
||||
|
||||
@@ -105,7 +105,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request->hasBody('email')) {
|
||||
if ($request->isMethod('POST')) {
|
||||
$post = $request->bodyAll();
|
||||
$input = $post;
|
||||
$tenantIds = $canManageAssignments ? $userAssignmentService->normalizeIdInput($input['tenant_ids'] ?? []) : [];
|
||||
|
||||
@@ -161,7 +161,7 @@ if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request->hasBody('email')) {
|
||||
if ($request->isMethod('POST')) {
|
||||
$post = $request->bodyAll();
|
||||
$submitDecision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_SUBMIT, [
|
||||
'actor_user_id' => $currentUserId,
|
||||
|
||||
@@ -66,6 +66,41 @@ class RoleServiceTest extends TestCase
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame('uuid-42', $result['uuid']);
|
||||
$this->assertSame(42, $result['id']);
|
||||
$this->assertEmpty($result['warnings']);
|
||||
}
|
||||
|
||||
public function testCreateFromAdminReturnsDuplicateCodeAsWarning(): void
|
||||
{
|
||||
$this->roleRepo
|
||||
->expects($this->any())
|
||||
->method('existsByCode')
|
||||
->with('DUPE', 0)
|
||||
->willReturn(true);
|
||||
|
||||
$this->roleRepo
|
||||
->expects($this->any())
|
||||
->method('create')
|
||||
->willReturn(50);
|
||||
|
||||
$this->roleRepo
|
||||
->expects($this->any())
|
||||
->method('find')
|
||||
->with(50)
|
||||
->willReturn(['id' => 50, 'uuid' => 'uuid-50', 'description' => 'Dupe Role']);
|
||||
|
||||
$this->auditService
|
||||
->expects($this->once())
|
||||
->method('record');
|
||||
|
||||
$result = $this->service->createFromAdmin([
|
||||
'description' => 'Dupe Role',
|
||||
'code' => 'DUPE',
|
||||
'active' => 1,
|
||||
], 1);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertNotEmpty($result['warnings']);
|
||||
$this->assertEmpty($result['errors'] ?? []);
|
||||
}
|
||||
|
||||
public function testCreateFromAdminRejectsEmptyDescription(): void
|
||||
@@ -184,7 +219,7 @@ class RoleServiceTest extends TestCase
|
||||
$this->assertTrue($result['ok']);
|
||||
}
|
||||
|
||||
public function testUpdateFromAdminRejectsDuplicateCode(): void
|
||||
public function testUpdateFromAdminReturnsDuplicateCodeAsWarning(): void
|
||||
{
|
||||
$this->roleRepo
|
||||
->expects($this->once())
|
||||
@@ -193,17 +228,30 @@ class RoleServiceTest extends TestCase
|
||||
->willReturn(true);
|
||||
|
||||
$this->roleRepo
|
||||
->expects($this->never())
|
||||
->method('update');
|
||||
->expects($this->once())
|
||||
->method('find')
|
||||
->with(10)
|
||||
->willReturn(['id' => 10, 'uuid' => 'uuid-10', 'active' => 1]);
|
||||
|
||||
$this->roleRepo
|
||||
->expects($this->once())
|
||||
->method('update')
|
||||
->with(10, $this->anything())
|
||||
->willReturn(true);
|
||||
|
||||
$this->auditService
|
||||
->expects($this->once())
|
||||
->method('record');
|
||||
|
||||
$result = $this->service->updateFromAdmin(10, [
|
||||
'description' => 'Manager',
|
||||
'code' => 'ADMIN',
|
||||
'active' => 1,
|
||||
]);
|
||||
], 5);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertNotEmpty($result['errors']);
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertNotEmpty($result['warnings']);
|
||||
$this->assertEmpty($result['errors'] ?? []);
|
||||
}
|
||||
|
||||
public function testUpdateFromAdminReturnsErrorWhenRepositoryUpdateFails(): void
|
||||
|
||||
@@ -207,6 +207,10 @@ class DepartmentServiceTest extends TestCase
|
||||
->with('abc-123')
|
||||
->willReturn($department);
|
||||
|
||||
$userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class);
|
||||
$userDeptRepo->expects($this->any())->method('countUsersByDepartmentIds')->with([5])->willReturn([]);
|
||||
$this->userServicesFactory->expects($this->any())->method('createUserDepartmentRepository')->willReturn($userDeptRepo);
|
||||
|
||||
$this->departmentRepository
|
||||
->expects($this->once())
|
||||
->method('delete')
|
||||
@@ -263,6 +267,10 @@ class DepartmentServiceTest extends TestCase
|
||||
->with('abc-999')
|
||||
->willReturn(['id' => 9, 'uuid' => 'abc-999', 'description' => 'Ops']);
|
||||
|
||||
$userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class);
|
||||
$userDeptRepo->expects($this->any())->method('countUsersByDepartmentIds')->with([9])->willReturn([]);
|
||||
$this->userServicesFactory->expects($this->any())->method('createUserDepartmentRepository')->willReturn($userDeptRepo);
|
||||
|
||||
$this->departmentRepository
|
||||
->expects($this->once())
|
||||
->method('delete')
|
||||
@@ -280,6 +288,72 @@ class DepartmentServiceTest extends TestCase
|
||||
$this->assertSame('delete_failed', $result['error']);
|
||||
}
|
||||
|
||||
public function testDeleteByUuidReturns409WhenUsersAssigned(): void
|
||||
{
|
||||
$this->departmentRepository
|
||||
->expects($this->once())
|
||||
->method('findByUuid')
|
||||
->with('abc-dept')
|
||||
->willReturn(['id' => 5, 'uuid' => 'abc-dept', 'description' => 'Sales']);
|
||||
|
||||
$userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class);
|
||||
$userDeptRepo->expects($this->once())
|
||||
->method('countUsersByDepartmentIds')
|
||||
->with([5])
|
||||
->willReturn([5 => 3]);
|
||||
$this->userServicesFactory->expects($this->once())
|
||||
->method('createUserDepartmentRepository')
|
||||
->willReturn($userDeptRepo);
|
||||
|
||||
$this->departmentRepository
|
||||
->expects($this->never())
|
||||
->method('delete');
|
||||
|
||||
$this->systemAuditService
|
||||
->expects($this->never())
|
||||
->method('record');
|
||||
|
||||
$result = $this->service->deleteByUuid('abc-dept');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame(409, $result['status']);
|
||||
$this->assertSame('department_has_users', $result['error']);
|
||||
$this->assertSame(3, $result['user_count']);
|
||||
}
|
||||
|
||||
public function testDeleteByUuidDeletesWhenNoUsersAssigned(): void
|
||||
{
|
||||
$this->departmentRepository
|
||||
->expects($this->once())
|
||||
->method('findByUuid')
|
||||
->with('abc-empty')
|
||||
->willReturn(['id' => 7, 'uuid' => 'abc-empty', 'description' => 'Empty Dept']);
|
||||
|
||||
$userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class);
|
||||
$userDeptRepo->expects($this->once())
|
||||
->method('countUsersByDepartmentIds')
|
||||
->with([7])
|
||||
->willReturn([]);
|
||||
$this->userServicesFactory->expects($this->once())
|
||||
->method('createUserDepartmentRepository')
|
||||
->willReturn($userDeptRepo);
|
||||
|
||||
$this->departmentRepository
|
||||
->expects($this->once())
|
||||
->method('delete')
|
||||
->with(7)
|
||||
->willReturn(true);
|
||||
|
||||
$this->systemAuditService
|
||||
->expects($this->once())
|
||||
->method('record');
|
||||
|
||||
$result = $this->service->deleteByUuid('abc-empty');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame('abc-empty', $result['department']['uuid']);
|
||||
}
|
||||
|
||||
public function testSyncTenantsUsesFirstId(): void
|
||||
{
|
||||
$this->departmentRepository
|
||||
|
||||
Reference in New Issue
Block a user