Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
1 change: 1 addition & 0 deletions routes/actions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
37 changes: 23 additions & 14 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
2 changes: 1 addition & 1 deletion src/Auth/AuthServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
5 changes: 2 additions & 3 deletions src/Http/Controllers/Auth/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()));
}
}
6 changes: 4 additions & 2 deletions src/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 18 additions & 2 deletions src/Route/Routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<int, non-empty-string>
*/
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.
*
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions src/View/TemplateGlobals.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
39 changes: 39 additions & 0 deletions tests/Feature/Http/Controllers/Auth/LoginControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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']);
Expand Down Expand Up @@ -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',
Expand Down
8 changes: 6 additions & 2 deletions tests/Feature/Http/Controllers/Auth/OAuthControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading