diff --git a/CHANGELOG.md b/CHANGELOG.md index 11d9a9c8abc..e021052575b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Release Notes for Craft CMS 6 +## Unreleased + +- Fixed a bug where site routes weren't being registered for each localized site value. + ## 6.0.0-alpha.11 - 2026-07-07 - Users can now connect their accounts to one or more Socialite providers. ([#19202](https://github.com/craftcms/cms/pull/19202)) diff --git a/routes/actions.php b/routes/actions.php index 0b9e8f6eccd..265e57e86fe 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -127,6 +127,7 @@ Route::post('auth/verify-recovery-code', [TwoFactorAuthenticationController::class, 'verifyRecoveryCode']); }); Route::post('auth/passkey-request-options', [PasskeyController::class, 'requestOptions']); + Route::post('users/login', [LoginController::class, 'attemptLogin']); Route::post('users/login-with-passkey', [PasskeyController::class, 'login']); Route::post('users/login-modal', [LoginController::class, 'showLoginModal']); Route::any('users/redirect', [LoginController::class, 'redirect']); diff --git a/routes/web.php b/routes/web.php index e89e7eb6d26..d710d1e4c8f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -17,26 +17,35 @@ $routes = app(CraftRoutes::class); if (Edition::get()->registersFrontendUserRoutes()) { - if (Cms::config()->loginPath !== false) { - Route::get(Cms::config()->loginPath, [LoginController::class, 'showLogin']); + if (Cms::config()->getLoginPath() !== false) { Route::get(CpAuthPath::TwoFactorChallenge->value, [TwoFactorAuthenticationController::class, 'showForm']); } - if (Cms::config()->verifyEmailPath !== false) { - Route::get(Cms::config()->verifyEmailPath, [VerifyEmailController::class, 'show']); - Route::post(Cms::config()->verifyEmailPath, [VerifyEmailController::class, 'store']); - } + /* + * These paths can be localized per site, so a route is registered for every + * site's value. Changing them or the sites requires re-running `route:cache` + * on installs that cache routes. + */ + if (Cms::isInstalled()) { + foreach ($routes->localizedConfigPaths('getLoginPath') as $path) { + Route::get($path, [LoginController::class, 'showLogin']); + Route::post($path, [LoginController::class, 'attemptLogin']); + } - if (Cms::config()->setPasswordPath !== false) { - Route::get(Cms::config()->setPasswordPath, [SetPasswordController::class, 'show']); - Route::post(Cms::config()->setPasswordPath, [SetPasswordController::class, 'store']); - } + foreach ($routes->localizedConfigPaths('getVerifyEmailPath') as $path) { + Route::get($path, [VerifyEmailController::class, 'show']); + Route::post($path, [VerifyEmailController::class, 'store']); + } - Route::middleware('auth')->group(function () { - if (Cms::config()->logoutPath !== false) { - Route::get(Cms::config()->logoutPath, [LoginController::class, 'logout']); + foreach ($routes->localizedConfigPaths('getSetPasswordPath') as $path) { + Route::get($path, [SetPasswordController::class, 'show']); + Route::post($path, [SetPasswordController::class, 'store']); } - }); + + foreach ($routes->localizedConfigPaths('getLogoutPath') as $path) { + Route::get($path, [LoginController::class, 'logout'])->middleware('auth'); + } + } } if (OAuth::isAvailable()) { diff --git a/src/Auth/AuthServiceProvider.php b/src/Auth/AuthServiceProvider.php index 2e7677fa6b5..99e2ccc02ba 100644 --- a/src/Auth/AuthServiceProvider.php +++ b/src/Auth/AuthServiceProvider.php @@ -69,7 +69,7 @@ private function bootRedirects(): void return Cms::config()->cpTrigger.'/login'; } - return Cms::config()->loginPath; + return Cms::config()->getLoginPath(); }); RedirectIfAuthenticated::redirectUsing(fn (Request $request) => URL::defaultReturnUrl()); diff --git a/src/Http/Controllers/Auth/LoginController.php b/src/Http/Controllers/Auth/LoginController.php index 14a00b028f5..06b7f580e57 100644 --- a/src/Http/Controllers/Auth/LoginController.php +++ b/src/Http/Controllers/Auth/LoginController.php @@ -31,6 +31,7 @@ use Tpetry\QueryExpressions\Function\String\Lower; use function CraftCms\Cms\cp_url; +use function CraftCms\Cms\site_url; use function CraftCms\Cms\template; readonly class LoginController extends AuthenticationController @@ -180,8 +181,6 @@ public function logout(Request $request): Response return redirect(cp_url(CpAuthPath::Login->value)); } - return $this->asSuccess( - redirect: $this->generalConfig->getPostLogoutRedirect(), - ); + return redirect(site_url($this->generalConfig->getPostLogoutRedirect())); } } diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index d0cecafd3b8..92bb60adebd 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -72,8 +72,10 @@ public function register(): void public function boot(): void { AuthenticationException::redirectUsing(function () { - if (! request()->isCpRequest() && Cms::config()->loginPath !== false) { - return Url::siteUrl(Cms::config()->getLoginPath()); + $loginPath = Cms::config()->getLoginPath(); + + if (! request()->isCpRequest() && $loginPath !== false) { + return Url::siteUrl($loginPath); } return Url::cpUrl(CpAuthPath::Login->value); diff --git a/src/Route/Routes.php b/src/Route/Routes.php index c5d7c75332e..8aec3531faf 100644 --- a/src/Route/Routes.php +++ b/src/Route/Routes.php @@ -11,6 +11,7 @@ use CraftCms\Cms\Route\Events\RouteDeleting; use CraftCms\Cms\Route\Events\RouteSaved; use CraftCms\Cms\Route\Events\RouteSaving; +use CraftCms\Cms\Site\Data\Site; use CraftCms\Cms\Site\Events\SiteDeleted; use CraftCms\Cms\Site\Exceptions\SiteNotFoundException; use CraftCms\Cms\Site\Sites; @@ -87,6 +88,21 @@ public function __construct( private readonly Sites $sites, ) {} + /** + * Returns the unique localized values of a GeneralConfig path setting across all sites, + * for settings that can be defined per site (a site handle keyed array or callable). + * + * @return Collection + */ + public function localizedConfigPaths(string $getter): Collection + { + return $this->sites->getAllSites() + ->map(fn (Site $site) => Cms::config()->$getter($site->handle)) + ->filter(fn (mixed $path) => is_string($path) && $path !== '') + ->unique() + ->values(); + } + /** * Returns the routes defined in the project config. * @@ -180,7 +196,7 @@ public function deleteRouteByUid(string $routeUid): bool } event(new RouteDeleting(new Route( - uriParts: $route['uriParts'], + uriParts: $route['uriParts'] ?? [], template: $route['template'], siteUid: $route['siteUid'], uid: $routeUid, @@ -192,7 +208,7 @@ public function deleteRouteByUid(string $routeUid): bool ); event(new RouteDeleted(new Route( - uriParts: $route['uriParts'], + uriParts: $route['uriParts'] ?? [], template: $route['template'], siteUid: $route['siteUid'], uid: $routeUid, diff --git a/src/View/TemplateGlobals.php b/src/View/TemplateGlobals.php index 64f3a57ce3c..2da798642cc 100644 --- a/src/View/TemplateGlobals.php +++ b/src/View/TemplateGlobals.php @@ -57,11 +57,11 @@ public function resolve(): array 'language' => app()->getLocale(), 'devMode' => $this->app->hasDebugModeEnabled(), 'isInstalled' => $isInstalled, - 'loginUrl' => $this->generalConfig->loginPath !== false - ? Url::siteUrl($this->generalConfig->getLoginPath()) + 'loginUrl' => is_string($loginPath = $this->generalConfig->getLoginPath()) + ? Url::siteUrl($loginPath) : null, - 'logoutUrl' => $this->generalConfig->logoutPath !== false - ? Url::siteUrl($this->generalConfig->getLogoutPath()) + 'logoutUrl' => is_string($logoutPath = $this->generalConfig->getLogoutPath()) + ? Url::siteUrl($logoutPath) : null, 'setPasswordUrl' => $setPasswordRequestPath !== null ? Url::siteUrl($setPasswordRequestPath) : null, 'now' => now(), diff --git a/tests/Feature/Http/Controllers/Auth/LoginControllerTest.php b/tests/Feature/Http/Controllers/Auth/LoginControllerTest.php index f786fde5793..86fcd4347e6 100644 --- a/tests/Feature/Http/Controllers/Auth/LoginControllerTest.php +++ b/tests/Feature/Http/Controllers/Auth/LoginControllerTest.php @@ -17,6 +17,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Route; use Inertia\Testing\AssertableInertia; use function CraftCms\Cms\cp_url; @@ -145,6 +146,28 @@ expect(Auth::check())->toBeFalse(); }); +test('logout redirects to the post-logout redirect, not back to the previous page', function () { + Cms::config()->postLogoutRedirect = ''; + + actingAs(User::findOne()); + + // Even when arriving from a page, logout must not fall through to back(). + $response = $this->from('https://localhost/members/dashboard') + ->get('/'.Cms::config()->getLogoutPath()) + ->assertRedirect(); + + expect($response->headers->get('Location'))->toBe('https://localhost/'); +}); + +test('logout honors a configured post-logout redirect', function () { + Cms::config()->postLogoutRedirect = 'goodbye'; + + actingAs(User::findOne()); + + $this->get('/'.Cms::config()->getLogoutPath()) + ->assertRedirect('https://localhost/goodbye'); +}); + test('showLoginModal requires email parameter', function () { postJson(action([LoginController::class, 'showLoginModal']), []) ->assertJsonValidationErrors(['email']); @@ -226,6 +249,22 @@ expect(Auth::check())->toBeTrue(); }); +test('login routes are registered for localized loginPath values', function () { + Cms::config()->isSystemLive = true; + Cms::config()->loginPath = ['siteWithCustomPath' => 'aanmelden']; + + Route::middleware(['web', 'craft', 'craft.web'])->group(dirname(__DIR__, 5).'/routes/web.php'); + + $user = User::findOne(); + + postJson('/aanmelden', [ + 'loginName' => $user->email, + 'password' => 'craftcms2018!!', + ])->assertOk(); + + expect(Auth::check())->toBeTrue(); +}); + test('attemptLogin respects LoginUserRetrieving event', function () { $user = UserModel::factory()->admin()->create([ 'email' => 'event-user@example.com', diff --git a/tests/Feature/Http/Controllers/Auth/OAuthControllerTest.php b/tests/Feature/Http/Controllers/Auth/OAuthControllerTest.php index faa8724d202..d07640c121d 100644 --- a/tests/Feature/Http/Controllers/Auth/OAuthControllerTest.php +++ b/tests/Feature/Http/Controllers/Auth/OAuthControllerTest.php @@ -65,8 +65,12 @@ function oauthControllerCallbackUrl(bool $isCpRequest = false, string $provider function oauthControllerLoginUrl(bool $isCpRequest): string { - if (! $isCpRequest && app(GeneralConfig::class)->loginPath !== false) { - return Url::siteUrl(app(GeneralConfig::class)->getLoginPath()); + if (! $isCpRequest) { + $loginPath = app(GeneralConfig::class)->getLoginPath(); + + if ($loginPath !== false) { + return Url::siteUrl($loginPath); + } } return cp_url('login');