diff --git a/lib/Support/helpers/request.php b/lib/Support/helpers/request.php
index c36051f..79a302a 100644
--- a/lib/Support/helpers/request.php
+++ b/lib/Support/helpers/request.php
@@ -3,6 +3,7 @@
use MintyPHP\Http\Input\FormErrors;
use MintyPHP\Http\Input\RequestInput;
use MintyPHP\Http\Input\RequestInputFactory;
+use MintyPHP\Http\Request;
use MintyPHP\Support\Flash;
function requestInput(): RequestInput
@@ -36,3 +37,160 @@ function flashFormErrors(FormErrors $errors, ?string $scope = null, string $keyP
$index++;
}
}
+
+function requestExtractNestedReturnValue(mixed $value): string
+{
+ if (is_string($value)) {
+ return trim($value);
+ }
+ if (!is_array($value)) {
+ return '';
+ }
+
+ for ($i = count($value) - 1; $i >= 0; $i--) {
+ $item = $value[$i] ?? null;
+ if (is_string($item) && trim($item) !== '') {
+ return trim($item);
+ }
+ }
+
+ return '';
+}
+
+function requestLooksLikeDetailPath(string $path): bool
+{
+ $path = trim($path, '/');
+ if ($path === '') {
+ return false;
+ }
+
+ $segments = array_values(array_filter(explode('/', $path), static fn (string $segment): bool => $segment !== ''));
+ return in_array('edit', $segments, true) || in_array('create', $segments, true);
+}
+
+function requestNormalizeSafeInternalTarget(string $target, int $maxDepth = 5): string
+{
+ $target = trim($target);
+ if ($target === '') {
+ return '';
+ }
+
+ $parts = parse_url($target);
+ if (
+ !is_array($parts)
+ || ($parts['scheme'] ?? '') !== ''
+ || ($parts['host'] ?? '') !== ''
+ ) {
+ return '';
+ }
+
+ $safeTarget = Request::safeReturnTarget($target);
+ if ($safeTarget === '') {
+ return '';
+ }
+
+ $safeParts = parse_url($safeTarget);
+ if (!is_array($safeParts)) {
+ return '';
+ }
+
+ $path = Request::stripLocale(Request::stripBasePath((string) ($safeParts['path'] ?? '')));
+ if ($path === '') {
+ return '';
+ }
+
+ $queryParams = [];
+ parse_str((string) ($safeParts['query'] ?? ''), $queryParams);
+ if (!is_array($queryParams)) {
+ $queryParams = [];
+ }
+ $nestedReturnTarget = requestExtractNestedReturnValue($queryParams['return'] ?? null);
+ unset($queryParams['return']);
+ $query = http_build_query($queryParams);
+ $normalizedOuterTarget = $path . ($query !== '' ? '?' . $query : '');
+
+ if ($maxDepth > 0 && $nestedReturnTarget !== '' && requestLooksLikeDetailPath($path)) {
+ $resolvedNestedTarget = requestNormalizeSafeInternalTarget($nestedReturnTarget, $maxDepth - 1);
+ if ($resolvedNestedTarget !== '') {
+ return $resolvedNestedTarget;
+ }
+ }
+
+ return $normalizedOuterTarget;
+}
+
+function requestResolveReturnTargetFromInput(RequestInput $input, string $fallback = ''): string
+{
+ $normalizedFallback = requestNormalizeSafeInternalTarget($fallback);
+
+ $candidate = $input->body('return', '');
+ if (!is_string($candidate)) {
+ $candidate = '';
+ }
+ $candidate = trim($candidate);
+
+ if ($candidate === '') {
+ $candidate = $input->query('return', '');
+ if (!is_string($candidate)) {
+ $candidate = '';
+ }
+ $candidate = trim($candidate);
+ }
+
+ if ($candidate !== '') {
+ $resolved = requestNormalizeSafeInternalTarget($candidate);
+ if ($resolved !== '') {
+ if ($normalizedFallback === '') {
+ return $resolved;
+ }
+ $resolvedPath = (string) parse_url($resolved, PHP_URL_PATH);
+ if ($resolvedPath !== '' && !requestLooksLikeDetailPath($resolvedPath)) {
+ return $resolved;
+ }
+ }
+ }
+
+ return $normalizedFallback;
+}
+
+function requestResolveReturnTarget(string $fallback = ''): string
+{
+ return requestResolveReturnTargetFromInput(requestInput(), $fallback);
+}
+
+function requestPathWithReturnTarget(string $path, string $returnTarget = ''): string
+{
+ $resolvedPath = requestNormalizeSafeInternalTarget($path);
+ if ($resolvedPath === '') {
+ return '';
+ }
+
+ $returnTarget = trim($returnTarget);
+ if ($returnTarget === '') {
+ return $resolvedPath;
+ }
+
+ $normalizedReturnTarget = requestNormalizeSafeInternalTarget($returnTarget);
+ if ($normalizedReturnTarget === '') {
+ return $resolvedPath;
+ }
+
+ $parts = parse_url($resolvedPath);
+ if (!is_array($parts)) {
+ return '';
+ }
+ $pathValue = Request::stripLocale(Request::stripBasePath((string) ($parts['path'] ?? '')));
+ if ($pathValue === '') {
+ return '';
+ }
+ $queryParams = [];
+ parse_str((string) ($parts['query'] ?? ''), $queryParams);
+ if (!is_array($queryParams)) {
+ $queryParams = [];
+ }
+ unset($queryParams['return']);
+ $queryParams['return'] = $normalizedReturnTarget;
+ $query = http_build_query($queryParams);
+
+ return $pathValue . ($query !== '' ? '?' . $query : '');
+}
diff --git a/pages/admin/departments/create().php b/pages/admin/departments/create().php
index 337ee8e..8354722 100644
--- a/pages/admin/departments/create().php
+++ b/pages/admin/departments/create().php
@@ -11,6 +11,9 @@ use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
$request = requestInput();
+$returnTarget = requestResolveReturnTarget();
+$closeTarget = requestResolveReturnTarget('admin/departments');
+$createTarget = requestPathWithReturnTarget('admin/departments/create', $returnTarget);
$tenantService = app(\MintyPHP\Service\Tenant\TenantService::class);
$directoryScopeGateway = app(\MintyPHP\Service\Directory\DirectoryScopeGateway::class);
$departmentService = app(\MintyPHP\Service\Org\DepartmentService::class);
@@ -48,8 +51,8 @@ if ($allowedTenantIds) {
}
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
- Flash::error(t('Form expired, please try again'), 'admin/departments/create', 'csrf_expired');
- Router::redirect('admin/departments/create');
+ Flash::error(t('Form expired, please try again'), $createTarget, 'csrf_expired');
+ Router::redirect($createTarget);
return;
}
@@ -70,14 +73,14 @@ if ($request->hasBody('description')) {
$action = (string) $request->body('action', 'create');
if ($action === 'create_close') {
if ($warnings) {
- Flash::info(implode(' ', $warnings), 'admin/departments', 'department_warning');
+ Flash::info(implode(' ', $warnings), $closeTarget, 'department_warning');
}
- Flash::success('Department created', 'admin/departments', 'department_created');
- Router::redirect('admin/departments');
+ Flash::success('Department created', $closeTarget, 'department_created');
+ Router::redirect($closeTarget);
} else {
$uuid = (string) ($result['uuid'] ?? '');
if ($uuid !== '') {
- $target = "admin/departments/edit/{$uuid}";
+ $target = requestPathWithReturnTarget("admin/departments/edit/{$uuid}", $returnTarget);
if ($warnings) {
Flash::info(implode(' ', $warnings), $target, 'department_warning');
}
@@ -85,10 +88,10 @@ if ($request->hasBody('description')) {
Router::redirect($target);
} else {
if ($warnings) {
- Flash::info(implode(' ', $warnings), 'admin/departments', 'department_warning');
+ Flash::info(implode(' ', $warnings), $closeTarget, 'department_warning');
}
- Flash::success('Department created', 'admin/departments', 'department_created');
- Router::redirect('admin/departments');
+ Flash::success('Department created', $closeTarget, 'department_created');
+ Router::redirect($closeTarget);
}
}
}
diff --git a/pages/admin/departments/edit($id).php b/pages/admin/departments/edit($id).php
index 2b12c57..3ab7d42 100644
--- a/pages/admin/departments/edit($id).php
+++ b/pages/admin/departments/edit($id).php
@@ -11,6 +11,8 @@ use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
$request = requestInput();
+$returnTarget = requestResolveReturnTarget();
+$closeTarget = requestResolveReturnTarget('admin/departments');
$authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class);
$currentUserId = (int) ($session['user']['id'] ?? 0);
@@ -23,10 +25,11 @@ $tenantService = app(\MintyPHP\Service\Tenant\TenantService::class);
$directoryScopeGateway = app(\MintyPHP\Service\Directory\DirectoryScopeGateway::class);
$uuid = trim((string) ($id ?? ''));
+$editTarget = requestPathWithReturnTarget("admin/departments/edit/{$uuid}", $returnTarget);
$department = $uuid !== '' ? $departmentService->findByUuid($uuid) : null;
if (!$department) {
- Flash::error('Department not found', 'admin/departments', 'department_not_found');
- Router::redirect('admin/departments');
+ Flash::error('Department not found', $closeTarget, 'department_not_found');
+ Router::redirect($closeTarget);
}
$departmentId = (int) ($department['id'] ?? 0);
@@ -95,8 +98,8 @@ if ($allowedTenantIds) {
}
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
- Flash::error(t('Form expired, please try again'), "admin/departments/edit/{$uuid}", 'csrf_expired');
- Router::redirect("admin/departments/edit/{$uuid}");
+ Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired');
+ Router::redirect($editTarget);
return;
}
@@ -142,20 +145,20 @@ if ($request->hasBody('description')) {
$cleaned = $departmentService->cleanupUserAssignments($departmentId);
$action = (string) $request->body('action', 'save');
if ($cleaned > 0) {
- Flash::info(t('Department assignments cleaned: %d', $cleaned), "admin/departments/edit/{$uuid}", 'department_assignments_cleaned');
+ Flash::info(t('Department assignments cleaned: %d', $cleaned), $editTarget, 'department_assignments_cleaned');
}
if ($action === 'save_close') {
if ($warnings) {
- Flash::info(implode(' ', $warnings), 'admin/departments', 'department_warning');
+ Flash::info(implode(' ', $warnings), $closeTarget, 'department_warning');
}
- Flash::success('Department updated', 'admin/departments', 'department_updated');
- Router::redirect('admin/departments');
+ Flash::success('Department updated', $closeTarget, 'department_updated');
+ Router::redirect($closeTarget);
} else {
if ($warnings) {
- Flash::info(implode(' ', $warnings), "admin/departments/edit/{$uuid}", 'department_warning');
+ Flash::info(implode(' ', $warnings), $editTarget, 'department_warning');
}
- Flash::success('Department updated', "admin/departments/edit/{$uuid}", 'department_updated');
- Router::redirect("admin/departments/edit/{$uuid}");
+ Flash::success('Department updated', $editTarget, 'department_updated');
+ Router::redirect($editTarget);
}
}
}
diff --git a/pages/admin/departments/index(default).phtml b/pages/admin/departments/index(default).phtml
index dbe413a..1459ca8 100644
--- a/pages/admin/departments/index(default).phtml
+++ b/pages/admin/departments/index(default).phtml
@@ -1,5 +1,6 @@
-
+
diff --git a/pages/admin/permissions/create().php b/pages/admin/permissions/create().php
index 9aa5b54..b01184a 100644
--- a/pages/admin/permissions/create().php
+++ b/pages/admin/permissions/create().php
@@ -11,6 +11,9 @@ use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
$request = requestInput();
+$returnTarget = requestResolveReturnTarget();
+$closeTarget = requestResolveReturnTarget('admin/permissions');
+$createTarget = requestPathWithReturnTarget('admin/permissions/create', $returnTarget);
$currentUserId = (int) ($session['user']['id'] ?? 0);
$authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class);
@@ -31,8 +34,8 @@ $form = [
];
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
- Flash::error(t('Form expired, please try again'), 'admin/permissions/create', 'csrf_expired');
- Router::redirect('admin/permissions/create');
+ Flash::error(t('Form expired, please try again'), $createTarget, 'csrf_expired');
+ Router::redirect($createTarget);
return;
}
@@ -44,17 +47,17 @@ if ($request->hasBody('key')) {
if (($result['ok'] ?? false) && !$errorBag->hasAny()) {
$action = (string) $request->body('action', 'create');
if ($action === 'create_close') {
- Flash::success('Permission created', 'admin/permissions', 'permission_created');
- Router::redirect('admin/permissions');
+ Flash::success('Permission created', $closeTarget, 'permission_created');
+ Router::redirect($closeTarget);
} else {
$id = (int) ($result['id'] ?? 0);
if ($id > 0) {
- $target = "admin/permissions/edit/{$id}";
+ $target = requestPathWithReturnTarget("admin/permissions/edit/{$id}", $returnTarget);
Flash::success('Permission created', $target, 'permission_created');
Router::redirect($target);
} else {
- Flash::success('Permission created', 'admin/permissions', 'permission_created');
- Router::redirect('admin/permissions');
+ Flash::success('Permission created', $closeTarget, 'permission_created');
+ Router::redirect($closeTarget);
}
}
}
diff --git a/pages/admin/permissions/edit($id).php b/pages/admin/permissions/edit($id).php
index 1717e01..11bf27a 100644
--- a/pages/admin/permissions/edit($id).php
+++ b/pages/admin/permissions/edit($id).php
@@ -12,15 +12,18 @@ use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
$request = requestInput();
+$returnTarget = requestResolveReturnTarget();
+$closeTarget = requestResolveReturnTarget('admin/permissions');
$currentUserId = (int) ($session['user']['id'] ?? 0);
$authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class);
$id = (int) ($id ?? 0);
+$editTarget = requestPathWithReturnTarget("admin/permissions/edit/{$id}", $returnTarget);
$permission = $id > 0 ? app(\MintyPHP\Service\Access\PermissionGateway::class)->find($id) : null;
if (!$permission) {
- Flash::error('Permission not found', 'admin/permissions', 'permission_not_found');
- Router::redirect('admin/permissions');
+ Flash::error('Permission not found', $closeTarget, 'permission_not_found');
+ Router::redirect($closeTarget);
}
$isSystemPermission = (int) ($permission['is_system'] ?? 0) === 1;
$contextDecision = $authorizationService->authorize(PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_EDIT_CONTEXT, [
@@ -46,8 +49,8 @@ $selectedRoleIds = $rolePermissionRepository->listRoleIdsByPermissionId($id);
$previousRoleIds = $selectedRoleIds;
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
- Flash::error(t('Form expired, please try again'), "admin/permissions/edit/{$id}", 'csrf_expired');
- Router::redirect("admin/permissions/edit/{$id}");
+ Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired');
+ Router::redirect($editTarget);
return;
}
@@ -97,11 +100,11 @@ if ($request->hasBody('key')) {
if ($syncSucceeded) {
$action = (string) $request->body('action', 'save');
if ($action === 'save_close') {
- Flash::success('Permission updated', 'admin/permissions', 'permission_updated');
- Router::redirect('admin/permissions');
+ Flash::success('Permission updated', $closeTarget, 'permission_updated');
+ Router::redirect($closeTarget);
} else {
- Flash::success('Permission updated', "admin/permissions/edit/{$id}", 'permission_updated');
- Router::redirect("admin/permissions/edit/{$id}");
+ Flash::success('Permission updated', $editTarget, 'permission_updated');
+ Router::redirect($editTarget);
}
}
}
diff --git a/pages/admin/permissions/index(default).phtml b/pages/admin/permissions/index(default).phtml
index 29e1c50..34f6690 100644
--- a/pages/admin/permissions/index(default).phtml
+++ b/pages/admin/permissions/index(default).phtml
@@ -1,5 +1,6 @@
-
+
diff --git a/pages/admin/roles/create().php b/pages/admin/roles/create().php
index c2b6db3..6b40ebe 100644
--- a/pages/admin/roles/create().php
+++ b/pages/admin/roles/create().php
@@ -11,6 +11,9 @@ use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
$request = requestInput();
+$returnTarget = requestResolveReturnTarget();
+$closeTarget = requestResolveReturnTarget('admin/roles');
+$createTarget = requestPathWithReturnTarget('admin/roles/create', $returnTarget);
$currentUserId = (int) ($session['user']['id'] ?? 0);
$authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class);
@@ -34,8 +37,8 @@ $permissions = $permissionRepository->listActive();
$selectedPermissionIds = [];
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
- Flash::error(t('Form expired, please try again'), 'admin/roles/create', 'csrf_expired');
- Router::redirect('admin/roles/create');
+ Flash::error(t('Form expired, please try again'), $createTarget, 'csrf_expired');
+ Router::redirect($createTarget);
return;
}
@@ -72,17 +75,17 @@ if ($request->hasBody('description')) {
$action = (string) $request->body('action', 'create');
if ($action === 'create_close') {
- Flash::success('Role created', 'admin/roles', 'role_created');
- Router::redirect('admin/roles');
+ Flash::success('Role created', $closeTarget, 'role_created');
+ Router::redirect($closeTarget);
} else {
$uuid = (string) ($result['uuid'] ?? '');
if ($uuid !== '') {
- $target = "admin/roles/edit/{$uuid}";
+ $target = requestPathWithReturnTarget("admin/roles/edit/{$uuid}", $returnTarget);
Flash::success('Role created', $target, 'role_created');
Router::redirect($target);
} else {
- Flash::success('Role created', 'admin/roles', 'role_created');
- Router::redirect('admin/roles');
+ Flash::success('Role created', $closeTarget, 'role_created');
+ Router::redirect($closeTarget);
}
}
}
diff --git a/pages/admin/roles/edit($id).php b/pages/admin/roles/edit($id).php
index 633cbb0..c5b662a 100644
--- a/pages/admin/roles/edit($id).php
+++ b/pages/admin/roles/edit($id).php
@@ -11,6 +11,8 @@ use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
$request = requestInput();
+$returnTarget = requestResolveReturnTarget();
+$closeTarget = requestResolveReturnTarget('admin/roles');
$currentUserId = (int) ($session['user']['id'] ?? 0);
$authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class);
@@ -21,10 +23,11 @@ $userWriteRepository = app(\MintyPHP\Repository\User\UserWriteRepository::class)
$userAccountService = app(\MintyPHP\Service\User\UserAccountService::class);
$uuid = trim((string) ($id ?? ''));
+$editTarget = requestPathWithReturnTarget("admin/roles/edit/{$uuid}", $returnTarget);
$role = $uuid !== '' ? app(\MintyPHP\Service\Access\RoleService::class)->findByUuid($uuid) : null;
if (!$role) {
- Flash::error('Role not found', 'admin/roles', 'role_not_found');
- Router::redirect('admin/roles');
+ Flash::error('Role not found', $closeTarget, 'role_not_found');
+ Router::redirect($closeTarget);
}
$roleId = (int) ($role['id'] ?? 0);
@@ -65,8 +68,8 @@ $permissions = $permissionRepository->listActive();
$selectedPermissionIds = $rolePermissionRepository->listPermissionIdsByRoleId($roleId);
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
- Flash::error(t('Form expired, please try again'), "admin/roles/edit/{$uuid}", 'csrf_expired');
- Router::redirect("admin/roles/edit/{$uuid}");
+ Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired');
+ Router::redirect($editTarget);
return;
}
@@ -114,11 +117,11 @@ if ($request->hasBody('description')) {
if ($syncSucceeded) {
$action = (string) $request->body('action', 'save');
if ($action === 'save_close') {
- Flash::success('Role updated', 'admin/roles', 'role_updated');
- Router::redirect('admin/roles');
+ Flash::success('Role updated', $closeTarget, 'role_updated');
+ Router::redirect($closeTarget);
} else {
- Flash::success('Role updated', "admin/roles/edit/{$uuid}", 'role_updated');
- Router::redirect("admin/roles/edit/{$uuid}");
+ Flash::success('Role updated', $editTarget, 'role_updated');
+ Router::redirect($editTarget);
}
}
}
diff --git a/pages/admin/roles/index(default).phtml b/pages/admin/roles/index(default).phtml
index b546b28..ff0ab41 100644
--- a/pages/admin/roles/index(default).phtml
+++ b/pages/admin/roles/index(default).phtml
@@ -1,5 +1,6 @@
-
+
diff --git a/pages/admin/tenants/create().php b/pages/admin/tenants/create().php
index 9e18b71..5c1bc84 100644
--- a/pages/admin/tenants/create().php
+++ b/pages/admin/tenants/create().php
@@ -11,6 +11,9 @@ use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
$request = requestInput();
+$returnTarget = requestResolveReturnTarget();
+$closeTarget = requestResolveReturnTarget('admin/tenants');
+$createTarget = requestPathWithReturnTarget('admin/tenants/create', $returnTarget);
$currentUserId = (int) ($session['user']['id'] ?? 0);
$authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class);
@@ -54,8 +57,8 @@ $form = [
$ssoUiState = $canManageSso ? $tenantSsoService->buildMicrosoftUiState(0, $form) : [];
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
- Flash::error(t('Form expired, please try again'), 'admin/tenants/create', 'csrf_expired');
- Router::redirect('admin/tenants/create');
+ Flash::error(t('Form expired, please try again'), $createTarget, 'csrf_expired');
+ Router::redirect($createTarget);
return;
}
@@ -105,7 +108,7 @@ if ($request->hasBody('description')) {
if (!($ssoSaveResult['ok'] ?? false)) {
Flash::error(
(string) (($ssoSaveResult['errors'][0] ?? t('Tenant SSO settings could not be saved'))),
- 'admin/tenants',
+ $closeTarget,
'tenant_sso_create_failed'
);
}
@@ -113,17 +116,17 @@ if ($request->hasBody('description')) {
}
$action = (string) ($post['action'] ?? 'create');
if ($action === 'create_close') {
- Flash::success('Tenant created', 'admin/tenants', 'tenant_created');
- Router::redirect('admin/tenants');
+ Flash::success('Tenant created', $closeTarget, 'tenant_created');
+ Router::redirect($closeTarget);
} else {
$uuid = (string) ($result['uuid'] ?? '');
if ($uuid !== '') {
- $target = "admin/tenants/edit/{$uuid}";
+ $target = requestPathWithReturnTarget("admin/tenants/edit/{$uuid}", $returnTarget);
Flash::success('Tenant created', $target, 'tenant_created');
Router::redirect($target);
} else {
- Flash::success('Tenant created', 'admin/tenants', 'tenant_created');
- Router::redirect('admin/tenants');
+ Flash::success('Tenant created', $closeTarget, 'tenant_created');
+ Router::redirect($closeTarget);
}
}
}
diff --git a/pages/admin/tenants/edit($id).php b/pages/admin/tenants/edit($id).php
index 4c57035..9b643ef 100644
--- a/pages/admin/tenants/edit($id).php
+++ b/pages/admin/tenants/edit($id).php
@@ -12,6 +12,8 @@ use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
$request = requestInput();
+$returnTarget = requestResolveReturnTarget();
+$closeTarget = requestResolveReturnTarget('admin/tenants');
$currentUserId = (int) ($session['user']['id'] ?? 0);
if ($currentUserId > 0) {
@@ -20,10 +22,11 @@ if ($currentUserId > 0) {
$userAccountService = app(\MintyPHP\Service\User\UserAccountService::class);
$uuid = trim((string) ($id ?? ''));
+$editTarget = requestPathWithReturnTarget("admin/tenants/edit/{$uuid}", $returnTarget);
$tenant = $uuid !== '' ? app(\MintyPHP\Service\Tenant\TenantService::class)->findByUuid($uuid) : null;
if (!$tenant) {
- Flash::error('Tenant not found', 'admin/tenants', 'tenant_not_found');
- Router::redirect('admin/tenants');
+ Flash::error('Tenant not found', $closeTarget, 'tenant_not_found');
+ Router::redirect($closeTarget);
}
$tenantId = (int) ($tenant['id'] ?? 0);
@@ -97,8 +100,8 @@ $errors = [];
$form = array_merge($tenant, $ssoConfig);
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
- Flash::error(t('Form expired, please try again'), "admin/tenants/edit/{$uuid}", 'csrf_expired');
- Router::redirect("admin/tenants/edit/{$uuid}");
+ Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired');
+ Router::redirect($editTarget);
return;
}
@@ -149,11 +152,11 @@ if ($request->hasBody('description')) {
}
$action = (string) ($post['action'] ?? 'save');
if ($action === 'save_close') {
- Flash::success('Tenant updated', 'admin/tenants', 'tenant_updated');
- Router::redirect('admin/tenants');
+ Flash::success('Tenant updated', $closeTarget, 'tenant_updated');
+ Router::redirect($closeTarget);
} else {
- Flash::success('Tenant updated', "admin/tenants/edit/{$uuid}", 'tenant_updated');
- Router::redirect("admin/tenants/edit/{$uuid}");
+ Flash::success('Tenant updated', $editTarget, 'tenant_updated');
+ Router::redirect($editTarget);
}
}
diff --git a/pages/admin/tenants/index(default).phtml b/pages/admin/tenants/index(default).phtml
index c85385b..a57bced 100644
--- a/pages/admin/tenants/index(default).phtml
+++ b/pages/admin/tenants/index(default).phtml
@@ -1,5 +1,6 @@
-
+
diff --git a/pages/admin/users/create().php b/pages/admin/users/create().php
index cb00fec..92d7e56 100644
--- a/pages/admin/users/create().php
+++ b/pages/admin/users/create().php
@@ -16,6 +16,9 @@ use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
$request = requestInput();
+$returnTarget = requestResolveReturnTarget();
+$closeTarget = requestResolveReturnTarget('admin/users');
+$createTarget = requestPathWithReturnTarget('admin/users/create', $returnTarget);
$authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class);
$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_CREATE, [
'actor_user_id' => (int) ($session['user']['id'] ?? 0),
@@ -93,8 +96,8 @@ $form = [
];
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
- Flash::error(t('Form expired, please try again'), 'admin/users/create', 'csrf_expired');
- Router::redirect('admin/users/create');
+ Flash::error(t('Form expired, please try again'), $createTarget, 'csrf_expired');
+ Router::redirect($createTarget);
return;
}
@@ -172,7 +175,7 @@ if ($request->hasBody('email')) {
if ($syncErrors) {
Flash::error(
implode(' | ', $syncErrors),
- "admin/users/edit/{$createdUuid}",
+ requestPathWithReturnTarget("admin/users/edit/{$createdUuid}", $returnTarget),
'user_custom_field_sync_failed'
);
}
@@ -181,17 +184,17 @@ if ($request->hasBody('email')) {
}
$action = (string) ($post['action'] ?? 'create');
if ($action === 'create_close') {
- Flash::success('User created', 'admin/users', 'user_created');
- Router::redirect('admin/users');
+ Flash::success('User created', $closeTarget, 'user_created');
+ Router::redirect($closeTarget);
} else {
$uuid = $createdUuid;
if ($uuid !== '') {
- $target = "admin/users/edit/{$uuid}";
+ $target = requestPathWithReturnTarget("admin/users/edit/{$uuid}", $returnTarget);
Flash::success('User created', $target, 'user_created');
Router::redirect($target);
} else {
- Flash::success('User created', 'admin/users', 'user_created');
- Router::redirect('admin/users');
+ Flash::success('User created', $closeTarget, 'user_created');
+ Router::redirect($closeTarget);
}
}
}
diff --git a/pages/admin/users/edit($id).php b/pages/admin/users/edit($id).php
index fc29578..70921b1 100644
--- a/pages/admin/users/edit($id).php
+++ b/pages/admin/users/edit($id).php
@@ -17,6 +17,8 @@ $session = $sessionStore->all();
Guard::requireLogin();
$request = requestInput();
+$returnTarget = requestResolveReturnTarget();
+$closeTarget = requestResolveReturnTarget('admin/users');
$userAccountService = app(\MintyPHP\Service\User\UserAccountService::class);
$userAssignmentService = app(\MintyPHP\Service\User\UserAssignmentService::class);
$userTenantRepository = app(\MintyPHP\Repository\Tenant\UserTenantRepository::class);
@@ -38,10 +40,11 @@ $passwordHints = $userPasswordPolicyService->hints();
$currentUserId = (int) ($session['user']['id'] ?? 0);
$uuid = trim((string) ($id ?? ''));
+$editTarget = requestPathWithReturnTarget("admin/users/edit/{$uuid}", $returnTarget);
$user = $uuid !== '' ? $userAccountService->findByUuid($uuid) : null;
if (!$user) {
- Flash::error('User not found', 'admin/users', 'user_not_found');
- Router::redirect('admin/users');
+ Flash::error('User not found', $closeTarget, 'user_not_found');
+ Router::redirect($closeTarget);
}
$userId = (int) ($user['id'] ?? 0);
@@ -175,8 +178,8 @@ $canManageApiTokens = $canManageApiTokens && $canViewSecurityArtifacts;
$showApiTokens = $canViewSecurityArtifacts && (!empty($apiTokens) || $canManageApiTokens);
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
- Flash::error(t('Form expired, please try again'), "admin/users/edit/{$uuid}", 'csrf_expired');
- Router::redirect("admin/users/edit/{$uuid}");
+ Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired');
+ Router::redirect($editTarget);
return;
}
@@ -297,11 +300,11 @@ if ($request->hasBody('email')) {
if (!$errorBag->hasAny()) {
$action = (string) ($post['action'] ?? 'save');
if ($action === 'save_close') {
- Flash::success('User updated', 'admin/users', 'user_updated');
- Router::redirect('admin/users');
+ Flash::success('User updated', $closeTarget, 'user_updated');
+ Router::redirect($closeTarget);
} else {
- Flash::success('User updated', "admin/users/edit/{$uuid}", 'user_updated');
- Router::redirect("admin/users/edit/{$uuid}");
+ Flash::success('User updated', $editTarget, 'user_updated');
+ Router::redirect($editTarget);
}
}
}
diff --git a/pages/admin/users/index(default).phtml b/pages/admin/users/index(default).phtml
index 7d75d8e..7fc6235 100644
--- a/pages/admin/users/index(default).phtml
+++ b/pages/admin/users/index(default).phtml
@@ -1,6 +1,7 @@
-
+
diff --git a/templates/partials/app-details-titlebar.phtml b/templates/partials/app-details-titlebar.phtml
index 6d10b48..43ac363 100644
--- a/templates/partials/app-details-titlebar.phtml
+++ b/templates/partials/app-details-titlebar.phtml
@@ -11,6 +11,10 @@ use MintyPHP\Session;
$titlebar = is_array($titlebar ?? null) ? $titlebar : [];
$title = (string) ($titlebar['title'] ?? '');
$backHref = (string) ($titlebar['backHref'] ?? '');
+$backReturn = requestResolveReturnTarget();
+if ($backReturn !== '') {
+ $backHref = $backReturn;
+}
$backTitle = (string) ($titlebar['backTitle'] ?? t('Cancel'));
$unsavedMessage = (string) ($titlebar['unsavedMessage'] ?? t('You have unsaved changes. Leave without saving?'));
$unsavedIndicatorLabel = (string) ($titlebar['unsavedIndicatorLabel'] ?? t('Unsaved changes'));
diff --git a/tests/Support/RequestReturnTargetHelperTest.php b/tests/Support/RequestReturnTargetHelperTest.php
new file mode 100644
index 0000000..f1f6a7c
--- /dev/null
+++ b/tests/Support/RequestReturnTargetHelperTest.php
@@ -0,0 +1,147 @@
+serverBackup = $_SERVER;
+ $this->defaultLocaleBackup = (string) I18n::$defaultLocale;
+ $this->localeBackup = (string) I18n::$locale;
+ I18n::$defaultLocale = 'de';
+ I18n::$locale = 'de';
+ Router::$baseUrl = '/';
+ }
+
+ protected function tearDown(): void
+ {
+ $_SERVER = $this->serverBackup;
+ I18n::$defaultLocale = $this->defaultLocaleBackup;
+ I18n::$locale = $this->localeBackup;
+ parent::tearDown();
+ }
+
+ public function testResolveReturnTargetPrefersBodyValue(): void
+ {
+ $input = new RequestInput('POST', ['return' => 'admin/roles'], ['return' => 'admin/users?active=1'], []);
+
+ $this->assertSame('admin/users?active=1', \requestResolveReturnTargetFromInput($input));
+ }
+
+ public function testResolveReturnTargetFallsBackToQueryValue(): void
+ {
+ $input = new RequestInput('GET', ['return' => 'admin/departments?tenant=abc'], [], []);
+
+ $this->assertSame('admin/departments?tenant=abc', \requestResolveReturnTargetFromInput($input));
+ }
+
+ public function testResolveReturnTargetFallsBackToStaticPathForInvalidReturn(): void
+ {
+ $input = new RequestInput('POST', ['return' => 'https://evil.example/path'], [], []);
+
+ $this->assertSame('admin/users', \requestResolveReturnTargetFromInput($input, 'admin/users'));
+ }
+
+ public function testResolveReturnTargetIgnoresRefererWhenReturnIsInvalid(): void
+ {
+ $_SERVER['HTTP_REFERER'] = 'https://app.example/admin/roles';
+ $_SERVER['HTTP_HOST'] = 'app.example';
+
+ $input = new RequestInput('POST', ['return' => 'https://evil.example/path'], [], []);
+
+ $this->assertSame('admin/users', \requestResolveReturnTargetFromInput($input, 'admin/users'));
+ }
+
+ public function testResolveReturnTargetFallsBackToStaticPathForEmptyReturn(): void
+ {
+ $input = new RequestInput('GET', [], [], []);
+
+ $this->assertSame('admin/users', \requestResolveReturnTargetFromInput($input, 'admin/users'));
+ }
+
+ public function testResolveReturnTargetStripsLocalePrefixAndNestedReturn(): void
+ {
+ $input = new RequestInput('GET', ['return' => '/de/admin/users?active=1&return=admin/old'], [], []);
+
+ $this->assertSame('admin/users?active=1', \requestResolveReturnTargetFromInput($input));
+ }
+
+ public function testPathWithReturnTargetAppendsReturnQuery(): void
+ {
+ $this->assertSame(
+ 'admin/users/edit/abc?return=admin%2Fusers%3Factive%3D1',
+ \requestPathWithReturnTarget('admin/users/edit/abc', 'admin/users?active=1')
+ );
+ }
+
+ public function testPathWithReturnTargetPreservesExistingQueryAndRemovesNestedReturn(): void
+ {
+ $this->assertSame(
+ 'admin/users/edit/abc?tab=profile&return=admin%2Fusers%3Factive%3D1',
+ \requestPathWithReturnTarget(
+ 'admin/users/edit/abc?tab=profile',
+ 'admin/users?active=1&return=admin/old'
+ )
+ );
+ }
+
+ public function testPathWithReturnTargetIgnoresExternalReturnTarget(): void
+ {
+ $_SERVER['HTTP_REFERER'] = 'https://app.example/admin/roles';
+ $_SERVER['HTTP_HOST'] = 'app.example';
+
+ $this->assertSame(
+ 'admin/users/edit/abc',
+ \requestPathWithReturnTarget('admin/users/edit/abc', 'https://evil.example')
+ );
+ }
+
+ public function testPathWithReturnTargetNormalizesLocalePrefixedReturnTarget(): void
+ {
+ $this->assertSame(
+ 'admin/users/edit/abc?return=admin%2Fusers%3Factive%3D1',
+ \requestPathWithReturnTarget('admin/users/edit/abc', '/de/admin/users?active=1')
+ );
+ }
+
+ public function testResolveReturnTargetUsesDeepestNestedReturnTarget(): void
+ {
+ $input = new RequestInput(
+ 'GET',
+ ['return' => 'admin/users/edit/abc?return=admin/users?active=1'],
+ [],
+ []
+ );
+
+ $this->assertSame('admin/users?active=1', \requestResolveReturnTargetFromInput($input, 'admin/users'));
+ }
+
+ public function testResolveReturnTargetFallsBackToListWhenReturnPointsToDetailPath(): void
+ {
+ $input = new RequestInput('GET', ['return' => 'admin/users/edit/abc'], [], []);
+
+ $this->assertSame('admin/users', \requestResolveReturnTargetFromInput($input, 'admin/users'));
+ }
+
+ public function testPathWithReturnTargetFlattensNestedEditReturnTargetToList(): void
+ {
+ $this->assertSame(
+ 'admin/users/edit/abc?return=admin%2Fusers%3Factive%3D1',
+ \requestPathWithReturnTarget(
+ 'admin/users/edit/abc',
+ 'admin/users/edit/abc?return=admin/users?active=1'
+ )
+ );
+ }
+}
diff --git a/web/js/core/app-details-action-policy.js b/web/js/core/app-details-action-policy.js
index 4553bd3..667e1b7 100644
--- a/web/js/core/app-details-action-policy.js
+++ b/web/js/core/app-details-action-policy.js
@@ -152,6 +152,10 @@ const readSubmitLockMode = (submitter, form) => normalizeSubmitLock(
const disableSubmitControlsForForm = (form, submitter, options = {}) => {
const controls = findSubmitControlsForForm(form);
controls.forEach((control) => {
+ if (submitter instanceof HTMLElement && control === submitter) {
+ // Keep the clicked submitter enabled so its name/value stays part of form submission.
+ return;
+ }
control.disabled = true;
});
if (submitter instanceof HTMLElement) {
diff --git a/web/js/core/app-grid-factory.js b/web/js/core/app-grid-factory.js
index 3ee9f8c..3b2750d 100644
--- a/web/js/core/app-grid-factory.js
+++ b/web/js/core/app-grid-factory.js
@@ -1,6 +1,6 @@
import { warnOnce } from './app-dom.js';
import { confirmDialog } from './app-confirm-dialog.js';
-import { gridFiltersFromSchema } from '../pages/app-list-utils.js';
+import { gridFiltersFromSchema, withCurrentListReturn } from '../pages/app-list-utils.js';
import { initListFilterExperience } from '../pages/app-list-filter-experience.js';
import { buildChipsFromMeta, removeChipFromMetaState, clearMetaState } from '../pages/app-list-filter-state.js';
@@ -294,7 +294,11 @@ export function createServerGrid(options) {
return '';
}
const url = rowDblClick.getUrl(rowData, rowIndex);
- return String(url || '').trim();
+ const normalized = String(url || '').trim();
+ if (normalized === '') {
+ return '';
+ }
+ return withCurrentListReturn(normalized);
};
const getInput = (input) => {
diff --git a/web/js/pages/admin-api-audit-index.js b/web/js/pages/admin-api-audit-index.js
index 1d5563c..0ef1f33 100644
--- a/web/js/pages/admin-api-audit-index.js
+++ b/web/js/pages/admin-api-audit-index.js
@@ -1,7 +1,7 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
-import { getAppBase } from './app-list-utils.js';
+import { getAppBase, withCurrentListReturn } from './app-list-utils.js';
const config = readPageConfig('admin-api-audit-index');
if (config) {
@@ -60,7 +60,7 @@ if (config) {
if (!cell.uuid) {
return gridjs.html(label);
}
- const url = new URL(`admin/users/edit/${cell.uuid}`, appBase).toString();
+ const url = withCurrentListReturn(new URL(`admin/users/edit/${cell.uuid}`, appBase).toString());
return gridjs.html(`${label}`);
},
},
@@ -75,7 +75,7 @@ if (config) {
if (!cell.uuid) {
return gridjs.html(label);
}
- const url = new URL(`admin/tenants/edit/${cell.uuid}`, appBase).toString();
+ const url = withCurrentListReturn(new URL(`admin/tenants/edit/${cell.uuid}`, appBase).toString());
return gridjs.html(`${label}`);
},
},
diff --git a/web/js/pages/admin-import-audit-index.js b/web/js/pages/admin-import-audit-index.js
index 2bbf5cd..74492a6 100644
--- a/web/js/pages/admin-import-audit-index.js
+++ b/web/js/pages/admin-import-audit-index.js
@@ -1,7 +1,7 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
-import { getAppBase } from './app-list-utils.js';
+import { getAppBase, withCurrentListReturn } from './app-list-utils.js';
const config = readPageConfig('admin-import-audit-index');
if (config) {
@@ -56,7 +56,7 @@ if (config) {
if (!cell.uuid) {
return gridjs.html(label);
}
- const url = new URL(`admin/users/edit/${cell.uuid}`, appBase).toString();
+ const url = withCurrentListReturn(new URL(`admin/users/edit/${cell.uuid}`, appBase).toString());
return gridjs.html(`${label}`);
},
},
diff --git a/web/js/pages/admin-system-audit-index.js b/web/js/pages/admin-system-audit-index.js
index 745b2b4..bc88d85 100644
--- a/web/js/pages/admin-system-audit-index.js
+++ b/web/js/pages/admin-system-audit-index.js
@@ -1,7 +1,7 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
-import { getAppBase } from './app-list-utils.js';
+import { getAppBase, withCurrentListReturn } from './app-list-utils.js';
const config = readPageConfig('admin-system-audit-index');
if (config) {
@@ -42,7 +42,7 @@ if (config) {
if (!cell.uuid) {
return gridjs.html(label);
}
- const url = new URL(`admin/users/edit/${cell.uuid}`, appBase).toString();
+ const url = withCurrentListReturn(new URL(`admin/users/edit/${cell.uuid}`, appBase).toString());
return gridjs.html(`${label}`);
},
},
diff --git a/web/js/pages/app-list-utils.js b/web/js/pages/app-list-utils.js
index 11d93b9..f72dcb8 100644
--- a/web/js/pages/app-list-utils.js
+++ b/web/js/pages/app-list-utils.js
@@ -8,6 +8,83 @@ export const getAppBase = () => {
return `${window.location.origin}/`;
};
+const resolveAppBaseUrl = () => {
+ try {
+ return new URL(getAppBase(), `${window.location.origin}/`);
+ } catch {
+ return new URL(`${window.location.origin}/`);
+ }
+};
+
+const normalizeBasePathname = (pathname) => {
+ const trimmed = String(pathname ?? '').trim();
+ if (trimmed === '' || trimmed === '/') {
+ return '/';
+ }
+ return `/${trimmed.replace(/^\/+|\/+$/g, '')}/`;
+};
+
+const toAppRelativePath = (pathname, appBasePathname) => {
+ const normalizedPath = `/${String(pathname ?? '').replace(/^\/+/, '')}`;
+ const normalizedBasePath = normalizeBasePathname(appBasePathname);
+ let relativePath = normalizedPath;
+
+ if (normalizedBasePath !== '/' && relativePath.startsWith(normalizedBasePath)) {
+ relativePath = relativePath.slice(normalizedBasePath.length - 1);
+ } else if (normalizedBasePath !== '/' && relativePath === normalizedBasePath.slice(0, -1)) {
+ relativePath = '/';
+ }
+
+ return relativePath.replace(/^\/+/, '');
+};
+
+const parseAppUrl = (value, baseUrl = resolveAppBaseUrl()) => {
+ const raw = String(value ?? '').trim();
+ if (raw === '') {
+ return null;
+ }
+ try {
+ return new URL(raw, baseUrl);
+ } catch {
+ return null;
+ }
+};
+
+export const normalizeListReturnTarget = (sourceUrl = window.location.href) => {
+ const appBaseUrl = resolveAppBaseUrl();
+ const parsed = parseAppUrl(sourceUrl, appBaseUrl);
+ if (!parsed) {
+ return '';
+ }
+ const relativePath = toAppRelativePath(parsed.pathname, appBaseUrl.pathname);
+ if (relativePath === '') {
+ return '';
+ }
+ const query = new URLSearchParams(parsed.search);
+ query.delete('return');
+ const queryString = query.toString();
+ return `${relativePath}${queryString ? `?${queryString}` : ''}`;
+};
+
+export const withReturnTarget = (targetUrl, returnTarget = '') => {
+ const appBaseUrl = resolveAppBaseUrl();
+ const parsedTarget = parseAppUrl(targetUrl, appBaseUrl);
+ if (!parsedTarget) {
+ return String(targetUrl ?? '');
+ }
+ if (parsedTarget.origin !== window.location.origin) {
+ return String(targetUrl ?? '');
+ }
+ const normalizedReturn = normalizeListReturnTarget(returnTarget);
+ parsedTarget.searchParams.delete('return');
+ if (normalizedReturn !== '') {
+ parsedTarget.searchParams.set('return', normalizedReturn);
+ }
+ return parsedTarget.toString();
+};
+
+export const withCurrentListReturn = (targetUrl) => withReturnTarget(targetUrl, window.location.href);
+
export const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&',
'<': '<',