feat(admin): preserve list return target for back and close actions

This commit is contained in:
2026-03-11 23:32:11 +01:00
parent bc9506866f
commit 277168f7e8
24 changed files with 525 additions and 96 deletions

View File

@@ -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 : '');
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -1,5 +1,6 @@
<?php
use MintyPHP\Router;
use MintyPHP\Http\Request;
$canCreateDepartments = (bool) ($canCreateDepartments ?? false);
$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : [];
@@ -24,7 +25,7 @@ $listTitle = t('Departments');
ob_start();
?>
<?php if ($canCreateDepartments): ?>
<a role="button" class="primary" href="admin/departments/create">
<a role="button" class="primary" href="<?php e(requestPathWithReturnTarget('admin/departments/create', Request::pathWithQuery())); ?>">
<i class="bi bi-plus"></i> <?php e(t('Create department')); ?>
</a>
<?php endif; ?>

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -1,5 +1,6 @@
<?php
use MintyPHP\Router;
use MintyPHP\Http\Request;
$canCreatePermissions = (bool) ($canCreatePermissions ?? false);
$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : [];
@@ -22,7 +23,7 @@ $listTitle = t('Permissions');
ob_start();
?>
<?php if ($canCreatePermissions): ?>
<a role="button" class="primary" href="admin/permissions/create">
<a role="button" class="primary" href="<?php e(requestPathWithReturnTarget('admin/permissions/create', Request::pathWithQuery())); ?>">
<i class="bi bi-plus"></i> <?php e(t('Create permission')); ?>
</a>
<?php endif; ?>

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -1,5 +1,6 @@
<?php
use MintyPHP\Router;
use MintyPHP\Http\Request;
$canCreateRoles = (bool) ($canCreateRoles ?? false);
$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : [];
@@ -22,7 +23,7 @@ $listTitle = t('Roles');
ob_start();
?>
<?php if ($canCreateRoles): ?>
<a role="button" class="primary" href="admin/roles/create">
<a role="button" class="primary" href="<?php e(requestPathWithReturnTarget('admin/roles/create', Request::pathWithQuery())); ?>">
<i class="bi bi-plus"></i> <?php e(t('Create role')); ?>
</a>
<?php endif; ?>

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -1,5 +1,6 @@
<?php
use MintyPHP\Router;
use MintyPHP\Http\Request;
$canCreateTenants = (bool) ($canCreateTenants ?? false);
$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : [];
@@ -22,7 +23,7 @@ $listTitle = t('Tenants');
ob_start();
?>
<?php if ($canCreateTenants): ?>
<a role="button" class="primary" href="admin/tenants/create">
<a role="button" class="primary" href="<?php e(requestPathWithReturnTarget('admin/tenants/create', Request::pathWithQuery())); ?>">
<i class="bi bi-plus"></i> <?php e(t('Create tenant')); ?>
</a>
<?php endif; ?>

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -1,6 +1,7 @@
<?php
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Http\Request;
$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : [];
$searchToolbarFilterSchema = is_array($searchToolbarFilterSchema ?? null) ? $searchToolbarFilterSchema : [];
@@ -64,7 +65,7 @@ ob_start();
</details>
<?php if ($canCreateUser): ?>
<a role="button" class="primary" href="admin/users/create">
<a role="button" class="primary" href="<?php e(requestPathWithReturnTarget('admin/users/create', Request::pathWithQuery())); ?>">
<i class="bi bi-plus"></i> <?php e(t('Create user')); ?>
</a>
<?php endif; ?>

View File

@@ -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'));

View File

@@ -0,0 +1,147 @@
<?php
namespace MintyPHP\Tests\Support;
use MintyPHP\Http\Input\RequestInput;
use MintyPHP\I18n;
use MintyPHP\Router;
use PHPUnit\Framework\TestCase;
class RequestReturnTargetHelperTest extends TestCase
{
private array $serverBackup = [];
private string $defaultLocaleBackup = 'en';
private string $localeBackup = '';
protected function setUp(): void
{
parent::setUp();
$this->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'
)
);
}
}

View File

@@ -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) {

View File

@@ -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) => {

View File

@@ -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(`<a href="${url}">${label}</a>`);
},
},
@@ -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(`<a href="${url}">${label}</a>`);
},
},

View File

@@ -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(`<a href="${url}">${label}</a>`);
},
},

View File

@@ -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(`<a href="${url}">${label}</a>`);
},
},

View File

@@ -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) => ({
'&': '&amp;',
'<': '&lt;',