diff --git a/typo3/sysext/backend/Classes/Controller/BackendController.php b/typo3/sysext/backend/Classes/Controller/BackendController.php index d829808b2bc250de17f73609a24170b791245b8f..8d352b8b534b5cec7451324c77032df5edcec67d 100644 --- a/typo3/sysext/backend/Classes/Controller/BackendController.php +++ b/typo3/sysext/backend/Classes/Controller/BackendController.php @@ -39,8 +39,12 @@ use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; use TYPO3\CMS\Core\Http\JsonResponse; use TYPO3\CMS\Core\Information\Typo3Version; use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Messaging\FlashMessageQueue; +use TYPO3\CMS\Core\Messaging\FlashMessageService; use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction; use TYPO3\CMS\Core\Page\PageRenderer; +use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; use TYPO3\CMS\Core\Type\File\ImageInfo; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\MathUtility; @@ -70,6 +74,7 @@ class BackendController protected readonly ExtensionConfiguration $extensionConfiguration, protected readonly BackendViewFactory $viewFactory, protected readonly EventDispatcherInterface $eventDispatcher, + protected readonly FlashMessageService $flashMessageService, ) { $this->modules = $this->moduleProvider->getModulesForModuleMenu($this->getBackendUser()); } @@ -248,6 +253,8 @@ class BackendController protected function getStartupModule(ServerRequestInterface $request): array { $startModule = null; + $startModuleIdentifier = null; + $inaccessibleRedirectModule = null; $moduleParameters = []; try { $redirect = RouteRedirect::createFromRequest($request); @@ -257,29 +264,33 @@ class BackendController if ($this->moduleProvider->accessGranted($redirect->getName(), $this->getBackendUser())) { // Only add start module from request in case user has access. // Access might temporarily be blocked due to being in a workspace. - $startModule = $redirect->getName(); + $startModuleIdentifier = $redirect->getName(); $moduleParameters = $redirect->getParameters(); + } elseif ($this->moduleProvider->isModuleRegistered($redirect->getName())) { + // A redirect is set, however, the user is not allowed to access the module. + // Store the requested module to later inform the user about the forced redirect. + $inaccessibleRedirectModule = $this->moduleProvider->getModule($redirect->getName()); } } } finally { // No valid redirect, check for the start module - if (!$startModule) { + if (!$startModuleIdentifier) { $backendUser = $this->getBackendUser(); // start module on first login, will be removed once used the first time if (isset($backendUser->uc['startModuleOnFirstLogin'])) { - $startModule = $backendUser->uc['startModuleOnFirstLogin']; + $startModuleIdentifier = $backendUser->uc['startModuleOnFirstLogin']; unset($backendUser->uc['startModuleOnFirstLogin']); $backendUser->writeUC(); } elseif (isset($backendUser->uc['startModule']) && $this->moduleProvider->accessGranted($backendUser->uc['startModule'], $backendUser)) { - $startModule = $backendUser->uc['startModule']; + $startModuleIdentifier = $backendUser->uc['startModule']; } elseif ($firstAccessibleModule = $this->moduleProvider->getFirstAccessibleModule($backendUser)) { - $startModule = $firstAccessibleModule->getIdentifier(); + $startModuleIdentifier = $firstAccessibleModule->getIdentifier(); } // check if the start module has additional parameters, so a redirect to a specific // action is possible - if (is_string($startModule) && str_contains($startModule, '->')) { - [$startModule, $startModuleParameters] = explode('->', $startModule, 2); + if (is_string($startModuleIdentifier) && str_contains($startModuleIdentifier, '->')) { + [$startModuleIdentifier, $startModuleParameters] = explode('->', $startModuleIdentifier, 2); // if no GET parameters are set, check if there are parameters given from the UC if (!$moduleParameters && $startModuleParameters) { $moduleParameters = $startModuleParameters; @@ -287,10 +298,11 @@ class BackendController } } } - if ($startModule) { - if ($this->moduleProvider->isModuleRegistered($startModule)) { - // startModule may be an alias, resolve original module name - $startModule = $this->moduleProvider->getModule($startModule, $this->getBackendUser())?->getIdentifier(); + if ($startModuleIdentifier) { + if ($this->moduleProvider->isModuleRegistered($startModuleIdentifier)) { + // startModuleIdentifier may be an alias, resolve original module + $startModule = $this->moduleProvider->getModule($startModuleIdentifier, $this->getBackendUser()); + $startModuleIdentifier = $startModule?->getIdentifier(); } if (is_array($moduleParameters)) { $parameters = $moduleParameters; @@ -299,8 +311,11 @@ class BackendController parse_str($moduleParameters, $parameters); } try { - $deepLink = $this->uriBuilder->buildUriFromRoute($startModule, $parameters); - return [$startModule, (string)$deepLink]; + $deepLink = $this->uriBuilder->buildUriFromRoute($startModuleIdentifier, $parameters); + if ($startModule !== null && $inaccessibleRedirectModule !== null) { + $this->enqueueRedirectMessage($startModule, $inaccessibleRedirectModule); + } + return [$startModuleIdentifier, (string)$deepLink]; } catch (RouteNotFoundException $e) { // It might be, that the user does not have access to the // $startModule, e.g. for modules with workspace restrictions. @@ -337,6 +352,25 @@ class BackendController return $collapseState === true || $collapseState === 'true'; } + protected function enqueueRedirectMessage(ModuleInterface $requestedModule, ModuleInterface $redirectedModule): void + { + $languageService = $this->getLanguageService(); + $this->flashMessageService + ->getMessageQueueByIdentifier(FlashMessageQueue::NOTIFICATION_QUEUE) + ->enqueue( + new FlashMessage( + sprintf( + $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:module.noAccess.message'), + $languageService->sL($redirectedModule->getTitle()), + $languageService->sL($requestedModule->getTitle()) + ), + $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:module.noAccess.title'), + ContextualFeedbackSeverity::INFO, + true + ) + ); + } + protected function getBackendUser(): BackendUserAuthentication { return $GLOBALS['BE_USER']; diff --git a/typo3/sysext/backend/Classes/Middleware/BackendModuleValidator.php b/typo3/sysext/backend/Classes/Middleware/BackendModuleValidator.php index a1333e1ed8e7aa305616be57ff4f0e7718fc040e..c7d3feaeecc3d5999fcb9389c50fc40333e92583 100644 --- a/typo3/sysext/backend/Classes/Middleware/BackendModuleValidator.php +++ b/typo3/sysext/backend/Classes/Middleware/BackendModuleValidator.php @@ -31,7 +31,12 @@ use TYPO3\CMS\Backend\Routing\RouteRedirect; use TYPO3\CMS\Backend\Routing\UriBuilder; use TYPO3\CMS\Backend\Utility\BackendUtility; use TYPO3\CMS\Core\Http\RedirectResponse; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Messaging\FlashMessageQueue; +use TYPO3\CMS\Core\Messaging\FlashMessageService; use TYPO3\CMS\Core\Type\Bitmask\Permission; +use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; use TYPO3\CMS\Core\Utility\MathUtility; /** @@ -44,7 +49,8 @@ class BackendModuleValidator implements MiddlewareInterface { public function __construct( protected readonly UriBuilder $uriBuilder, - protected readonly ModuleProvider $moduleProvider + protected readonly ModuleProvider $moduleProvider, + protected readonly FlashMessageService $flashMessageService, ) {} /** @@ -55,6 +61,8 @@ class BackendModuleValidator implements MiddlewareInterface { /** @var Route $route */ $route = $request->getAttribute('route'); + $selectedSubModule = null; + $inaccessibleSubModule = null; $ensureToPersistUserSettings = false; $backendUser = $GLOBALS['BE_USER'] ?? null; if (!$backendUser @@ -68,17 +76,21 @@ class BackendModuleValidator implements MiddlewareInterface // (either the last used or the first in the list) and store this selection for the user. /** @var $module ModuleInterface */ if ($module->getParentModule() && $module->hasSubModules()) { - $selectedSubModule = null; // Note: "action" is a special setting, which is evaluated here individually $subModuleIdentifier = (string)($backendUser->getModuleData($module->getIdentifier())['action'] ?? ''); - if ($module->hasSubModule($subModuleIdentifier) - && $this->moduleProvider->accessGranted($subModuleIdentifier, $backendUser) - ) { - // Use the selected sub module if user has access to it. By checking access here, - // we prevent that the user can no longer access the parent module, since it would - // always run into the ModuleAccessDeniedException. - $selectedSubModule = $module->getSubModule($subModuleIdentifier); - } else { + if ($module->hasSubModule($subModuleIdentifier)) { + if ($this->moduleProvider->accessGranted($subModuleIdentifier, $backendUser)) { + // Use the selected sub module if user has access to it. By checking access here, + // we prevent that the user can no longer access the parent module, since it would + // always run into the ModuleAccessDeniedException. + $selectedSubModule = $module->getSubModule($subModuleIdentifier); + } else { + // Stored sub module exists but is currently not accessible. Store the + // requested module to later inform the user about the forced redirect. + $inaccessibleSubModule = $module->getSubModule($subModuleIdentifier); + } + } + if ($selectedSubModule === null) { // Try to fetch the first accessible sub module. We check access here to prevent // that the user can no longer access the parent module, since it would always run // into the ModuleAccessDeniedException. @@ -112,11 +124,15 @@ class BackendModuleValidator implements MiddlewareInterface // Validate the requested module try { $this->validateModuleAccess($request, $module); + if ($selectedSubModule !== null && $inaccessibleSubModule !== null) { + $this->enqueueRedirectMessage($inaccessibleSubModule, $selectedSubModule); + } } catch (ModuleAccessDeniedException $e) { // Since the user might request a module which is just temporarily blocked, e.g. due to workspace // restrictions, do not throw an exception but redirect to the first accessible module - if any. - if (($module = $this->moduleProvider->getFirstAccessibleModule($backendUser)) !== null) { - return new RedirectResponse($this->uriBuilder->buildUriFromRoute($module->getIdentifier())); + if (($firstAccessibleModule = $this->moduleProvider->getFirstAccessibleModule($backendUser)) !== null) { + $this->enqueueRedirectMessage($module, $firstAccessibleModule); + return new RedirectResponse($this->uriBuilder->buildUriFromRoute($firstAccessibleModule->getIdentifier())); } // User does not have access to any module.. ¯\_(ツ)_/¯ throw new NoAccessibleModuleException('You don\'t have access to any module.', 1702480600); @@ -213,4 +229,28 @@ class BackendModuleValidator implements MiddlewareInterface } } } + + protected function enqueueRedirectMessage(ModuleInterface $requestedModule, ModuleInterface $redirectedModule): void + { + $languageService = $this->getLanguageService(); + $this->flashMessageService + ->getMessageQueueByIdentifier(FlashMessageQueue::NOTIFICATION_QUEUE) + ->enqueue( + new FlashMessage( + sprintf( + $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:module.noAccess.message'), + $languageService->sL($redirectedModule->getTitle()), + $languageService->sL($requestedModule->getTitle()) + ), + $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:module.noAccess.title'), + ContextualFeedbackSeverity::INFO, + true + ) + ); + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } } diff --git a/typo3/sysext/backend/Resources/Private/Language/locallang.xlf b/typo3/sysext/backend/Resources/Private/Language/locallang.xlf index 2227533bd36815439e952533bb2fe93b1406ef13..0653b2837e30eff42c56c00e77f6927699329252 100644 --- a/typo3/sysext/backend/Resources/Private/Language/locallang.xlf +++ b/typo3/sysext/backend/Resources/Private/Language/locallang.xlf @@ -142,6 +142,12 @@ Have a nice day.</source> <trans-unit id="modulemenu.label" resname="modulemenu.label"> <source>Module Menu</source> </trans-unit> + <trans-unit id="module.noAccess.title" resname="module.noAccess.title"> + <source>No module access</source> + </trans-unit> + <trans-unit id="module.noAccess.message" resname="module.noAccess.message"> + <source>You've been redirected to the "%s" module, because you currently can't access the "%s" module. You either don't have the necessary permissions or access is temporarily not possible, e.g. due to workspace restrictions.</source> + </trans-unit> <trans-unit id="clearcache.title" resname="clearcache.title"> <source>Page cache</source> </trans-unit> diff --git a/typo3/sysext/backend/Tests/Functional/Controller/BackendControllerTest.php b/typo3/sysext/backend/Tests/Functional/Controller/BackendControllerTest.php index 2e84f502ee6ce0d60b72334d1f2c589163a445e6..ac32022e14a55fd99440804c21b5803a4aa47ca3 100644 --- a/typo3/sysext/backend/Tests/Functional/Controller/BackendControllerTest.php +++ b/typo3/sysext/backend/Tests/Functional/Controller/BackendControllerTest.php @@ -25,11 +25,17 @@ use TYPO3\CMS\Core\Core\Bootstrap; use TYPO3\CMS\Core\Core\SystemEnvironmentBuilder; use TYPO3\CMS\Core\EventDispatcher\ListenerProvider; use TYPO3\CMS\Core\Http\ServerRequest; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Messaging\FlashMessageQueue; +use TYPO3\CMS\Core\Messaging\FlashMessageService; +use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; final class BackendControllerTest extends FunctionalTestCase { + protected array $testExtensionsToLoad = ['workspaces']; + public function setUp(): void { parent::setUp(); @@ -71,4 +77,29 @@ final class BackendControllerTest extends FunctionalTestCase self::assertInstanceOf(AfterBackendPageRenderEvent::class, $state['after-backend-page-render-listener']); } + + /** + * @test + */ + public function flashMessageIsDispatchedForForcedRedirect(): void + { + // Set workspace to disable the site configuration module + $GLOBALS['BE_USER']->workspace = 1; + + $request = (new ServerRequest('https://example.com/typo3/main')) + ->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE) + ->withQueryParams(['redirect' => 'site_configuration']) + ->withAttribute('route', new Route('/main', ['packageName' => 'typo3/cms-backend', '_identifier' => 'main'])); + + $GLOBALS['TYPO3_REQUEST'] = $request; + $this->get(BackendController::class)->mainAction($request); + + $flashMessage = $this->get(FlashMessageService::class) + ->getMessageQueueByIdentifier(FlashMessageQueue::NOTIFICATION_QUEUE) + ->getAllMessages()[0] ?? null; + + self::assertInstanceOf(FlashMessage::class, $flashMessage); + self::assertEquals('No module access', $flashMessage->getTitle()); + self::assertEquals(ContextualFeedbackSeverity::INFO, $flashMessage->getSeverity()); + } } diff --git a/typo3/sysext/backend/Tests/Functional/Middleware/BackendModuleValidatorTest.php b/typo3/sysext/backend/Tests/Functional/Middleware/BackendModuleValidatorTest.php index ffced0a217486654e4a1ebbdfd24897518a1e91d..1ec5aa66da363c4ef7dbdb00d4410c744e6c42c7 100644 --- a/typo3/sysext/backend/Tests/Functional/Middleware/BackendModuleValidatorTest.php +++ b/typo3/sysext/backend/Tests/Functional/Middleware/BackendModuleValidatorTest.php @@ -29,6 +29,10 @@ use TYPO3\CMS\Backend\Routing\UriBuilder; use TYPO3\CMS\Core\Core\Bootstrap; use TYPO3\CMS\Core\Http\Response; use TYPO3\CMS\Core\Http\ServerRequest; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Messaging\FlashMessageQueue; +use TYPO3\CMS\Core\Messaging\FlashMessageService; +use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; final class BackendModuleValidatorTest extends FunctionalTestCase @@ -48,6 +52,7 @@ final class BackendModuleValidatorTest extends FunctionalTestCase $this->subject = new BackendModuleValidator( $this->get(UriBuilder::class), $this->get(ModuleProvider::class), + $this->get(FlashMessageService::class), ); $this->request = new ServerRequest('/some/uri'); $this->requestHandler = new class () implements RequestHandlerInterface { @@ -145,6 +150,33 @@ final class BackendModuleValidatorTest extends FunctionalTestCase ); } + /** + * @test + */ + public function flashMessageIsDispatchedForForcedRedirect(): void + { + $module = $this->get(ModuleFactory::class)->createModule( + 'some_module', + [ + 'packageName' => 'typo3/cms-testing', + 'path' => '/some/module', + ] + ); + + $this->subject->process( + $this->request->withAttribute('route', new Route('/some/route', ['module' => $module])), + $this->requestHandler + ); + + $flashMessage = $this->get(FlashMessageService::class) + ->getMessageQueueByIdentifier(FlashMessageQueue::NOTIFICATION_QUEUE) + ->getAllMessages()[0] ?? null; + + self::assertInstanceOf(FlashMessage::class, $flashMessage); + self::assertEquals('No module access', $flashMessage->getTitle()); + self::assertEquals(ContextualFeedbackSeverity::INFO, $flashMessage->getSeverity()); + } + /** * @test */