From f581b79dab1c5b8048ab621f0b37a88fb1e29d4d Mon Sep 17 00:00:00 2001 From: Rias Date: Fri, 10 Jul 2026 20:33:00 +0200 Subject: [PATCH 1/8] Fix Testbench workbench serving --- composer.json | 5 ++++- testbench.yaml | 2 +- workbench/.env.example | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index b7ca4051a62..9f8968bf1a1 100644 --- a/composer.json +++ b/composer.json @@ -142,7 +142,10 @@ ], "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", "prepare": "@php vendor/bin/testbench package:discover --ansi", - "build": "@php vendor/bin/testbench workbench:build --ansi", + "build": [ + "@php vendor/bin/testbench package:create-sqlite-db --ansi", + "@php vendor/bin/testbench workbench:build --ansi" + ], "serve": [ "Composer\\Config::disableProcessTimeout", "@php vendor/bin/testbench package:sync-skeleton --ansi", diff --git a/testbench.yaml b/testbench.yaml index bd4f20c94da..f61b1625f09 100644 --- a/testbench.yaml +++ b/testbench.yaml @@ -18,7 +18,7 @@ workbench: install: false # We run the Craft install in DatabaseSeeder user: 1 sync: - - from: resources + - from: cms-assets/resources to: public/vendor/craft - from: storage to: workbench/storage diff --git a/workbench/.env.example b/workbench/.env.example index 45a02fd3a90..20eee2ace41 100644 --- a/workbench/.env.example +++ b/workbench/.env.example @@ -7,7 +7,7 @@ APP_KEY=base64:6BDzvwO4hrN7Twv6/gaQ6oZMB/CvpUArZXho189U75E= APP_URL=http://127.0.0.1:8000 DB_CONNECTION=sqlite -DB_DATABASE=workbench/database/database.sqlite +SESSION_DRIVER=database FROM_EMAIL_NAME="Craft CMS" FROM_EMAIL_ADDRESS=info@craftcms.com From 51937e5b3a2a745f636798ccdfc7a520746690b6 Mon Sep 17 00:00:00 2001 From: Rias Date: Fri, 10 Jul 2026 20:33:19 +0200 Subject: [PATCH 2/8] Modernize elevated session confirmation --- CHANGELOG.md | 5 + packages/craftcms-legacy/cp/src/Craft.js | 2 - .../components/HtmlFragmentRenderer.vue | 8 + resources/js/common/layouts/AppLayout.vue | 2 + resources/js/common/types/globals.d.ts | 2 +- resources/js/cp.ts | 1 + resources/js/legacy.ts | 3 + .../auth/components/login/login-form.ts | 10 ++ .../ElevatedSessionHost.test.ts | 96 ++++++++++ .../elevated-session/ElevatedSessionHost.vue | 113 ++++++++++++ .../elevated-session-form.test.ts | 66 +++++++ .../elevated-session/elevated-session-form.ts | 170 ++++++++++++++++++ .../auth/elevated-session/index.test.ts | 41 +++++ .../js/modules/auth/elevated-session/index.ts | 71 ++++++++ .../auth/elevated-session/manager.test.ts | 142 +++++++++++++++ .../modules/auth/elevated-session/manager.ts | 166 +++++++++++++++++ .../settings/composables/useSettingsSave.ts | 75 ++++++-- resources/js/pages/graphql/schemas/Edit.vue | 8 +- resources/js/pages/graphql/tokens/Edit.vue | 7 +- .../js/pages/settings/users/groups/Edit.vue | 9 +- resources/js/pages/users/Permissions.vue | 14 +- resources/js/pages/users/SignInProviders.vue | 55 ++++-- routes/actions.php | 3 + routes/cp.php | 5 +- src/Auth/Concerns/ConfirmsPasswords.php | 13 +- src/Auth/LoginRateLimiter.php | 29 +++ src/Http/Controllers/Auth/LoginController.php | 7 +- .../Auth/SessionInfoController.php | 63 +++++++ .../Controllers/Gql/SchemasController.php | 13 +- src/Http/Controllers/Gql/TokensController.php | 11 +- .../Settings/Users/UserGroupsController.php | 11 -- .../Users/PermissionsController.php | 15 +- .../Users/SignInProvidersController.php | 14 +- .../Middleware/RequireConfirmedPassword.php | 21 +++ src/Route/RouteServiceProvider.php | 10 ++ .../Auth/Concerns/ConfirmsPasswordsTest.php | 21 ++- .../Controllers/Auth/LoginControllerTest.php | 56 ++++++ .../Auth/SessionInfoControllerTest.php | 123 +++++++++++++ .../User/SignInProvidersControllerTest.php | 23 +-- .../RequireConfirmedPasswordTest.php | 39 ++++ 40 files changed, 1428 insertions(+), 115 deletions(-) create mode 100644 resources/js/modules/auth/elevated-session/ElevatedSessionHost.test.ts create mode 100644 resources/js/modules/auth/elevated-session/ElevatedSessionHost.vue create mode 100644 resources/js/modules/auth/elevated-session/elevated-session-form.test.ts create mode 100644 resources/js/modules/auth/elevated-session/elevated-session-form.ts create mode 100644 resources/js/modules/auth/elevated-session/index.test.ts create mode 100644 resources/js/modules/auth/elevated-session/index.ts create mode 100644 resources/js/modules/auth/elevated-session/manager.test.ts create mode 100644 resources/js/modules/auth/elevated-session/manager.ts create mode 100644 src/Auth/LoginRateLimiter.php create mode 100644 src/Http/Middleware/RequireConfirmedPassword.php create mode 100644 tests/Feature/Http/Middleware/RequireConfirmedPasswordTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 11d9a9c8abc..c7e06ae291b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Release Notes for Craft CMS 6 +## Unreleased + +- Updated elevated session prompts to use the modern control panel frontend while preserving the legacy JavaScript APIs. +- Login attempts are now rate limited. + ## 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/packages/craftcms-legacy/cp/src/Craft.js b/packages/craftcms-legacy/cp/src/Craft.js index 8b3aac249fd..5278931ddab 100644 --- a/packages/craftcms-legacy/cp/src/Craft.js +++ b/packages/craftcms-legacy/cp/src/Craft.js @@ -61,8 +61,6 @@ import './js/ElementDeletionManager.js'; import './js/ElementEditor.js'; import './js/ElementFieldSettings.js'; import './js/ElementTableSorter.js'; -import './js/ElevatedSessionForm.js'; -import './js/ElevatedSessionManager.js'; import './js/EntryIndex.js'; import './js/EntrySelectInput.js'; import './js/EntryTypeSelectInput.js'; diff --git a/resources/js/common/components/HtmlFragmentRenderer.vue b/resources/js/common/components/HtmlFragmentRenderer.vue index 3f193ce8e78..53dd9c50853 100644 --- a/resources/js/common/components/HtmlFragmentRenderer.vue +++ b/resources/js/common/components/HtmlFragmentRenderer.vue @@ -11,6 +11,10 @@ fragment?: CraftCms.Cms.View.HtmlFragment | null; }>(); + const emit = defineEmits<{ + rendered: [element: HTMLElement]; + }>(); + const container = ref(null); const disposers: AppendHtmlDisposer[] = []; let lastKey = ''; @@ -86,6 +90,10 @@ ) { return; } + + if (currentRunId === runId) { + emit('rendered', element); + } }, {immediate: true} ); diff --git a/resources/js/common/layouts/AppLayout.vue b/resources/js/common/layouts/AppLayout.vue index f4e28db29ab..b491cc15b2f 100644 --- a/resources/js/common/layouts/AppLayout.vue +++ b/resources/js/common/layouts/AppLayout.vue @@ -15,6 +15,7 @@ import SystemInfo from '@/common/components/SystemInfo.vue'; import UserMenu from '@/common/components/UserMenu.vue'; import ErrorSummary from '@/common/form/ErrorSummary.vue'; + import ElevatedSessionHost from '@/modules/auth/elevated-session/ElevatedSessionHost.vue'; import {useActionRedirect} from '@/common/composables/useActionRedirect'; import {useAnnouncer} from '@/common/composables/useAnnouncer'; import {useAppendHtml} from '@/common/composables/useAppendHtml'; @@ -396,6 +397,7 @@ + diff --git a/resources/js/pages/users/Permissions.vue b/resources/js/pages/users/Permissions.vue index 362c65c0171..41e9496848b 100644 --- a/resources/js/pages/users/Permissions.vue +++ b/resources/js/pages/users/Permissions.vue @@ -87,80 +87,79 @@ :value="form.permissions.join(',')" /> - -
-
-

{{ t('User Groups') }}

- - -
- -
+ +

{{ t('User Groups') }}

+ + +
+ +
+ + +

{{ t('Permissions') }}

+ + -
-

{{ t('Permissions') }}

- - - - - - - - -
-
+ + + + + +
+
-
-
+ +
From 85716025ce222afc39f50bcc142c74713481a282 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Fri, 10 Jul 2026 15:42:27 -0500 Subject: [PATCH 8/8] Refactor a bit --- .../modules/auth/elevated-session/README.md | 51 +++++++++++++++++++ .../elevated-session-form.ce.ts | 50 ++++++++++++++++++ .../js/modules/auth/elevated-session/index.ts | 11 ++++ 3 files changed, 112 insertions(+) create mode 100644 resources/js/modules/auth/elevated-session/README.md create mode 100644 resources/js/modules/auth/elevated-session/elevated-session-form.ce.ts diff --git a/resources/js/modules/auth/elevated-session/README.md b/resources/js/modules/auth/elevated-session/README.md new file mode 100644 index 00000000000..0914bdf27e3 --- /dev/null +++ b/resources/js/modules/auth/elevated-session/README.md @@ -0,0 +1,51 @@ +# Elevated session + +Forces the user to (re)confirm their password before a sensitive change goes +through (toggling admin, granting new permissions/groups, changing a password, +revealing a GraphQL token, etc.). + +Split along the same lines as other modules — a main controller +class, a custom element that boots it, and an `index.ts` that shims the modern +code onto the legacy `Craft.*` globals — plus the reactive manager + Vue modal +that back the confirmation UI. + +## Files + +- `elevated-session-form.ts` — the **main class**, `ElevatedSessionForm`. Guards a + `
`: on submit, if any watched input changed, it holds the native submit, + requires an elevated session via the manager, then re-submits. Dependency-free + DOM (`requestSubmit` / `SubmitEvent`); the only jQuery seam is reading a Craft + password field's swapped input. +- `elevated-session-form.ce.ts` — the **custom element**, + ``. Boots `ElevatedSessionForm` around the `` + it wraps (optional `inputs` JSON-array attribute), so Twig can emit it instead + of a `{% js %}` boot. +- `index.ts` — the **shim**. Assigns `window.Craft.ElevatedSessionForm` and + `window.Craft.elevatedSessionManager` (a `LegacyElevatedSessionManager` adapter + over the modern manager) so existing `new Craft.ElevatedSessionForm(...)` + callers keep working, and `defineElement('craft-elevated-session-form', …)`. + Also exports `mountElevatedSessionHost()` and the `usePasswordConfirmation()` + composable. Imported for side effects from `resources/js/cp.ts`; the host is + mounted from `resources/js/legacy.ts`. +- `manager.ts` — `ElevatedSessionManager`, a reactive (Vue) manager. Talks to the + `users/confirm-password` endpoint, coalesces concurrent `require()` calls, and + drives the modal's state. `run()` also retries a callback once on a `423`. +- `ElevatedSessionHost.vue` — the Vue modal that renders the `craft-login-form` + (with 2FA / passkey fallbacks) when the manager needs confirmation. Mounted once + into ``. + +## Two seams, one manager + +There are two ways to require elevation, both routed through the single reactive +`elevatedSessionManager`: + +- **Legacy DOM forms** — `ElevatedSessionForm` / ``, + used by `EmailField.php` and the `_password` / `_team` / `_permissions` Twig + screens through the `Craft.ElevatedSessionForm` shim. +- **Inertia/Vue** — `useSettingsSave({passwordConfirmation})` and direct + `elevatedSessionManager.run(...)` calls (e.g. `SignInProviders.vue`, + `tokens/Edit.vue`). + +The server is the source of truth: `RequireConfirmedPassword` / +`ConfirmsPasswords::requireConfirmedPassword()` returns `423` when elevation is +missing, so the client guard is UX, not enforcement. diff --git a/resources/js/modules/auth/elevated-session/elevated-session-form.ce.ts b/resources/js/modules/auth/elevated-session/elevated-session-form.ce.ts new file mode 100644 index 00000000000..2ea9dd52858 --- /dev/null +++ b/resources/js/modules/auth/elevated-session/elevated-session-form.ce.ts @@ -0,0 +1,50 @@ +import {ElevatedSessionForm} from '@/modules/auth/elevated-session/elevated-session-form'; +import {ControllerElement} from '@/common/web-components'; + +/** + * `` — boots an {@link ElevatedSessionForm} around + * the `` it wraps, so PHP/Twig can emit the element instead of a manual + * `new Craft.ElevatedSessionForm(...)` `{% js %}` block (the same declarative + * shape as `` / ``). + * + * The form renders as **light-DOM children** — the controller listens on the real + * `` submit and calls `requestSubmit()` on it, so a shadow root would hide + * it. The optional `inputs` attribute is a JSON array of selectors to watch + * (resolved within the form); with none, submitting always requires an elevated + * session — mirroring the constructor's `inputTargets` argument. + * + * ```html + * + * … + * + * ``` + */ +export default class CraftElevatedSessionForm extends ControllerElement { + protected readonly rootSelector = 'form'; + + protected create(form: HTMLElement): ElevatedSessionForm { + return new ElevatedSessionForm( + form as HTMLFormElement, + this.inputSelectors() + ); + } + + private inputSelectors(): string[] | undefined { + const raw = this.getAttribute('inputs'); + if (!raw) { + return undefined; + } + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : undefined; + } catch { + return undefined; + } + } +} + +declare global { + interface HTMLElementTagNameMap { + 'craft-elevated-session-form': CraftElevatedSessionForm; + } +} diff --git a/resources/js/modules/auth/elevated-session/index.ts b/resources/js/modules/auth/elevated-session/index.ts index 89533c71480..14ac840474c 100644 --- a/resources/js/modules/auth/elevated-session/index.ts +++ b/resources/js/modules/auth/elevated-session/index.ts @@ -1,6 +1,8 @@ import {createApp} from 'vue'; import ElevatedSessionHost from './ElevatedSessionHost.vue'; import {ElevatedSessionForm} from './elevated-session-form'; +import CraftElevatedSessionForm from './elevated-session-form.ce'; +import {defineElement} from '@/common/web-components'; import { elevatedSessionManager, ElevatedSessionManager, @@ -35,11 +37,19 @@ class LegacyElevatedSessionManager { } } +// Assign onto the legacy `Craft` global so the Twig/PHP-emitted +// `new Craft.ElevatedSessionForm(...)` (EmailField, _password/_team/_permissions +// screens) and `Craft.elevatedSessionManager` keep working. The manager global is +// a thin `LegacyElevatedSessionManager` shim over the modern reactive manager. const craft = (window as any).Craft ?? ((window as any).Craft = {}); craft.ElevatedSessionManager = LegacyElevatedSessionManager; craft.elevatedSessionManager = new LegacyElevatedSessionManager(); craft.ElevatedSessionForm = ElevatedSessionForm; +// Register the declarative custom element, so Twig can emit +// `` instead of the manual global constructor. +defineElement('craft-elevated-session-form', CraftElevatedSessionForm); + export function mountElevatedSessionHost(): void { if (document.querySelector('[data-elevated-session-host]')) { return; @@ -64,6 +74,7 @@ export function usePasswordConfirmation() { export { ElevatedSessionForm, + CraftElevatedSessionForm, ElevatedSessionHost, elevatedSessionManager, ElevatedSessionManager,