diff --git a/CHANGELOG.md b/CHANGELOG.md index c3e195dbbeb..909c1771d33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Updated elevated session prompts to use the modern control panel frontend while preserving the legacy JavaScript APIs. +- Login attempts are now rate limited. - Added `Illuminate\Contracts\Translation\HasLocalePreference` support to user elements, allowing Laravel notifications to use users’ Language preferences. ([#19228](https://github.com/craftcms/cms/pull/19228)) - Fixed a bug where site routes weren't being registered for each localized site value. - Fixed a bug where POST requests to the `loginPath` weren’t being handled properly. ([#19220](https://github.com/craftcms/cms/pull/19220)) 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/package.json b/package.json index 98f4e0331f1..83b4fe26317 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dev:cp": "cd ./packages/craftcms-cp && npm run dev", "build:cp": "cd ./packages/craftcms-cp && npm run build", "build:garnish": "cd ./packages/craftcms-garnish && npm run build", + "test": "vitest run", "test:cp": "cd ./packages/craftcms-cp && npm run test", "storybook": "storybook dev -p 6007", "build:storybook": "storybook build", 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/packages/craftcms-legacy/cp/src/css/_cp.scss b/packages/craftcms-legacy/cp/src/css/_cp.scss index 6041a22c3d0..6141833e550 100644 --- a/packages/craftcms-legacy/cp/src/css/_cp.scss +++ b/packages/craftcms-legacy/cp/src/css/_cp.scss @@ -1577,21 +1577,6 @@ li.breadcrumb-toggle-wrapper { } } -/* grids */ -.grid { - position: relative; - min-height: 1px; // Required for Grid.js to run - - &::after { - @include mixins.clearafter; - } - - & > .item { - display: none; - box-sizing: border-box; - } -} - %type-heading-small { text-transform: uppercase; color: var(--medium-text-color); diff --git a/packages/craftcms-legacy/cp/src/js/ElevatedSessionForm.js b/packages/craftcms-legacy/cp/src/js/ElevatedSessionForm.js deleted file mode 100644 index 4de1041ca7e..00000000000 --- a/packages/craftcms-legacy/cp/src/js/ElevatedSessionForm.js +++ /dev/null @@ -1,106 +0,0 @@ -/** global: Craft */ -/** global: Garnish */ -/** - * Elevated Session Form - */ -Craft.ElevatedSessionForm = Garnish.Base.extend({ - $form: null, - inputSelectors: null, - $inputs: null, - inputs: null, - - init: function (form, inputs) { - this.$form = $(form); - this.inputSelectors = []; - this.$inputs = $(); - this.inputs = []; - - // Only check specific inputs? - if (typeof inputs !== 'undefined') { - $.makeArray(inputs).forEach((selector) => { - if (typeof selector === 'string') { - this.inputSelectors.push(selector); - } - - $(selector, this.$form).each((i, input) => { - this.$inputs = this.$inputs.add(input); - const $input = $(input); - this.inputs.push({ - $input, - val: Garnish.getInputPostVal($input), - }); - }); - }); - } - - // is this for a slideout? - const slideout = this.$form.data('slideout'); - if (slideout) { - } else { - } - this.addListener(this.$form, 'submit', 'handleFormSubmit'); - }, - - handleFormSubmit: function (ev) { - // Ignore if we're in the middle of getting the elevated session timeout - if (Craft.elevatedSessionManager.fetchingTimeout) { - ev.preventDefault(); - ev.stopImmediatePropagation(); - ev.cancel = true; - return; - } - - if (!this.inputsChanged()) { - return; - } - - // Prevent the form from submitting until the user has an elevated session - ev.preventDefault(); - ev.stopImmediatePropagation(); - ev.cancel = true; - - Craft.elevatedSessionManager.requireElevatedSession( - this.submitForm.bind(this) - ); - }, - - inputsChanged: function () { - if (!this.inputSelectors.length && !this.inputs.length) { - // no way to know - return true; - } - - // If we have any input selectors, see if there are any new inputs that match them - for (const selector of this.inputSelectors) { - const $inputs = $(selector, this.$form); - for (let i = 0; i < $inputs.length; i++) { - const input = $inputs[i]; - if (!this.$inputs.is(input)) { - return true; - } - } - } - - // If we have any inputs, see if their values have changed - for (let {$input, val} of this.inputs) { - // Is this a password input? - if ($input.data('passwordInput')) { - $input = $input.data('passwordInput').$currentInput; - } - - // Has this input's value changed? - if (Garnish.getInputPostVal($input) !== val) { - return true; - } - } - - return false; - }, - - submitForm: function () { - // Don't let handleFormSubmit() interrupt this time - this.disable(); - this.$form.trigger('submit'); - this.enable(); - }, -}); diff --git a/packages/craftcms-legacy/cp/src/js/ElevatedSessionManager.js b/packages/craftcms-legacy/cp/src/js/ElevatedSessionManager.js deleted file mode 100644 index e1ded0731a1..00000000000 --- a/packages/craftcms-legacy/cp/src/js/ElevatedSessionManager.js +++ /dev/null @@ -1,124 +0,0 @@ -/** global: Craft */ -/** global: Garnish */ - -/** - * Elevated Session Manager - */ -Craft.ElevatedSessionManager = Garnish.Base.extend( - { - fetchingTimeout: false, - - loginModal: null, - showingLoginModal: false, - - onSuccess: null, - onCancel: null, - success: false, - - /** - * @callback requireElevatedSessionCallback - */ - /** - * Requires that the user has an elevated session. - * - * @param {requireElevatedSessionCallback} onSuccess The callback function that should be called once the user has an elevated session - * @param {requireElevatedSessionCallback} [onCancel] The callback function that should be called if establishing an elevated session is cancelled - * @param {number} [minSafeElevatedSessionTimeout] The minimum amount of time that must be remaining on an existing elevated session - * (in seconds), for it to be considered safe. (Defaults to 5.) - */ - async requireElevatedSession( - onSuccess, - onCancel, - minSafeElevatedSessionTimeout - ) { - this.onSuccess = onSuccess; - this.onCancel = onCancel; - - // Check the time remaining on the user’s elevated session (if any) - this.fetchingTimeout = true; - - let data; - - try { - const response = await Craft.sendActionRequest( - 'POST', - 'users/get-elevated-session-timeout' - ); - data = response.data; - } finally { - this.fetchingTimeout = false; - } - - if ( - data.timeout === false || - data.timeout >= - (minSafeElevatedSessionTimeout || - Craft.ElevatedSessionManager.minSafeElevatedSessionTimeout) - ) { - this.onSuccess(); - } else { - // Show the login modal - this.showLoginModal(); - } - }, - - /** - * Shows the login modal. - */ - async showLoginModal() { - if (this.showingLoginModal) { - return; - } - - this.showingLoginModal = true; - - if (this.loginModal) { - this.loginModal.destroy(); - } - - const {data} = await Craft.sendActionRequest( - 'POST', - 'users/login-modal', - { - data: { - email: Craft.userEmail, - forElevatedSession: true, - }, - } - ); - const $container = $(data.html); - - this.loginModal = new Garnish.Modal($container, { - closeOtherModals: false, - shadeClass: 'modal-shade dark login-modal-shade', - onFadeIn: async () => { - Craft.initUiElements($container); - const $loginForm = $container.find('craft-login-form'); - $loginForm.on('craft:login:success', (event) => { - event.preventDefault(); - this.success = true; - this.loginModal.hide(); - }); - }, - onFadeOut: () => { - this.loginModal.destroy(); - this.loginModal = null; - }, - onHide: () => { - this.showingLoginModal = false; - if (this.success) { - this.onSuccess(); - } else if (this.onCancel) { - this.onCancel(); - } - }, - }); - }, - }, - { - minSafeElevatedSessionTimeout: 5, - } -); - -// Instantiate it -Craft.elevatedSessionManager = new Craft.ElevatedSessionManager(); diff --git a/resources/js/common/components/Pane.vue b/resources/js/common/components/Pane.vue index 82661bb14b2..58ab2d6b800 100644 --- a/resources/js/common/components/Pane.vue +++ b/resources/js/common/components/Pane.vue @@ -74,7 +74,7 @@
-

+

{{ title }}

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/graphql/schemas/Edit.vue b/resources/js/pages/graphql/schemas/Edit.vue index 974e177999e..335d57e40a5 100644 --- a/resources/js/pages/graphql/schemas/Edit.vue +++ b/resources/js/pages/graphql/schemas/Edit.vue @@ -42,6 +42,7 @@ enabled: props.token?.enabled ?? true, expiryDate: props.token?.expiryDate ?? '', }); + const initialPermissions = new Set(form.permissions); const routeAction = () => { if (!props.schema.id) { @@ -53,7 +54,13 @@ }); }; - const {save} = useSettingsSave(form, routeAction); + const {save} = useSettingsSave(form, routeAction, { + passwordConfirmation: { + required: ({permissions}) => + permissions.length !== initialPermissions.size || + permissions.some((permission) => !initialPermissions.has(permission)), + }, + }); useAppLayout({form, onSave: save}); diff --git a/resources/js/pages/graphql/tokens/Edit.vue b/resources/js/pages/graphql/tokens/Edit.vue index fdf6adeb3f5..4a5d5fee80c 100644 --- a/resources/js/pages/graphql/tokens/Edit.vue +++ b/resources/js/pages/graphql/tokens/Edit.vue @@ -16,6 +16,7 @@ update, } from '@actions/Gql/TokensController'; import {useForm, useHttp} from '@inertiajs/vue3'; + import {elevatedSessionManager} from '@/modules/auth/elevated-session'; type TokenData = Pick< CraftCms.Cms.Gql.Data.GqlToken, @@ -57,7 +58,9 @@ const routeAction = () => props.token.id ? update({tokenId: props.token.id}) : store(); - const {save} = useSettingsSave(form, routeAction); + const {save} = useSettingsSave(form, routeAction, { + passwordConfirmation: {required: () => true}, + }); useAppLayout({form, onSave: save}); @@ -72,7 +75,7 @@ const tokenId = props.token.id; if (!visibleAccessToken.value && tokenId) { - (Craft as any).elevatedSessionManager.requireElevatedSession(async () => { + await elevatedSessionManager.run(async () => { await revealToken(tokenId); await nextTick(); await copyButton.value?.copyValue(); diff --git a/resources/js/pages/settings/users/groups/Edit.vue b/resources/js/pages/settings/users/groups/Edit.vue index 19636a812e3..671569ec1e0 100644 --- a/resources/js/pages/settings/users/groups/Edit.vue +++ b/resources/js/pages/settings/users/groups/Edit.vue @@ -40,6 +40,7 @@ description: props.group.description ?? '', permissions: props.group.permissions ?? [], }); + const initialPermissions = new Set(form.permissions); // Auto-generate handle from name for new sections const handleGenerator = useInputGenerator( @@ -52,7 +53,16 @@ handleGenerator.stop(); } - const {save} = useSettingsSave(form, store); + const {save} = useSettingsSave(form, store, { + passwordConfirmation: { + required: ({permissions}) => + !props.brandNew && + (permissions.length !== initialPermissions.size || + permissions.some( + (permission) => !initialPermissions.has(permission) + )), + }, + }); const actions = computed(() => { if (props.readOnly || !props.group.id) { diff --git a/resources/js/pages/users/Permissions.vue b/resources/js/pages/users/Permissions.vue index 2e2a9a0fdd3..41e9496848b 100644 --- a/resources/js/pages/users/Permissions.vue +++ b/resources/js/pages/users/Permissions.vue @@ -28,6 +28,9 @@ groups: props.currentGroupIds, permissions: props.directPermissions, }); + const initialAdmin = form.admin; + const initialGroups = new Set(form.groups); + const initialPermissions = new Set(form.permissions); const routeAction = () => props.user.isCurrent @@ -36,7 +39,16 @@ userId: props.user.id, }); - const {save} = useSettingsSave(form, routeAction); + const {save} = useSettingsSave(form, routeAction, { + passwordConfirmation: { + required: ({admin, groups, permissions}) => + admin !== initialAdmin || + groups.length !== initialGroups.size || + groups.some((group) => !initialGroups.has(group)) || + permissions.length !== initialPermissions.size || + permissions.some((permission) => !initialPermissions.has(permission)), + }, + }); const lockedPermissions = computed(() => [ ...new Set( @@ -75,80 +87,79 @@ :value="form.permissions.join(',')" /> - -
-
-

{{ t('User Groups') }}

- - -
- -
+ +

{{ t('User Groups') }}

+ + +
+ +
+ + +

{{ t('Permissions') }}

+ + -
-

{{ t('Permissions') }}

- - - - - - - - -
-
+ + + + + +
+
-
-
+ +
diff --git a/resources/js/pages/users/SignInProviders.vue b/resources/js/pages/users/SignInProviders.vue index f550c1bce2a..6df112c58f6 100644 --- a/resources/js/pages/users/SignInProviders.vue +++ b/resources/js/pages/users/SignInProviders.vue @@ -1,6 +1,6 @@