Skip to content

Commit e49fafd

Browse files
committed
Merge branch 'develop' into task/FOUR-28803
2 parents 5da27ce + 1159229 commit e49fafd

20 files changed

Lines changed: 1110 additions & 234 deletions

ProcessMaker/Http/Controllers/Auth/ForgotPasswordController.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
namespace ProcessMaker\Http\Controllers\Auth;
44

55
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Support\Facades\Password;
68
use ProcessMaker\Http\Controllers\Controller;
9+
use ProcessMaker\Models\User;
710

811
class ForgotPasswordController extends Controller
912
{
@@ -29,4 +32,30 @@ public function __construct()
2932
{
3033
$this->middleware('guest');
3134
}
35+
36+
/**
37+
* Send a reset link to the given user.
38+
* Blocked or inactive users will not receive the reset email for security reasons.
39+
*
40+
* @param Request $request
41+
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
42+
*/
43+
public function sendResetLinkEmail(Request $request)
44+
{
45+
$this->validateEmail($request);
46+
47+
$user = User::where('email', $request->input('email'))->first();
48+
49+
if ($user && ($user->status === 'BLOCKED' || $user->status === 'INACTIVE')) {
50+
return $this->sendResetLinkResponse($request, Password::RESET_LINK_SENT);
51+
}
52+
53+
$response = $this->broker()->sendResetLink(
54+
$this->credentials($request)
55+
);
56+
57+
return $response == Password::RESET_LINK_SENT
58+
? $this->sendResetLinkResponse($request, $response)
59+
: $this->sendResetLinkFailedResponse($request, $response);
60+
}
3261
}

ProcessMaker/Http/Controllers/Auth/ResetPasswordController.php

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
use Illuminate\Foundation\Auth\ResetsPasswords;
66
use Illuminate\Http\Request;
7+
use Illuminate\Validation\Rules\Password;
8+
use Illuminate\Validation\ValidationException;
79
use ProcessMaker\Http\Controllers\Controller;
810
use ProcessMaker\Models\User;
911

@@ -20,7 +22,9 @@ class ResetPasswordController extends Controller
2022
|
2123
*/
2224

23-
use ResetsPasswords;
25+
use ResetsPasswords {
26+
reset as protected performPasswordReset;
27+
}
2428

2529
/**
2630
* Where to redirect users after resetting their password.
@@ -46,8 +50,96 @@ public function __construct()
4650
*/
4751
public function showResetForm(Request $request, $token)
4852
{
49-
$username = User::where('email', $request->input('email'))->firstOrFail()->username;
53+
$user = User::where('email', $request->input('email'))->firstOrFail();
54+
55+
if ($user->status === 'BLOCKED') {
56+
return redirect()->route('password.request')
57+
->withErrors(['email' => __('passwords.blocked')]);
58+
}
59+
60+
if ($user->status === 'INACTIVE') {
61+
return redirect()->route('password.request')
62+
->withErrors(['email' => __('passwords.inactive')]);
63+
}
64+
65+
return view('auth.passwords.reset', [
66+
'username' => $user->username,
67+
'token' => $token,
68+
'email' => $request->input('email'),
69+
]);
70+
}
71+
72+
/**
73+
* Reset the given user's password.
74+
* Blocked or inactive users cannot reset their password.
75+
*
76+
* @param Request $request
77+
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
78+
*/
79+
public function reset(Request $request)
80+
{
81+
$user = User::where('email', $request->input('email'))
82+
->where('username', $request->input('username'))
83+
->first();
84+
85+
if ($user && $user->status === 'BLOCKED') {
86+
return $this->sendResetFailedResponse($request, 'passwords.blocked');
87+
}
88+
89+
if ($user && $user->status === 'INACTIVE') {
90+
return $this->sendResetFailedResponse($request, 'passwords.inactive');
91+
}
92+
93+
if (!$user) {
94+
return redirect()->back()
95+
->withInput($request->only('email', 'username'))
96+
->withErrors(['email' => __('passwords.account_not_found')]);
97+
}
98+
99+
return $this->performPasswordReset($request);
100+
}
101+
102+
/**
103+
* Get the password reset validation rules.
104+
*/
105+
protected function rules(): array
106+
{
107+
return [
108+
'token' => 'required',
109+
'email' => 'required|email',
110+
'username' => 'required|string',
111+
'password' => ['required', 'confirmed', Password::defaults()],
112+
];
113+
}
114+
115+
/**
116+
* Get the password reset credentials from the request.
117+
* Include username so the broker resolves the same user as email+username (not email alone).
118+
*/
119+
protected function credentials(Request $request): array
120+
{
121+
return $request->only(
122+
'email',
123+
'username',
124+
'password',
125+
'password_confirmation',
126+
'token'
127+
);
128+
}
129+
130+
/**
131+
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
132+
*/
133+
protected function sendResetFailedResponse(Request $request, $response)
134+
{
135+
if ($request->wantsJson()) {
136+
throw ValidationException::withMessages([
137+
'email' => [trans($response)],
138+
]);
139+
}
50140

51-
return view('auth.passwords.reset', compact('username', 'token'));
141+
return redirect()->back()
142+
->withInput($request->only('email', 'username'))
143+
->withErrors(['email' => trans($response)]);
52144
}
53145
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
namespace ProcessMaker\Http\Middleware;
4+
5+
use Closure;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Support\Facades\Auth;
8+
use ProcessMaker\Models\User;
9+
use Symfony\Component\HttpFoundation\Response;
10+
11+
class EnsureAccountAllowsAccess
12+
{
13+
/**
14+
* Status values that cannot use the application while authenticated (aligned with LoginController).
15+
*/
16+
private const DENIED_STATUSES = ['BLOCKED', 'INACTIVE'];
17+
18+
/**
19+
* If any active guard has a blocked/inactive user, log out and return the appropriate response.
20+
* Used by the web middleware group and by ProcessMakerAuthenticate (covers all auth:api routes, including packages).
21+
*/
22+
public static function blockingResponseForRequest(Request $request): ?Response
23+
{
24+
foreach (['web', 'api'] as $guard) {
25+
if (!Auth::guard($guard)->check()) {
26+
continue;
27+
}
28+
29+
/** @var User $user */
30+
$user = Auth::guard($guard)->user();
31+
if (!$user instanceof User || !in_array($user->status, self::DENIED_STATUSES, true)) {
32+
continue;
33+
}
34+
35+
return self::denyAccess($request, $guard, $user);
36+
}
37+
38+
return null;
39+
}
40+
41+
public function handle(Request $request, Closure $next): Response
42+
{
43+
$blocked = self::blockingResponseForRequest($request);
44+
if ($blocked !== null) {
45+
return $blocked;
46+
}
47+
48+
return $next($request);
49+
}
50+
51+
public static function denyAccess(Request $request, string $guard, User $user): Response
52+
{
53+
Auth::guard($guard)->logout();
54+
55+
if ($request->hasSession()) {
56+
$request->session()->invalidate();
57+
$request->session()->regenerateToken();
58+
}
59+
60+
if ($guard === 'api' || $request->expectsJson()) {
61+
$message = $user->status === 'BLOCKED'
62+
? __('Account locked after too many failed attempts. Contact administrator.')
63+
: __('Unauthorized');
64+
65+
return response()->json(['message' => $message], 401);
66+
}
67+
68+
return redirect()
69+
->guest(route('login'))
70+
->withErrors([
71+
'username' => $user->status === 'BLOCKED'
72+
? __('Account locked after too many failed attempts. Contact administrator.')
73+
: __('These credentials do not match our records.'),
74+
]);
75+
}
76+
}

ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,29 @@
22

33
namespace ProcessMaker\Http\Middleware;
44

5+
use Closure;
56
use Illuminate\Auth\Middleware\Authenticate;
67
use Illuminate\Support\Str;
78

89
class ProcessMakerAuthenticate extends Authenticate
910
{
11+
/**
12+
* {@inheritdoc}
13+
*
14+
* After a successful authenticate(), reject BLOCKED/INACTIVE users so every auth:api route
15+
* (core and packages) is covered without listing middleware per route file.
16+
*/
17+
public function handle($request, Closure $next, ...$guards)
18+
{
19+
$this->authenticate($request, $guards);
20+
21+
if ($blocked = EnsureAccountAllowsAccess::blockingResponseForRequest($request)) {
22+
return $blocked;
23+
}
24+
25+
return $next($request);
26+
}
27+
1028
protected function authenticate($request, array $guards)
1129
{
1230
$this->addAcceptJsonHeaderIfApiCall($request, $guards);

ProcessMaker/Models/ProcessRequestToken.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
use ProcessMaker\Events\ActivityAssigned;
1818
use ProcessMaker\Events\ActivityReassignment;
1919
use ProcessMaker\Facades\WorkflowUserManager;
20+
use ProcessMaker\Managers\DataManager;
21+
use ProcessMaker\Models\MustacheExpressionEvaluator;
2022
use ProcessMaker\Nayra\Bpmn\TokenTrait;
2123
use ProcessMaker\Nayra\Contracts\Bpmn\ActivityInterface;
2224
use ProcessMaker\Nayra\Contracts\Bpmn\FlowElementInterface;
@@ -1440,6 +1442,59 @@ public function reassign($toUserId, User $requestingUser, $comments = '')
14401442
}
14411443
}
14421444

1445+
/**
1446+
* Build context for Mustache (end event external URL). Same as scripts/screens: _user, _request, process data, APP_URL.
1447+
*/
1448+
private function getElementDestinationMustacheContext(): array
1449+
{
1450+
try {
1451+
$context = (new DataManager())->getData($this);
1452+
} catch (Throwable $e) {
1453+
Log::warning('Failed to load Mustache context via DataManager, falling back to request data', [
1454+
'token_id' => $this->id,
1455+
'error' => $e->getMessage(),
1456+
]);
1457+
$request = $this->processRequest;
1458+
$context = array_merge($request->data ?? [], $request ? (new DataManager())->updateRequestMagicVariable([], $request) : []);
1459+
$user = $this->user ?? auth()->user();
1460+
if ($user) {
1461+
$userData = $user->attributesToArray();
1462+
unset($userData['remember_token']);
1463+
$context['_user'] = $userData;
1464+
}
1465+
}
1466+
1467+
$context['APP_URL'] = config('app.url');
1468+
1469+
// Never expose remember_token to Mustache (defense in depth; DataManager/fallback may already strip it)
1470+
if (isset($context['_user']) && is_array($context['_user'])) {
1471+
unset($context['_user']['remember_token']);
1472+
}
1473+
1474+
// Normalize to plain arrays/scalars so Mustache resolves all keys (common PHP idiom)
1475+
$json = json_encode($context, JSON_THROW_ON_ERROR);
1476+
$normalized = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
1477+
1478+
return is_array($normalized) ? $normalized : [];
1479+
}
1480+
1481+
/**
1482+
* Resolve Mustache in end event external URL. FEEL is not supported here; use Mustache only.
1483+
* Context: APP_URL, _request, _user, process variables (same as getElementDestinationMustacheContext).
1484+
*
1485+
* Example (Mustache):
1486+
* {{APP_URL}}/admin/users/{{_request.id}}/edit -> https://example.com/admin/users/123/edit
1487+
* {{APP_URL}}/webentry/{{_request.id}} -> https://example.com/webentry/123
1488+
* {{APP_URL}}/path/{{my_process_var}} -> uses process variable my_process_var
1489+
*/
1490+
private function resolveElementDestinationUrl(string $url): string
1491+
{
1492+
$url = html_entity_decode($url, ENT_QUOTES | ENT_HTML401, 'UTF-8');
1493+
$context = $this->getElementDestinationMustacheContext();
1494+
1495+
return (new MustacheExpressionEvaluator())->render($url, $context);
1496+
}
1497+
14431498
/**
14441499
* Determines the destination based on the type of element destination property
14451500
*
@@ -1481,6 +1536,11 @@ private function getElementDestination($elementDestinationType, $elementDestinat
14811536
$elementDestination = $elementDestinationProp['value']['url'] ?? null;
14821537
}
14831538
}
1539+
if (is_string($elementDestination) && $elementDestination !== '') {
1540+
if ($elementDestinationType === 'externalURL' || $elementDestinationType === 'customDashboard') {
1541+
$elementDestination = $this->resolveElementDestinationUrl($elementDestination);
1542+
}
1543+
}
14841544
break;
14851545
case 'taskList':
14861546
$elementDestination = route('tasks.index');

ProcessMaker/RollbackProcessRequest.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ public function rollback(
127127
break;
128128
}
129129

130+
// update the process request status to active
130131
$processRequest = $this->newTask->processRequest;
131132
$processRequest->status = 'ACTIVE';
132133
$process = $processRequest->process;
@@ -135,9 +136,12 @@ public function rollback(
135136
)->id;
136137
$processRequest->saveOrFail();
137138

139+
// update the current task status to closed
138140
$currentTask->status = 'CLOSED';
139141
$currentTask->saveOrFail();
140142

143+
$this->syncParentProcessStatus($processRequest);
144+
141145
return $this->newTask;
142146
}
143147

@@ -209,4 +213,25 @@ private function addComment() : void
209213
'case_number' => isset($this->currentTask->case_number) ? $this->currentTask->case_number : null,
210214
]);
211215
}
216+
217+
/**
218+
* When the rolled-back request is a subprocess, reactivate the parent request
219+
* and the parent's call activity tokens that were in error.
220+
*/
221+
private function syncParentProcessStatus(ProcessRequest $processRequest): void
222+
{
223+
$parentRequest = $processRequest->parentRequest;
224+
if (!$parentRequest) {
225+
return;
226+
}
227+
228+
if (in_array($parentRequest->status, ['ERROR', 'FAILING'])) {
229+
$parentRequest->status = 'ACTIVE';
230+
$parentRequest->saveOrFail();
231+
}
232+
233+
ProcessRequestToken::where('subprocess_request_id', $processRequest->id)
234+
->whereIn('status', ['ERROR', 'FAILING'])
235+
->update(['status' => 'ACTIVE']);
236+
}
212237
}

0 commit comments

Comments
 (0)