From 277168f7e8e91f1cfa06895c3cd7d1d7d7eb7e55 Mon Sep 17 00:00:00 2001 From: fs Date: Wed, 11 Mar 2026 23:32:11 +0100 Subject: [PATCH] feat(admin): preserve list return target for back and close actions --- lib/Support/helpers/request.php | 158 ++++++++++++++++++ pages/admin/departments/create().php | 21 ++- pages/admin/departments/edit($id).php | 25 +-- pages/admin/departments/index(default).phtml | 3 +- pages/admin/permissions/create().php | 17 +- pages/admin/permissions/edit($id).php | 19 ++- pages/admin/permissions/index(default).phtml | 3 +- pages/admin/roles/create().php | 17 +- pages/admin/roles/edit($id).php | 19 ++- pages/admin/roles/index(default).phtml | 3 +- pages/admin/tenants/create().php | 19 ++- pages/admin/tenants/edit($id).php | 19 ++- pages/admin/tenants/index(default).phtml | 3 +- pages/admin/users/create().php | 19 ++- pages/admin/users/edit($id).php | 19 ++- pages/admin/users/index(default).phtml | 3 +- templates/partials/app-details-titlebar.phtml | 4 + .../Support/RequestReturnTargetHelperTest.php | 147 ++++++++++++++++ web/js/core/app-details-action-policy.js | 4 + web/js/core/app-grid-factory.js | 8 +- web/js/pages/admin-api-audit-index.js | 6 +- web/js/pages/admin-import-audit-index.js | 4 +- web/js/pages/admin-system-audit-index.js | 4 +- web/js/pages/app-list-utils.js | 77 +++++++++ 24 files changed, 525 insertions(+), 96 deletions(-) create mode 100644 tests/Support/RequestReturnTargetHelperTest.php 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) => ({ '&': '&', '<': '<',