diff --git a/CHANGELOG.md b/CHANGELOG.md index e5999f1258c..7d3282cf845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,7 @@ - Fixed JavaScript errors that could occur throughout the control panel. ([#19313](https://github.com/craftcms/cms/issues/19313)) - Fixed a bug where `forms.checkboxField()` and `CraftCms\Cms\Cp\FormFields::checkboxFieldHtml()` rendered an empty field. ([#19338](https://github.com/craftcms/cms/pull/19338)) - Fixed a bug where Utility pages weren’t rendering, and were logging `$ is not defined` and `window.Cp.config is not a function` errors to the console. ([#19340](https://github.com/craftcms/cms/pull/19340)) +- Fixed a bug where `actionClient` requests for bare action paths could corrupt the `?site=` query string on multi-site installs. ([#19342](https://github.com/craftcms/cms/pull/19342)) - `Craft.cp.announce()` now accepts live regions that are plain elements as well as jQuery collections. ([#19340](https://github.com/craftcms/cms/pull/19340)) ## 6.0.0-alpha.14 - 2026-07-22 diff --git a/packages/craftcms-ui/src/utilities/api/actionClient.test.ts b/packages/craftcms-ui/src/utilities/api/actionClient.test.ts new file mode 100644 index 00000000000..c9919c605e6 --- /dev/null +++ b/packages/craftcms-ui/src/utilities/api/actionClient.test.ts @@ -0,0 +1,80 @@ +import {AxiosHeaders, type InternalAxiosRequestConfig} from 'axios'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {ConfigService} from '../../services/Config'; +import {actionClient} from './actionClient'; + +// The request interceptor references a bare `Cp` global and `window.Craft` when +// building action headers; provide inert stand-ins. +beforeEach(() => { + (globalThis as any).Cp = {registeredAssetBundles: [], registeredJsFiles: []}; + (globalThis as any).Craft = {}; + ConfigService.resetInstance(); +}); + +afterEach(() => { + delete (globalThis as any).Cp; + delete (globalThis as any).Craft; + ConfigService.resetInstance(); +}); + +function runRequestInterceptor( + url: string +): Promise { + const handler = (actionClient.interceptors.request as any).handlers[0] + .fulfilled; + + return handler({url, headers: new AxiosHeaders()}); +} + +describe('actionClient request URL resolution', () => { + it('preserves a query string on the action base URL for bare paths (multi-site)', async () => { + ConfigService.getInstance().initialize({ + actionUrl: 'https://example.test/admin/actions?site=default', + }); + + const config = await runRequestInterceptor('users/confirm-password'); + + // The path must extend the pathname and keep the query intact — NOT + // `?site=default/users/confirm-password`, which is what naive baseURL + // string concatenation produced. + expect(config.url).toBe( + 'https://example.test/admin/actions/users/confirm-password?site=default' + ); + }); + + it('expands bare paths against a clean action base URL (single-site)', async () => { + ConfigService.getInstance().initialize({ + actionUrl: 'https://example.test/admin/actions', + }); + + const config = await runRequestInterceptor('auth/verify-totp'); + + expect(config.url).toBe( + 'https://example.test/admin/actions/auth/verify-totp' + ); + }); + + it('resolves /-prefixed Wayfinder route paths against the origin only', async () => { + ConfigService.getInstance().initialize({ + actionUrl: 'https://example.test/admin/actions?site=default', + }); + + const config = await runRequestInterceptor( + '/admin/actions/fields/render-settings' + ); + + expect(config.baseURL).toBe('https://example.test'); + expect(config.url).toBe('/admin/actions/fields/render-settings'); + }); + + it('leaves absolute URLs untouched', async () => { + ConfigService.getInstance().initialize({ + actionUrl: 'https://example.test/admin/actions', + }); + + const config = await runRequestInterceptor('https://other.test/thing'); + + expect(config.url).toBe('https://other.test/thing'); + expect(config.baseURL).toBeUndefined(); + }); +}); diff --git a/packages/craftcms-ui/src/utilities/api/actionClient.ts b/packages/craftcms-ui/src/utilities/api/actionClient.ts index 90d54d94048..a860033588b 100644 --- a/packages/craftcms-ui/src/utilities/api/actionClient.ts +++ b/packages/craftcms-ui/src/utilities/api/actionClient.ts @@ -44,26 +44,31 @@ export const actionClient = axios.create(); const csrf = new Csrf(); actionClient.interceptors.request.use(async (config) => { - // Resolve the base URL lazily so it reflects the runtime CP trigger; the - // config isn't guaranteed to be initialized when this module is first - // imported. Request URLs come in two shapes, told apart by their leading - // character: + // Resolve the URL lazily so it reflects the runtime CP trigger; the config + // isn't guaranteed to be initialized when this module is first imported. + // Request URLs come in three shapes: // + // - A bare action path (e.g. `users/confirm-password`) is expanded to the + // full action URL via `ConfigService.getActionUrl()`, which inserts the + // path into the *pathname*. This preserves any query string on the action + // base URL — notably `?site=` on multi-site installs — which naive + // `baseURL` + path string concatenation would corrupt by appending the + // path after the query (`?site=default/users/confirm-password`). // - A route path starting with `/` (e.g. a Wayfinder-generated // `/admin/actions/fields/render-settings`) already carries the CP/action - // triggers, so it resolves against the origin only. `URL.origin` - // supplies scheme + host (+ port) without the `protocol` trailing-colon - // / port-doubling pitfalls. - // - A bare action path (e.g. `users/confirm-password`) resolves against - // the full action base URL (`Url::actionUrl()`), which carries the - // triggers for it. - // - // Absolute URLs skip `baseURL` entirely, per axios semantics. - const actionUrl = getActionUrl(); - config.baseURL = - config.url && !config.url.startsWith('/') - ? actionUrl.replace(/\/+$/, '') - : new URL(actionUrl).origin; + // triggers, so it resolves against the origin only. `URL.origin` supplies + // scheme + host (+ port) without the `protocol` trailing-colon / + // port-doubling pitfalls. + // - An absolute URL is left untouched, per axios semantics. + if ( + config.url && + !config.url.startsWith('/') && + !/^[a-z][a-z\d+.-]*:/i.test(config.url) + ) { + config.url = getActionUrl(config.url); + } else if (config.url?.startsWith('/')) { + config.baseURL = new URL(getActionUrl()).origin; + } // Set X-Requested-With header config.headers.set('X-Requested-With', 'XMLHttpRequest'); diff --git a/resources/js/pages/users/Passkeys.vue b/resources/js/pages/users/Passkeys.vue new file mode 100644 index 00000000000..45638b0a546 --- /dev/null +++ b/resources/js/pages/users/Passkeys.vue @@ -0,0 +1,231 @@ + + + diff --git a/resources/templates/users/_passkeys-table.twig b/resources/templates/users/_passkeys-table.twig deleted file mode 100644 index fbb93a9c264..00000000000 --- a/resources/templates/users/_passkeys-table.twig +++ /dev/null @@ -1,36 +0,0 @@ -{% if passkeys is empty %} -

{{ 'No passkeys have been created yet.'|t('app') }}

-{% else %} - {# otherwise show the table of keys #} -
- - - - - - - - - - {% for passkey in passkeys %} - - - - - - {% endfor %} - -
{{ 'Name'|t('app') }}{{ 'Last Used'|t('app') }}{{ 'Actions'|t('app') }}
{{ passkey.credentialName }}{{ passkey.dateLastUsed|timestamp }} - {{ tag('a', { - class: ['delete', 'icon'], - href: '#', - role: 'button', - title: 'Delete'|t('app'), - data: { - uid: passkey.uid, - name: passkey.credentialName, - }, - }) }} -
-
-{% endif %} diff --git a/resources/templates/users/_passkeys.twig b/resources/templates/users/_passkeys.twig deleted file mode 100644 index 267768227df..00000000000 --- a/resources/templates/users/_passkeys.twig +++ /dev/null @@ -1,16 +0,0 @@ -{% import '_includes/forms.twig' as forms %} - -

{{ 'Passkeys'|t('app') }}

-

{{ 'Passkeys are an easy and secure way to identify yourself, using your fingerprint or facial recognition.'|t('app')|widont }}

- -
- {% include 'users/_passkeys-table.twig' %} -
- -{{ forms.button({ - name: 'addSecurityKey', - id: 'add-passkey-btn', - label: 'Add a passkey'|t('app'), -}) }} - - diff --git a/src/Http/Controllers/Users/PasskeysController.php b/src/Http/Controllers/Users/PasskeysController.php index 3aa9f953957..f9c1548c8a2 100644 --- a/src/Http/Controllers/Users/PasskeysController.php +++ b/src/Http/Controllers/Users/PasskeysController.php @@ -8,17 +8,12 @@ use CraftCms\Cms\Auth\Passkeys\Passkeys; use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\Responses\CpScreenResponse; -use CraftCms\Cms\User\Contracts\CraftUser; -use CraftCms\Cms\View\HtmlStack; -use CraftCms\Cms\View\LegacyAssets\InternalAssetRegistry; -use CraftCms\Cms\View\LegacyAssets\PasskeySetupAsset; -use CraftCms\Cms\View\TemplateMode; +use CraftCms\Cms\Http\ViewModels\UserPasskeysViewModel; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; use function CraftCms\Cms\t; -use function CraftCms\Cms\template; readonly class PasskeysController { @@ -30,28 +25,16 @@ public function __construct( private Passkeys $passkeys, ) {} - public function index(Request $request, HtmlStack $HtmlStack): CpScreenResponse + public function index(Request $request): CpScreenResponse { - $currentUser = $request->craftUser(); - if (! $currentUser) { + if (! $currentUser = $request->craftUser()) { abort(401); } $user = $currentUser->asElement(); - $response = $this->asEditUserScreen($user, self::SCREEN_PASSKEYS); - - app(InternalAssetRegistry::class)->register(PasskeySetupAsset::class); - $HtmlStack->js(<<<'JS' -new Craft.PasskeySetup(); -JS); - - $response->contentTemplate('users/_passkeys', [ - 'user' => $user, - 'passkeys' => $this->passkeys->getPasskeys($user)->all(), - ]); - - return $response; + return $this->asEditUserScreen($user, self::SCREEN_PASSKEYS) + ->inertiaPage('users/Passkeys', new UserPasskeysViewModel($user, $this->passkeys)); } public function creationOptions(Request $request): JsonResponse @@ -86,14 +69,7 @@ public function verifyCreation(Request $request): Response return $this->asFailure(t('Passkey creation failed.')); } - $user = $request->craftUser(); - if (! $user) { - abort(401); - } - - return $this->asSuccess(t('Passkey created.'), [ - 'tableHtml' => $this->passkeyTableHtml($user), - ]); + return $this->asSuccess(t('Passkey created.')); } public function delete(Request $request): Response @@ -109,15 +85,6 @@ public function delete(Request $request): Response $this->passkeys->deletePasskey($user, $uid); - return $this->asSuccess(t('Passkey deleted.'), [ - 'tableHtml' => $this->passkeyTableHtml($user), - ]); - } - - private function passkeyTableHtml(CraftUser $user): string - { - return template('users/_passkeys-table', [ - 'passkeys' => $this->passkeys->getPasskeys($user)->all(), - ], templateMode: TemplateMode::Cp); + return $this->asSuccess(t('Passkey deleted.')); } } diff --git a/src/Http/ViewModels/UserPasskeysViewModel.php b/src/Http/ViewModels/UserPasskeysViewModel.php new file mode 100644 index 00000000000..7c62af83587 --- /dev/null +++ b/src/Http/ViewModels/UserPasskeysViewModel.php @@ -0,0 +1,34 @@ + + */ + public array $passkeys; + + public function __construct(User $user, Passkeys $passkeys) + { + $this->passkeys = $passkeys->getPasskeys($user) + ->map(fn (array $passkey): array => [ + 'uid' => $passkey['uid'], + 'name' => $passkey['credentialName'], + 'dateLastUsed' => $passkey['dateLastUsed']?->toIso8601String(), + ]) + ->values() + ->all(); + } +} diff --git a/tests/Feature/Http/Controllers/User/PasskeysControllerTest.php b/tests/Feature/Http/Controllers/User/PasskeysControllerTest.php index 3146fee615f..ed3f55e30a3 100644 --- a/tests/Feature/Http/Controllers/User/PasskeysControllerTest.php +++ b/tests/Feature/Http/Controllers/User/PasskeysControllerTest.php @@ -6,6 +6,7 @@ use CraftCms\Cms\Support\Json; use CraftCms\Cms\User\Elements\User; use Illuminate\Support\Facades\Session; +use Inertia\Testing\AssertableInertia; use function CraftCms\Cms\t; use function Pest\Laravel\actingAs; @@ -27,7 +28,9 @@ test('index', function () { get(action([PasskeysController::class, 'index'])) ->assertOk() - ->assertSee(t('Passkeys')); + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('users/Passkeys') + ->has('passkeys')); }); describe('creationOptions', function () { @@ -106,13 +109,13 @@ ->assertJsonValidationErrorFor('uid'); }); - it('returns success message with table HTML', function () { - // This test would need a real passkey to delete - // For now, we'll just verify the structure when passkey doesn't exist + it('returns a success message', function () { + // This test would need a real passkey to delete; for now we just verify + // the response shape when the passkey doesn't exist. postJson(action([PasskeysController::class, 'delete']), [ 'uid' => 'non-existent-uid', ]) ->assertOk() - ->assertJsonStructure(['message', 'tableHtml']); + ->assertJsonStructure(['message']); }); }); diff --git a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php index 59c683f139d..d63e731921d 100644 --- a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php +++ b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php @@ -12,6 +12,7 @@ use CraftCms\Cms\Http\ViewModels\FieldEditViewModel; use CraftCms\Cms\Http\ViewModels\FilesystemsEditViewModel; use CraftCms\Cms\Http\ViewModels\UserAddressesViewModel; +use CraftCms\Cms\Http\ViewModels\UserPasskeysViewModel; use CraftCms\Cms\Http\ViewModels\UserPermissionsViewModel; use CraftCms\Cms\Http\ViewModels\UserPreferencesViewModel; use CraftCms\Cms\Http\ViewModels\UserProfileViewModel; @@ -58,6 +59,7 @@ protected function configure(TypeScriptTransformerConfigFactory $config): void HtmlFragment::class, FieldEditViewModel::class, UserAddressesViewModel::class, + UserPasskeysViewModel::class, UserPermissionsViewModel::class, UserPreferencesViewModel::class, UserProfileViewModel::class,