From 87d77e73aee2946ddfd918ea63cf4cb5a4d499c6 Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Fri, 9 Oct 2026 17:19:35 -0400 Subject: [PATCH 1/5] Allow self-service profile delegation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change enables self-service profile updates to set a valid delegation user and status values without exposing server-managed fields. The API now restricts editable statuses to the account owner’s allowed set, preserves current admin-assigned values when needed, clears invalid delegation references, and validates delegation ownership and non-system/non-deleted users. The profile editor also sends a filtered payload for self-service edits, and tests cover successful out-of-office delegation updates plus rejection of blocked/scheduled statuses. --- .../Http/Controllers/Api/UserController.php | 61 +++++++++++++++++++ .../Http/Controllers/ProfileController.php | 4 +- resources/views/profile/edit.blade.php | 5 +- tests/Feature/Api/UsersTest.php | 49 ++++++++++++++- 4 files changed, 116 insertions(+), 3 deletions(-) diff --git a/ProcessMaker/Http/Controllers/Api/UserController.php b/ProcessMaker/Http/Controllers/Api/UserController.php index e7e62248ee..9c95182ad4 100644 --- a/ProcessMaker/Http/Controllers/Api/UserController.php +++ b/ProcessMaker/Http/Controllers/Api/UserController.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Hash; +use Illuminate\Validation\Rule; use ProcessMaker\Events\UserCreated; use ProcessMaker\Events\UserDeleted; use ProcessMaker\Events\UserGroupMembershipUpdated; @@ -63,6 +64,7 @@ class UserController extends Controller 'timezone', 'datetime_format', 'status', + 'delegation_user_id', 'avatar', 'preferences_2fa', 'connected_accounts', @@ -79,6 +81,17 @@ class UserController extends Controller 'disableRecommendations', ]; + /** + * Statuses a personal-profile user may set on their own account. + * + * @var array + */ + private const SELF_SERVICE_STATUSES = [ + 'ACTIVE', + 'INACTIVE', + 'OUT_OF_OFFICE', + ]; + /** * Display a listing of the resource. * @@ -503,8 +516,21 @@ public function update(User $user, Request $request) $isSelfServiceUpdate = $this->authorizeSelfServiceUpdate($authenticatedUser, $user, $fields); $rules = User::rules($user); if ($isSelfServiceUpdate) { + $fields = $this->forgetUnassignableDelegation($fields); + if (array_key_exists('delegation_user_id', $fields)) { + $request->json()->set('delegation_user_id', $fields['delegation_user_id']); + } $rules['meta'] = ['sometimes', 'array']; $rules['meta.disableRecommendations'] = ['sometimes', 'boolean']; + $rules['status'] = ['required', Rule::in($this->selfServiceStatuses($user))]; + $rules['delegation_user_id'] = [ + 'nullable', + 'integer', + Rule::exists('users', 'id')->where(function ($query) { + $query->where('is_system', false)->whereNull('deleted_at'); + }), + Rule::notIn([(string) $user->id]), + ]; } $request->validate($rules); if ($isSelfServiceUpdate) { @@ -646,6 +672,41 @@ private function authorizeSelfServiceUpdate(User $authenticatedUser, User $targe return true; } + /** + * Statuses this user may submit. An administrator-assigned status that is + * not otherwise available stays valid so saving the profile does not + * reject the account's current value. + * + * @return array + */ + private function selfServiceStatuses(User $user): array + { + $statuses = self::SELF_SERVICE_STATUSES; + if ($user->status && !in_array($user->status, $statuses, true)) { + $statuses[] = $user->status; + } + + return $statuses; + } + + /** + * A replacement the profile picker can no longer load should not fail the + * rest of the save. Clear it and continue. + */ + private function forgetUnassignableDelegation(array $fields): array + { + if (empty($fields['delegation_user_id'])) { + return $fields; + } + + $delegate = User::withTrashed()->find($fields['delegation_user_id']); + if (!$delegate || $delegate->trashed() || $delegate->is_system) { + $fields['delegation_user_id'] = null; + } + + return $fields; + } + /** * Merge self-service metadata into the persisted server-managed values. */ diff --git a/ProcessMaker/Http/Controllers/ProfileController.php b/ProcessMaker/Http/Controllers/ProfileController.php index 0fdfae2e84..69133a0a76 100644 --- a/ProcessMaker/Http/Controllers/ProfileController.php +++ b/ProcessMaker/Http/Controllers/ProfileController.php @@ -62,10 +62,12 @@ function ($result, $item) { $is2FAEnabledForGroup = $currentUser->in2FAGroupOrIndependent(); $addons = $this->getPluginAddons('edit', []); + $addonsSettings = $this->getPluginAddons('edit.settings', []); return view('profile.edit', compact('currentUser', 'states', 'timezones', 'countries', 'datetimeFormats', - 'status', 'enabled2FA', 'global2FAEnabled', 'is2FAEnabledForGroup', 'addons', 'ssoUser')); + 'status', 'enabled2FA', 'global2FAEnabled', 'is2FAEnabledForGroup', 'addons', + 'addonsSettings', 'ssoUser')); } /** diff --git a/resources/views/profile/edit.blade.php b/resources/views/profile/edit.blade.php index e0ffc2ca1e..d3c0692218 100644 --- a/resources/views/profile/edit.blade.php +++ b/resources/views/profile/edit.blade.php @@ -148,11 +148,13 @@ 'timezone', 'datetime_format', 'status', + 'delegation_user_id', 'avatar', 'preferences_2fa', 'connected_accounts', 'valpassword', ]; + const isSelfServiceProfile = @json(!(Auth::user()->is_administrator || Auth::user()->hasPermission('edit-users'))); let formVueInstance = new Vue({ el: '#editProfile', mixins:addons, @@ -310,7 +312,8 @@ if (this.image === false) { this.formData.avatar = false; } - ProcessMaker.apiClient.put('users/' + this.formData.id, this.formData) + const payload = isSelfServiceProfile ? this.profilePayload() : this.formData; + ProcessMaker.apiClient.put('users/' + this.formData.id, payload) .then((response) => { // reset the slack configuration error this.slackConfigurationError = false; diff --git a/tests/Feature/Api/UsersTest.php b/tests/Feature/Api/UsersTest.php index 5d3ade9fb1..9649312b9b 100644 --- a/tests/Feature/Api/UsersTest.php +++ b/tests/Feature/Api/UsersTest.php @@ -960,7 +960,6 @@ public function testSelfServiceUpdateRejectsDisallowedFieldsWithoutPersistingCha $originalFirstname = $this->user->firstname; $disallowedFields = [ 'manager_id' => User::factory()->create()->id, - 'delegation_user_id' => User::factory()->create()->id, 'schedule' => ['monday' => []], 'force_change_password' => true, 'is_administrator' => true, @@ -989,6 +988,54 @@ public function testSelfServiceUpdateRejectsDisallowedFieldsWithoutPersistingCha ]); } + public function testSelfServiceUpdateCanSetOutOfOfficeAndDelegation(): void + { + $this->user = User::factory()->create(['is_administrator' => false, 'status' => 'ACTIVE']); + $this->user->giveDirectPermission('edit-personal-profile'); + $this->user->refresh(); + $this->flushSession(); + $delegate = User::factory()->create(['status' => 'ACTIVE']); + + $response = $this->apiCall( + 'PUT', + self::API_TEST_URL . '/' . $this->user->id, + $this->getSelfServiceUpdateData($this->user, [ + 'status' => 'OUT_OF_OFFICE', + 'delegation_user_id' => $delegate->id, + ]) + ); + + $response->assertStatus(204); + $this->assertDatabaseHas('users', [ + 'id' => $this->user->id, + 'status' => 'OUT_OF_OFFICE', + 'delegation_user_id' => $delegate->id, + ]); + } + + public function testSelfServiceUpdateRejectsBlockedAndScheduledStatuses(): void + { + $this->user = User::factory()->create(['is_administrator' => false, 'status' => 'ACTIVE']); + $this->user->giveDirectPermission('edit-personal-profile'); + $this->user->refresh(); + $this->flushSession(); + + foreach (['BLOCKED', 'SCHEDULED'] as $status) { + $response = $this->apiCall( + 'PUT', + self::API_TEST_URL . '/' . $this->user->id, + $this->getSelfServiceUpdateData($this->user, ['status' => $status]) + ); + + $response->assertStatus(422)->assertJsonValidationErrors('status'); + } + + $this->assertDatabaseHas('users', [ + 'id' => $this->user->id, + 'status' => 'ACTIVE', + ]); + } + public function testSelfServiceUpdateRejectsServerManagedMetadataWithoutPersistingChanges(): void { $this->user = User::factory()->create([ From 8de3728d70e9c59c2db0f9a36fc9b8e92c5a2631 Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Mon, 12 Oct 2026 14:31:13 -0400 Subject: [PATCH 2/5] Resolve CursorBot Issue --- .../Http/Controllers/ProfileController.php | 4 +- tests/Feature/ProfileTest.php | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/ProcessMaker/Http/Controllers/ProfileController.php b/ProcessMaker/Http/Controllers/ProfileController.php index 69133a0a76..a0d5a206b3 100644 --- a/ProcessMaker/Http/Controllers/ProfileController.php +++ b/ProcessMaker/Http/Controllers/ProfileController.php @@ -61,8 +61,8 @@ function ($result, $item) { $currentUser->preferences_2fa = $currentUser->getValid2FAPreferences(); $is2FAEnabledForGroup = $currentUser->in2FAGroupOrIndependent(); - $addons = $this->getPluginAddons('edit', []); - $addonsSettings = $this->getPluginAddons('edit.settings', []); + $addons = $this->getPluginAddons('edit', ['user' => $currentUser]); + $addonsSettings = $this->getPluginAddons('edit.settings', ['user' => $currentUser]); return view('profile.edit', compact('currentUser', 'states', 'timezones', 'countries', 'datetimeFormats', diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php index 666ee2b569..3f498e1559 100644 --- a/tests/Feature/ProfileTest.php +++ b/tests/Feature/ProfileTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use Illuminate\Support\Facades\Artisan; +use ProcessMaker\Http\Controllers\ProfileController; use ProcessMaker\Models\Group; use ProcessMaker\Models\GroupMember; use ProcessMaker\Models\Permission; @@ -91,4 +92,40 @@ public function testEditProfileGroupPermission(): void $response = $this->actingAs($user, 'web')->call('GET', $url); $response->assertStatus(200); } + + public function testEditProfilePassesSignedInUserToSettingsAddons(): void + { + Artisan::call('db:seed', ['class' => 'PermissionSeeder']); + $user = User::factory()->create([ + 'is_administrator' => false, + 'delegation_user_id' => User::factory()->create()->id, + ]); + $user->permissions()->attach(Permission::byName('edit-personal-profile')->id); + $user->refresh(); + $user->invalidatePermissionCache(); + + $addons = new \ReflectionProperty(ProfileController::class, 'addons'); + $originalAddons = $addons->getValue(); + $captured = null; + + try { + ProfileController::registerAddon([ + 'method' => 'edit.settings', + 'data' => function (array $data) use (&$captured) { + $captured = $data; + + return $data; + }, + ]); + + $response = $this->actingAs($user)->get(route('profile.edit')); + } finally { + $addons->setValue(null, $originalAddons); + } + + $response->assertStatus(200); + $this->assertInstanceOf(User::class, $captured['user'] ?? null); + $this->assertSame($user->id, $captured['user']->id); + $this->assertSame($user->delegation_user_id, $captured['user']->delegation_user_id); + } } From 072862fbc4115dbe6f318d94e03c4451311e0711 Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Tue, 13 Oct 2026 14:29:17 -0400 Subject: [PATCH 3/5] Fix delegated assignee handling for OOO users Ensure assignments for out-of-office users only resolve to active, valid replacements. This adds active delegation checks and assignee validation to task assignment flows, updates the raw repository queries to ignore soft-deleted/inactive delegation targets, and covers the behavior with regression tests for variable-based task assignment and user delegation rules. --- ProcessMaker/Models/Process.php | 14 +++++--- ProcessMaker/Models/ProcessRequestToken.php | 6 +++- ProcessMaker/Models/User.php | 31 +++++++++++++++++ .../ProcessExecutionRawRepository.php | 16 +++++++-- .../Api/TaskAssignmentByVariableTest.php | 33 +++++++++++++++++++ tests/Model/UserTest.php | 23 +++++++++++++ 6 files changed, 114 insertions(+), 9 deletions(-) diff --git a/ProcessMaker/Models/Process.php b/ProcessMaker/Models/Process.php index f7390f1aaa..99e17e3e47 100644 --- a/ProcessMaker/Models/Process.php +++ b/ProcessMaker/Models/Process.php @@ -724,16 +724,20 @@ public function scalateToManagerIfEnabled($user, $activity, $token, $assignmentT if (app()->bound('workflow.UserManager') && $assignmentProcess) { $config = json_decode($activity->getProperty('config', '{}'), true); $escalateToManager = $config['escalateToManager'] ?? false; + $assignee = User::find($user); if ($escalateToManager) { - $user = WorkflowUserManager::escalateToManager($token, $user); + $assignedUserId = WorkflowUserManager::escalateToManager($token, $user); } else { $res = (new WorkflowManagerDefault)->runProcess($assignmentProcess, 'assign', [ 'user_id' => $user, 'process_id' => $this->id, 'request_id' => $token->getInstance()->getId(), ]); - $user = $res['assign_to']; + $assignedUserId = $res['assign_to'] ?? null; } + $user = $assignee + ? $assignee->acceptResolvedAssignee($assignedUserId) + : $assignedUserId; } } @@ -806,9 +810,9 @@ private function getNextUserFromProcessVariable($activity, $token) $outOfOffice = User::whereIn('id', array_unique($assignedUsers))->where('status', 'OUT_OF_OFFICE')->get(); foreach ($outOfOffice as $user) { - $delegation = $user->delegationUser()->pluck('id')->toArray(); - if ($delegation) { - $users[] = $delegation[0]; + $delegationId = $user->activeDelegationUserId(); + if ($delegationId) { + $users[] = $delegationId; } } diff --git a/ProcessMaker/Models/ProcessRequestToken.php b/ProcessMaker/Models/ProcessRequestToken.php index b65c1b0a06..fdf8b403a9 100644 --- a/ProcessMaker/Models/ProcessRequestToken.php +++ b/ProcessMaker/Models/ProcessRequestToken.php @@ -1323,7 +1323,11 @@ public function reassignTo($userId) 'process_id' => $this->process_id, 'request_id' => $this->process_request_id, ]); - $this->user_id = $res['assign_to']; + $assignedUserId = $res['assign_to'] ?? null; + $assignee = User::find($userId); + $this->user_id = $assignee + ? $assignee->acceptResolvedAssignee($assignedUserId) + : $assignedUserId; } return $this; diff --git a/ProcessMaker/Models/User.php b/ProcessMaker/Models/User.php index 5f15ac4e36..d48c2baad6 100644 --- a/ProcessMaker/Models/User.php +++ b/ProcessMaker/Models/User.php @@ -510,6 +510,37 @@ public function delegationUser() return $this->belongsTo(self::class); } + /** + * Replacement who can take a task right now. A stored replacement can + * become inactive or out of office after it was selected. + */ + public function activeDelegationUserId(): ?int + { + $delegateId = $this->delegationUser()->where('status', 'ACTIVE')->value('id'); + + return $delegateId ? (int) $delegateId : null; + } + + /** + * Keep a resolved assignee unless it is this user's replacement and that + * replacement cannot take a task. + * + * @param mixed $resolvedUserId + * @return mixed + */ + public function acceptResolvedAssignee($resolvedUserId) + { + if (!$resolvedUserId || (int) $resolvedUserId === (int) $this->id) { + return $resolvedUserId; + } + + if ((int) $this->delegation_user_id !== (int) $resolvedUserId) { + return $resolvedUserId; + } + + return $this->activeDelegationUserId() ? $resolvedUserId : null; + } + /** * User's Manager are user associations that allow for automatic reassignment based on specific rules in the task assignment. * diff --git a/ProcessMaker/Repositories/ProcessExecutionRawRepository.php b/ProcessMaker/Repositories/ProcessExecutionRawRepository.php index cf7685c7bc..dbb1d1fb66 100644 --- a/ProcessMaker/Repositories/ProcessExecutionRawRepository.php +++ b/ProcessMaker/Repositories/ProcessExecutionRawRepository.php @@ -350,7 +350,10 @@ public function getUserByIdRaw(?int $userId): ?User return null; } - $row = DB::selectOne('SELECT * FROM users WHERE id = ? LIMIT 1', [$userId]); + $row = DB::selectOne( + 'SELECT * FROM users WHERE id = ? AND deleted_at IS NULL LIMIT 1', + [$userId] + ); if (!$row) { return null; } @@ -566,8 +569,15 @@ private function getNextUserFromProcessVariableRaw( $oooPlaceholders = implode(',', array_fill(0, count($uniqueUsers), '?')); $oooRows = DB::select( - "SELECT delegation_user_id FROM users WHERE id IN ($oooPlaceholders) AND status = ? AND delegation_user_id IS NOT NULL", - array_merge($uniqueUsers, ['OUT_OF_OFFICE']) + "SELECT delegate.id AS delegation_user_id + FROM users + INNER JOIN users AS delegate ON delegate.id = users.delegation_user_id + WHERE users.id IN ($oooPlaceholders) + AND users.status = ? + AND users.delegation_user_id IS NOT NULL + AND delegate.status = ? + AND delegate.deleted_at IS NULL", + array_merge($uniqueUsers, ['OUT_OF_OFFICE', 'ACTIVE']) ); foreach ($oooRows as $row) { $users[(int) $row->delegation_user_id] = (int) $row->delegation_user_id; diff --git a/tests/Feature/Api/TaskAssignmentByVariableTest.php b/tests/Feature/Api/TaskAssignmentByVariableTest.php index 1d62397894..ea75afe1a1 100644 --- a/tests/Feature/Api/TaskAssignmentByVariableTest.php +++ b/tests/Feature/Api/TaskAssignmentByVariableTest.php @@ -250,6 +250,39 @@ public function testProcessVariableAssignmentWithObjectVariable() $this->assertEquals($users->first()->id, $task->user_id); } + public function testOutOfOfficeAssignmentUsesOnlyAnActiveReplacement() + { + $activeReplacement = User::factory()->create(['status' => 'ACTIVE']); + $outOfOffice = User::factory()->create([ + 'status' => 'OUT_OF_OFFICE', + 'delegation_user_id' => $activeReplacement->id, + ]); + $process = $this->createProcess('process_variable', 'usersVariable', '', '', false); + + $response = $this->startTestProcess($process, ['usersVariable' => $outOfOffice->id]); + $task = ProcessRequestToken::where([ + 'process_request_id' => $response['id'], + 'status' => 'ACTIVE', + ])->firstOrFail(); + $this->assertEquals($activeReplacement->id, $task->user_id); + + $manager = User::factory()->create(['status' => 'ACTIVE']); + foreach (['INACTIVE', 'OUT_OF_OFFICE'] as $status) { + $unavailableReplacement = User::factory()->create(['status' => $status]); + $outOfOffice->delegation_user_id = $unavailableReplacement->id; + $outOfOffice->save(); + $process = $this->createProcess('process_variable', 'usersVariable', '', '', false); + $process->manager_id = [$manager->id]; + $process->save(); + + $response = $this->startTestProcess($process, ['usersVariable' => $outOfOffice->id]); + $assignedToReplacement = ProcessRequestToken::where('process_request_id', $response['id']) + ->where('user_id', $unavailableReplacement->id) + ->exists(); + $this->assertFalse($assignedToReplacement); + } + } + public function testProcessVariableAssignmentWithLiteralId() { // Create users of a group and a user without group diff --git a/tests/Model/UserTest.php b/tests/Model/UserTest.php index d7249f51b6..fe4103a078 100644 --- a/tests/Model/UserTest.php +++ b/tests/Model/UserTest.php @@ -143,4 +143,27 @@ public function testRefreshInvalidatesBothPermissionCacheFamilies() $this->assertNull(Cache::get("user_{$user->id}_permissions")); $this->assertSame('keep-me', Cache::get('unrelated-cache-key')); } + + public function testOnlyAnActiveReplacementCanReceiveADelegatedTask(): void + { + $activeReplacement = User::factory()->create(['status' => 'ACTIVE']); + $owner = User::factory()->create([ + 'status' => 'OUT_OF_OFFICE', + 'delegation_user_id' => $activeReplacement->id, + ]); + + $this->assertSame($activeReplacement->id, $owner->activeDelegationUserId()); + $this->assertSame($activeReplacement->id, $owner->acceptResolvedAssignee($activeReplacement->id)); + + foreach (['INACTIVE', 'OUT_OF_OFFICE', 'BLOCKED', 'SCHEDULED'] as $status) { + $unavailableReplacement = User::factory()->create(['status' => $status]); + $owner->delegation_user_id = $unavailableReplacement->id; + $owner->save(); + + $this->assertNull($owner->activeDelegationUserId()); + $this->assertNull($owner->acceptResolvedAssignee($unavailableReplacement->id)); + } + + $this->assertSame($owner->id, $owner->acceptResolvedAssignee($owner->id)); + } } From 7a0f6133ec11a77d42685b5eb8765c7bc78c82cf Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Tue, 13 Oct 2026 16:44:45 -0400 Subject: [PATCH 4/5] Resolve unavailable assignee to process manager When an assignment resolves to a user who cannot take the task, the token now falls back to the process manager instead of leaving the task unassigned. This also clears stale user relationships before notifying reassignees and adds a feature test covering inactive, out-of-office, and empty replacement cases. --- ProcessMaker/Models/ProcessRequestToken.php | 43 ++++++++++++++++++--- tests/Feature/Api/TaskAssignmentTest.php | 36 +++++++++++++++++ 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/ProcessMaker/Models/ProcessRequestToken.php b/ProcessMaker/Models/ProcessRequestToken.php index fdf8b403a9..178e166db0 100644 --- a/ProcessMaker/Models/ProcessRequestToken.php +++ b/ProcessMaker/Models/ProcessRequestToken.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Notification; use Laravel\Scout\Searchable; use Log; +use ProcessMaker\AssignmentRules\ProcessManagerAssigned; use ProcessMaker\Casts\MillisecondsToDateCast; use ProcessMaker\Contracts\ConditionalRedirectServiceInterface; use ProcessMaker\Events\ActivityAssigned; @@ -1324,15 +1325,42 @@ public function reassignTo($userId) 'request_id' => $this->process_request_id, ]); $assignedUserId = $res['assign_to'] ?? null; - $assignee = User::find($userId); - $this->user_id = $assignee - ? $assignee->acceptResolvedAssignee($assignedUserId) - : $assignedUserId; + $this->user_id = $this->resolveAssignedUserId($userId, $assignedUserId); + $this->unsetRelation('user'); } return $this; } + /** + * Assignee to store after the assignment process runs. When nobody can + * take the task, it falls back to the Process Manager instead of being + * left unassigned. + * + * @param mixed $requestedUserId + * @param mixed $assignedUserId + * @return mixed + */ + public function resolveAssignedUserId($requestedUserId, $assignedUserId) + { + $assignee = User::find($requestedUserId); + $resolvedUserId = $assignee + ? $assignee->acceptResolvedAssignee($assignedUserId) + : $assignedUserId; + + return $resolvedUserId ?: $this->processManagerId(); + } + + /** + * @return int + */ + private function processManagerId(): int + { + $request = $this->processRequest; + + return (new ProcessManagerAssigned())->getNextUser($this->getDefinition(true), $this, $request->process, $request); + } + /** * Returns True is the tokens belongs to a MultiInstance Task * @@ -1453,8 +1481,11 @@ public function reassign($toUserId, User $requestingUser, $comments = '') } // Send a notification to the user - $notification = new TaskReassignmentNotification($this); - $this->user->notify($notification); + $this->unsetRelation('user'); + if ($this->user) { + $notification = new TaskReassignmentNotification($this); + $this->user->notify($notification); + } if ($this->element_type === 'task') { event(new ActivityAssigned($this)); } diff --git a/tests/Feature/Api/TaskAssignmentTest.php b/tests/Feature/Api/TaskAssignmentTest.php index 58f2ec7f3e..1c1544d1a2 100644 --- a/tests/Feature/Api/TaskAssignmentTest.php +++ b/tests/Feature/Api/TaskAssignmentTest.php @@ -130,6 +130,42 @@ public function testUpdateTaskAssignment() $this->assertEquals('ProcessMaker\Models\Group', $assignment->assignment_type); } + /** + * An unavailable replacement is resolved to the process manager + */ + public function testUnavailableReplacementResolvesToProcessManager() + { + $manager = User::factory()->create(['status' => 'ACTIVE']); + $process = Process::factory()->create([ + 'status' => 'ACTIVE', + 'bpmn' => file_get_contents(__DIR__ . '/processes/InvalidUserAssignment.bpmn'), + ]); + $process->manager_id = $manager->id; + $process->save(); + $instance = $this->startProcess($process, 'node_1'); + $token = $instance->tokens()->where('status', 'ACTIVE')->firstOrFail(); + + $replacement = User::factory()->create(['status' => 'INACTIVE']); + $outOfOffice = User::factory()->create([ + 'status' => 'OUT_OF_OFFICE', + 'delegation_user_id' => $replacement->id, + ]); + + $this->assertSame( + $manager->id, + $token->resolveAssignedUserId($outOfOffice->id, $replacement->id) + ); + + $activeReplacement = User::factory()->create(['status' => 'ACTIVE']); + $outOfOffice->delegation_user_id = $activeReplacement->id; + $outOfOffice->save(); + $this->assertSame( + $activeReplacement->id, + $token->resolveAssignedUserId($outOfOffice->id, $activeReplacement->id) + ); + $this->assertSame($manager->id, $token->resolveAssignedUserId($outOfOffice->id, null)); + } + /** * Invalid user assignment is reassigned to the process manager */ From 247dc26c0f70caa925af13227f87dc3d987c28ee Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Mon, 19 Oct 2026 11:51:46 -0400 Subject: [PATCH 5/5] Enforce ACTIVE delegation and assignee resolution This change restricts delegation assignment validation to non-system, non-deleted, ACTIVE users and clears stale delegation_user_id values for missing, deleted, system, or inactive delegates. It also fixes assignee resolution during escalation so the resolved manager user is used consistently before accepting the final assignee. --- .../Http/Controllers/Api/UserController.php | 13 +++++++-- ProcessMaker/Models/Process.php | 8 +++--- tests/Feature/Api/UsersTest.php | 28 +++++++++++++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/ProcessMaker/Http/Controllers/Api/UserController.php b/ProcessMaker/Http/Controllers/Api/UserController.php index 9c95182ad4..351a1c84ab 100644 --- a/ProcessMaker/Http/Controllers/Api/UserController.php +++ b/ProcessMaker/Http/Controllers/Api/UserController.php @@ -527,7 +527,9 @@ public function update(User $user, Request $request) 'nullable', 'integer', Rule::exists('users', 'id')->where(function ($query) { - $query->where('is_system', false)->whereNull('deleted_at'); + $query->where('is_system', false) + ->whereNull('deleted_at') + ->where('status', 'ACTIVE'); }), Rule::notIn([(string) $user->id]), ]; @@ -691,7 +693,7 @@ private function selfServiceStatuses(User $user): array /** * A replacement the profile picker can no longer load should not fail the - * rest of the save. Clear it and continue. + * rest of the save. Clear missing, deleted, system, and non-ACTIVE users. */ private function forgetUnassignableDelegation(array $fields): array { @@ -700,7 +702,12 @@ private function forgetUnassignableDelegation(array $fields): array } $delegate = User::withTrashed()->find($fields['delegation_user_id']); - if (!$delegate || $delegate->trashed() || $delegate->is_system) { + if ( + !$delegate + || $delegate->trashed() + || $delegate->is_system + || $delegate->status !== 'ACTIVE' + ) { $fields['delegation_user_id'] = null; } diff --git a/ProcessMaker/Models/Process.php b/ProcessMaker/Models/Process.php index 99e17e3e47..b87cde0286 100644 --- a/ProcessMaker/Models/Process.php +++ b/ProcessMaker/Models/Process.php @@ -726,7 +726,7 @@ public function scalateToManagerIfEnabled($user, $activity, $token, $assignmentT $escalateToManager = $config['escalateToManager'] ?? false; $assignee = User::find($user); if ($escalateToManager) { - $assignedUserId = WorkflowUserManager::escalateToManager($token, $user); + $user = WorkflowUserManager::escalateToManager($token, $user); } else { $res = (new WorkflowManagerDefault)->runProcess($assignmentProcess, 'assign', [ 'user_id' => $user, @@ -734,10 +734,10 @@ public function scalateToManagerIfEnabled($user, $activity, $token, $assignmentT 'request_id' => $token->getInstance()->getId(), ]); $assignedUserId = $res['assign_to'] ?? null; + $user = $assignee + ? $assignee->acceptResolvedAssignee($assignedUserId) + : $assignedUserId; } - $user = $assignee - ? $assignee->acceptResolvedAssignee($assignedUserId) - : $assignedUserId; } } diff --git a/tests/Feature/Api/UsersTest.php b/tests/Feature/Api/UsersTest.php index 9649312b9b..4205ef609c 100644 --- a/tests/Feature/Api/UsersTest.php +++ b/tests/Feature/Api/UsersTest.php @@ -988,6 +988,34 @@ public function testSelfServiceUpdateRejectsDisallowedFieldsWithoutPersistingCha ]); } + public function testSelfServiceUpdateClearsStaleInactiveDelegationOnSave(): void + { + $this->user = User::factory()->create(['is_administrator' => false, 'status' => 'ACTIVE']); + $this->user->giveDirectPermission('edit-personal-profile'); + $inactiveDelegate = User::factory()->create(['status' => 'INACTIVE']); + $this->user->delegation_user_id = $inactiveDelegate->id; + $this->user->status = 'OUT_OF_OFFICE'; + $this->user->save(); + $this->user->refresh(); + $this->flushSession(); + + $response = $this->apiCall( + 'PUT', + self::API_TEST_URL . '/' . $this->user->id, + $this->getSelfServiceUpdateData($this->user, [ + 'status' => 'OUT_OF_OFFICE', + 'delegation_user_id' => $inactiveDelegate->id, + ]) + ); + + $response->assertStatus(204); + $this->assertDatabaseHas('users', [ + 'id' => $this->user->id, + 'status' => 'OUT_OF_OFFICE', + 'delegation_user_id' => null, + ]); + } + public function testSelfServiceUpdateCanSetOutOfOfficeAndDelegation(): void { $this->user = User::factory()->create(['is_administrator' => false, 'status' => 'ACTIVE']);