From 31d9e95f8336ce0ec754414631b605399e50b577 Mon Sep 17 00:00:00 2001 From: Thomas Lotze Date: Wed, 27 May 2026 15:00:30 +0200 Subject: [PATCH 1/9] prepare subpackage --- src/plone/app/layout/users/{__init__py => __init__.py} | 0 src/plone/app/layout/users/configure.zcml | 1 - 2 files changed, 1 deletion(-) rename src/plone/app/layout/users/{__init__py => __init__.py} (100%) diff --git a/src/plone/app/layout/users/__init__py b/src/plone/app/layout/users/__init__.py similarity index 100% rename from src/plone/app/layout/users/__init__py rename to src/plone/app/layout/users/__init__.py diff --git a/src/plone/app/layout/users/configure.zcml b/src/plone/app/layout/users/configure.zcml index ecacd150d..585947ec1 100644 --- a/src/plone/app/layout/users/configure.zcml +++ b/src/plone/app/layout/users/configure.zcml @@ -1,7 +1,6 @@ From 4d4f99e1e12c28ea24184554e575c614898f721e Mon Sep 17 00:00:00 2001 From: Thomas Lotze Date: Wed, 27 May 2026 17:23:30 +0200 Subject: [PATCH 2/9] move user schema editor views over from plone.app.users --- setup.py | 3 + src/plone/app/layout/users/configure.zcml | 15 +++++ src/plone/app/layout/users/schema_layout.pt | 69 +++++++++++++++++++++ src/plone/app/layout/users/schemaeditor.py | 35 +++++++++++ 4 files changed, 122 insertions(+) create mode 100644 src/plone/app/layout/users/schema_layout.pt create mode 100644 src/plone/app/layout/users/schemaeditor.py diff --git a/setup.py b/setup.py index 21ced5507..fbfd3cdb1 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,7 @@ "plone.app.registry", "plone.app.relationfield", "plone.app.workflow", + "plone.app.users", "plone.app.uuid", "plone.app.viewletmanager >=1.2", "plone.autoform", @@ -57,6 +58,8 @@ "plone.memoize", "plone.portlets", "plone.protect", + "plone.schemaeditor", + "plone.z3cform", "Products.CMFEditions >=1.2.2", "Products.GenericSetup", "Products.statusmessages", diff --git a/src/plone/app/layout/users/configure.zcml b/src/plone/app/layout/users/configure.zcml index 585947ec1..7bcdf1ea9 100644 --- a/src/plone/app/layout/users/configure.zcml +++ b/src/plone/app/layout/users/configure.zcml @@ -3,4 +3,19 @@ xmlns:browser="http://namespaces.zope.org/browser" > + + + + diff --git a/src/plone/app/layout/users/schema_layout.pt b/src/plone/app/layout/users/schema_layout.pt new file mode 100644 index 000000000..9c233efe0 --- /dev/null +++ b/src/plone/app/layout/users/schema_layout.pt @@ -0,0 +1,69 @@ + + + + +
+ + + Site Setup + + +

Edit Member Form Fields

+ +
+ Portal status message +
+ +
+ + + + + +
+ +
+ + + diff --git a/src/plone/app/layout/users/schemaeditor.py b/src/plone/app/layout/users/schemaeditor.py new file mode 100644 index 000000000..ecaeba020 --- /dev/null +++ b/src/plone/app/layout/users/schemaeditor.py @@ -0,0 +1,35 @@ +from plone.app.users.browser.schemaeditor import ALLOWED_FIELDS +from plone.app.users.browser.schemaeditor import getFromBaseSchema +from plone.app.users.schema import IMemberSchemaContext +from plone.app.users.schema import IUserDataSchema +from plone.app.users.schema import SCHEMATA_KEY +from plone.base import PloneMessageFactory as _ +from plone.schemaeditor.browser.schema.listing import SchemaListing +from plone.schemaeditor.browser.schema.traversal import SchemaContext +from plone.z3cform.layout import FormWrapper +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from zope.interface import implementer + + +class SchemaListingPage(FormWrapper): + form = SchemaListing + index = ViewPageTemplateFile("schema_layout.pt") + + +@implementer(IMemberSchemaContext) +class MemberSchemaContext(SchemaContext): + label = _("Edit Member Form Fields") + + def __init__(self, context, request): + self.fieldsWhichCannotBeDeleted = ["fullname", "email"] + self.showSaveDefaults = False + self.enableFieldsets = False + self.allowedFields = ALLOWED_FIELDS + + schema = getFromBaseSchema(IUserDataSchema) + super().__init__( + schema, + request, + name=SCHEMATA_KEY, + title=_("Member Fields"), + ) From 39bef1ebf43bd0510496e88c8e2a542b34783cac Mon Sep 17 00:00:00 2001 From: Thomas Lotze Date: Fri, 29 May 2026 12:29:34 +0200 Subject: [PATCH 3/9] move AccountPanelForm over here from plone.app.users --- src/plone/app/layout/users/account-panel.pt | 46 +++++ src/plone/app/layout/users/account.py | 187 ++++++++++++++++++++ src/plone/app/layout/users/configure.zcml | 1 + 3 files changed, 234 insertions(+) create mode 100644 src/plone/app/layout/users/account-panel.pt create mode 100644 src/plone/app/layout/users/account.py diff --git a/src/plone/app/layout/users/account-panel.pt b/src/plone/app/layout/users/account-panel.pt new file mode 100644 index 000000000..ab4de8663 --- /dev/null +++ b/src/plone/app/layout/users/account-panel.pt @@ -0,0 +1,46 @@ + + + +

${view/label}

+
+ + +

${view/description}

+
+ + +
+ + + +
+
+ + diff --git a/src/plone/app/layout/users/account.py b/src/plone/app/layout/users/account.py new file mode 100644 index 000000000..83bbbc19d --- /dev/null +++ b/src/plone/app/layout/users/account.py @@ -0,0 +1,187 @@ +from plone.app.users.browser.account import AccountPanelValidation +from plone.app.users.browser.interfaces import IAccountPanelForm +from plone.app.users.utils import notifyWidgetActionExecutionError +from plone.autoform.form import AutoExtensibleForm +from plone.base import PloneMessageFactory as _ +from plone.protect import CheckAuthenticator +from Products.CMFCore.utils import getToolByName +from Products.CMFPlone.controlpanel.events import ConfigurationChangedEvent +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from Products.statusmessages.interfaces import IStatusMessage +from z3c.form import button +from z3c.form import form +from zope.cachedescriptors.property import Lazy as lazy_property +from zope.component import getMultiAdapter +from zope.event import notify +from zope.interface import implementer +from ZTUtils import make_query + + +@implementer(IAccountPanelForm) +class AccountPanelForm(AutoExtensibleForm, form.Form, AccountPanelValidation): + """A simple form to be used as a basis for account panel screens.""" + + schema = IAccountPanelForm + template = ViewPageTemplateFile("account-panel.pt") + enableCSRFProtection = True + + hidden_widgets = [] + successMessage = _("Changes saved.") + noChangesMessage = _("No changes made.") + + @lazy_property + def member(self): + mtool = getToolByName(self.context, "portal_membership") + if self.request.get("userid"): + return mtool.getMemberById(self.request.get("userid")) + + @property + def label(self): + return self.member.getProperty("fullname") or self.member.getUserName() + + def _differentEmail(self, email): + """Check if the submitted form email address differs from the existing + one. + + Keeping your email the same (which happens when you change something + else on the personalize form) or changing it back to your login name, + is fine. + + So: we only return True if it is *really* a different email. + """ + member = self.member + if email in (member.getId(), member.getUserName()): + return False + # By default, PAS transforms login names to lowercase, at least when + # email-as-login is used. So compare the transformed/normalized names. + pas = getToolByName(self.context, "acl_users") + email_normalized = pas.applyTransform(email) + # The user name should already have been normalized, but let's make sure. + login_normalized = pas.applyTransform(member.getUserName()) + return email_normalized != login_normalized + + def makeQuery(self): + userid = self.request.form.get("userid", None) + if userid is not None: + return "?{}".format(make_query({"userid": userid})) + return "" + + def action(self): + return self.request.getURL() + self.makeQuery() + + def validate_email(self, action, data): + error_keys = [error.field.getName() for error in action.form.widgets.errors] + if "email" in error_keys: + # There is already a validation error for email, + # so there is no need for further validation. + return + err_str = super().validate_email(data) + if err_str: + notifyWidgetActionExecutionError(action, "email", err_str) + + def validate_portrait(self, action, data): + """Portrait validation. + Checks if image is supported by Pillow. + SVG files are not yet supported. + """ + error_keys = [error.field.getName() for error in action.form.widgets.errors] + if "portrait" in error_keys: + return + err_str = super().validate_portrait(data) + if err_str: + notifyWidgetActionExecutionError(action, "portrait", err_str) + + @button.buttonAndHandler(_("Save")) + def handleSave(self, action): + CheckAuthenticator(self.request) + data, errors = self.extractData() + + # Extra validation for email, when it is there. email is not in the + # data when you are at the personal-preferences page. + if "email" in data: + self.validate_email(action, data) + + # Validate portrait, upload image could be not supported + # by PIL what raises an exception when scaling image. + if "portrait" in data: + self.validate_portrait(action, data) + + if action.form.widgets.errors: + self.status = self.formErrorsMessage + return + if self.applyChanges(data): + IStatusMessage(self.request).addStatusMessage( + self.successMessage, type="info" + ) + notify(ConfigurationChangedEvent(self, data)) + self._on_save(data) + else: + IStatusMessage(self.request).addStatusMessage( + self.noChangesMessage, type="info" + ) + self.request.response.redirect(self.action()) + + def updateActions(self): + super().updateActions() + if self.actions and "save" in self.actions: + self.actions["save"].addClass("btn btn-primary") + + @button.buttonAndHandler(_("Cancel")) + def cancel(self, action): + IStatusMessage(self.request).addStatusMessage( + _("Changes canceled."), type="info" + ) + self.request.response.redirect( + "{}{}".format(self.request["ACTUAL_URL"], self.makeQuery()) + ) + + def _on_save(self, data=None): + pass + + def prepareObjectTabs(self, default_tab="view", sort_first=["folderContents"]): + context = self.context + mt = getToolByName(context, "portal_membership") + tabs = [] + navigation_root_url = context.absolute_url() + + def _check_allowed(context, request, name): + """Check, if user has required permissions on view.""" + view = getMultiAdapter((context, request), name=name) + allowed = True + for perm in view.__ac_permissions__: + allowed = allowed and mt.checkPermission(perm[0], context) + return allowed + + if _check_allowed(context, self.request, "personal-information"): + tabs.append( + { + "title": _( + "title_personal_information_form", "Personal Information" + ), + "url": navigation_root_url + "/@@personal-information", + "selected": (self.__name__ == "personal-information"), + "id": "user_data-personal-information", + } + ) + + if _check_allowed(context, self.request, "personal-preferences"): + tabs.append( + { + "title": _("Personal Preferences"), + "url": navigation_root_url + "/@@personal-preferences", + "selected": (self.__name__ == "personal-preferences"), + "id": "user_data-personal-preferences", + } + ) + + member = mt.getAuthenticatedMember() + if member.canPasswordSet(): + tabs.append( + { + "title": _("label_password", "Password"), + "url": navigation_root_url + "/@@change-password", + "selected": (self.__name__ == "change-password"), + "id": "user_data-change-password", + } + ) + return tabs diff --git a/src/plone/app/layout/users/configure.zcml b/src/plone/app/layout/users/configure.zcml index 7bcdf1ea9..81992852f 100644 --- a/src/plone/app/layout/users/configure.zcml +++ b/src/plone/app/layout/users/configure.zcml @@ -3,6 +3,7 @@ xmlns:browser="http://namespaces.zope.org/browser" > + Date: Fri, 29 May 2026 14:55:37 +0200 Subject: [PATCH 4/9] move user registration forms over from plone.app.users --- src/plone/app/layout/users/configure.zcml | 27 + src/plone/app/layout/users/newuser_form.pt | 33 ++ src/plone/app/layout/users/register.py | 572 ++++++++++++++++++++ src/plone/app/layout/users/register_form.pt | 61 +++ src/plone/app/layout/users/registered.pt | 157 ++++++ 5 files changed, 850 insertions(+) create mode 100644 src/plone/app/layout/users/newuser_form.pt create mode 100644 src/plone/app/layout/users/register.py create mode 100644 src/plone/app/layout/users/register_form.pt create mode 100644 src/plone/app/layout/users/registered.pt diff --git a/src/plone/app/layout/users/configure.zcml b/src/plone/app/layout/users/configure.zcml index 81992852f..ec54c524a 100644 --- a/src/plone/app/layout/users/configure.zcml +++ b/src/plone/app/layout/users/configure.zcml @@ -4,6 +4,33 @@ > + + + + + + + + + + + + + + + +
+

+
+ + +
+
+ +
+ + diff --git a/src/plone/app/layout/users/register.py b/src/plone/app/layout/users/register.py new file mode 100644 index 000000000..3400b9e13 --- /dev/null +++ b/src/plone/app/layout/users/register.py @@ -0,0 +1,572 @@ +from AccessControl import getSecurityManager +from plone.app.users.browser.register import getRegisterSchema +from plone.app.users.schema import IAddUserSchema +from plone.app.users.schema import IRegisterSchema +from plone.app.users.utils import generate_login_name as _generate_login_name +from plone.app.users.utils import generate_user_id as _generate_user_id +from plone.app.users.utils import notifyWidgetActionExecutionError +from plone.autoform.form import AutoExtensibleForm +from plone.base import PloneMessageFactory as _ +from plone.base.interfaces import ISecuritySchema +from plone.base.interfaces import IUserGroupsSettingsSchema +from plone.protect import CheckAuthenticator +from plone.registry.interfaces import IRegistry +from Products.CMFCore.interfaces import ISiteRoot +from Products.CMFCore.permissions import ManagePortal +from Products.CMFCore.utils import getToolByName +from Products.Five import BrowserView +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from Products.statusmessages.interfaces import IStatusMessage +from z3c.form import button +from z3c.form import field +from z3c.form import form +from z3c.form.browser.checkbox import CheckBoxFieldWidget +from z3c.form.interfaces import DISPLAY_MODE +from zExceptions import Forbidden +from ZODB.POSException import ConflictError +from zope.component import getAdapter +from zope.component import getMultiAdapter +from zope.component import getUtility +from zope.schema import getFieldNames + +import datetime +import logging + + +class BaseRegistrationForm(AutoExtensibleForm, form.Form): + """Form to be used as base for Register and Add User forms.""" + + label = "" + description = "" + formErrorsMessage = _("There were errors.") + ignoreContext = True + enableCSRFProtection = True + + # this attribute indicates if user was successfully registered + _finishedRegister = False + + @property + def schema(self): + return getRegisterSchema() + + def _get_security_settings(self): + """Return security settings from the registry.""" + registry = getUtility(IRegistry) + return registry.forInterface(ISecuritySchema, prefix="plone") + + def render(self): + if self._finishedRegister: + return self.context.unrestrictedTraverse("registered")() + + return super().render() + + def updateFields(self): + """Fields are dynamic in this form, to be able to handle + different join styles. + """ + settings = self._get_security_settings() + use_email_as_login = settings.use_email_as_login + + # Finally, let autoform process the schema and any FormExtenders do + # their thing + super().updateFields() + + if use_email_as_login: + self.fields["email"].field.description = _( + "help_email_creation_for_login", + default="Enter an email " + "address. This will be your login name. We respect your " + "privacy and will not give the address away to any third " + "parties or expose it anywhere.", + ) + del self.fields["username"] + else: + self.fields["email"].field.description = _( + "help_email_creation", + default="Enter an email address. This is necessary in case " + "the password is lost. We respect your privacy and " + "will not give the address away to any third parties " + "or expose it anywhere.", + ) + + # Change the password description based on PAS Plugin The user needs a + # list of instructions on what kind of password is required. We'll + # reuse password errors as instructions e.g. "Must contain a letter and + # a number". Assume PASPlugin errors are already translated + if self.fields.get("password", None): + registration = getToolByName(self.context, "portal_registration") + err_str = registration.testPasswordValidity("") + if err_str: + msg = _( + "help_password_creation_with_errors", + default="Enter your new password. ${errors}", + mapping=dict(errors=err_str), + ) + self.fields["password"].field.description = msg + + def updateActions(self): + super().updateActions() + self.actions["register"].addClass("btn-primary") + + def generate_user_id(self, data): + """Generate a user id from data. + + Delegates to the standalone function in plone.app.users.utils. + See :func:`plone.app.users.utils.generate_user_id` for details. + """ + return _generate_user_id(self.context, data) + + def generate_login_name(self, data): + """Generate a login name from data. + + Delegates to the standalone function in plone.app.users.utils. + See :func:`plone.app.users.utils.generate_login_name` for details. + """ + return _generate_login_name(self.context, data) + + # Actions validators + def validate_registration(self, action, data): + """Specific business logic for this join form. Note: all this logic + was taken directly from the old validate_registration.py script in + Products/CMFPlone/skins/plone_login/join_form_validate.vpy + """ + + # CSRF protection + CheckAuthenticator(self.request) + + registration = getToolByName(self.context, "portal_registration") + + error_keys = [error.field.getName() for error in action.form.widgets.errors] + + form_field_names = [f for f in self.fields] + + portal = getUtility(ISiteRoot) + + # passwords should match + if "password" in form_field_names: + assert "password_ctl" in form_field_names + # Skip this check if password fields already have an error + if not ("password" in error_keys or "password_ctl" in error_keys): + password = data.get("password") + password_ctl = data.get("password_ctl") + if password != password_ctl: + err_str = _("Passwords do not match.") + notifyWidgetActionExecutionError(action, "password", err_str) + notifyWidgetActionExecutionError(action, "password_ctl", err_str) + + # Password field checked against RegistrationTool + if "password" in form_field_names: + # Skip this check if password fields already have an error + if "password" not in error_keys: + password = data.get("password") + if password: + # Use PAS to test validity + err_str = registration.testPasswordValidity(password) + if err_str: + notifyWidgetActionExecutionError(action, "password", err_str) + + settings = self._get_security_settings() + if settings.use_email_as_login: + username_field = "email" + else: + username_field = "username" + + # The term 'username' is not clear. It may be the user id or + # the login name. So here we try to be explicit. + + # Generate a nice user id and store that in the data. + user_id = self.generate_user_id(data) + # Generate a nice login name and store that in the data. + login_name = self.generate_login_name(data) + + # Do several checks to see if the user id and the login name + # are valid. + # + # Skip these checks if username was already in error list. + # + # Note that if we cannot generate a unique user id, it is not + # necessarily the fault of the username field, but it + # certainly is the most likely cause in a standard Plone + # setup. + + # check if username is valid + # Skip this check if username was already in error list + if username_field not in error_keys: + # user id may not be the same as the portal id. + if user_id == portal.getId(): + err_str = _( + "This username is reserved. Please choose a different name." + ) + notifyWidgetActionExecutionError(action, username_field, err_str) + + # Check if user id is allowed by the member id pattern. + if username_field not in error_keys: + if not registration.isMemberIdAllowed(user_id): + err_str = _( + "The login name you selected is already in use or is not valid. Please choose another." + ) + notifyWidgetActionExecutionError(action, username_field, err_str) + + if username_field not in error_keys: + # Check the uniqueness of the login name, not only when + # use_email_as_login is true, but always. + pas = getToolByName(self, "acl_users") + results = pas.searchUsers(name=login_name, exact_match=True) + if results: + err_str = _( + "The login name you selected is already in use or is not valid. Please choose another." + ) + notifyWidgetActionExecutionError(action, username_field, err_str) + + if "password" in form_field_names and "password" not in error_keys: + # Admin can either set a password or mail the user (or both). + if not (data["password"] or data["mail_me"]): + err_str = _( + "msg_no_password_no_mail_me", + default="You must set a password or choose to send an email.", + ) + + # set error on password field + notifyWidgetActionExecutionError(action, "password", err_str) + notifyWidgetActionExecutionError(action, "mail_me", err_str) + + @button.buttonAndHandler(_("label_register", default="Register"), name="register") + def action_join(self, action): + data, errors = self.extractData() + + # extra password validation + self.validate_registration(action, data) + + if action.form.widgets.errors: + self.status = self.formErrorsMessage + return + + self.handle_join_success(data) + + # XXX Return somewhere else, depending on what + # handle_join_success returns? + came_from = self.request.form.get("came_from") + if came_from: + utool = getToolByName(self.context, "portal_url") + if utool.isURLInPortal(came_from): + self.request.response.redirect(came_from) + return "" + + def handle_join_success(self, data): + # portal should be acquisition wrapped, this is needed for the schema + # adapter below + portal = getToolByName(self.context, "portal_url").getPortalObject() + registration = getToolByName(self.context, "portal_registration") + + # user_id and login_name should be in the data, but let's be safe. + user_id = data.get("user_id", data.get("username")) + login_name = data.get("login_name", data.get("username")) + + # Set the username for good measure, as some code may expect + # it to exist and contain the user id. + data["username"] = user_id + + # The login name may already be in the form, but not + # necessarily, for example when using email as login. This is + # at least needed for logging in immediately when password + # reset is bypassed. We need the login name here, not the + # user id. + self.request.form["form.username"] = login_name + + password = data.get("password") or registration.generatePassword() + + try: + registration.addMember(user_id, password, REQUEST=self.request) + except (AttributeError, ValueError) as err: + logging.exception(err) + IStatusMessage(self.request).addStatusMessage(err, type="error") + self._finishedRegister = False + return + + if user_id != login_name: + # The user id differs from the login name. Set the login + # name correctly. + pas = getToolByName(self.context, "acl_users") + pas.updateLoginName(user_id, login_name) + + # set member properties + self.applyProperties(user_id, data) + + settings = self._get_security_settings() + self._finishedRegister = True + if data.get("mail_me") or ( + not settings.enable_user_pwd_choice and not data.get("password") + ): + # We want to validate the email address (users cannot + # select their own passwords on the register form) or the + # admin has explicitly requested to send an email on the + # 'add new user' form. + try: + # When all goes well, this call actually returns the + # rendered mail_password_response template. As a side + # effect, this removes any status messages added to + # the request so far, as they are already shown in + # this template. + response = registration.registeredNotify(user_id) + return response + except ConflictError: + # Let Zope handle this exception. + raise + except Exception as err: + logging.exception(err) + ctrlOverview = getMultiAdapter( + (portal, self.request), name="overview-controlpanel" + ) + mail_settings_correct = not ctrlOverview.mailhost_warning() + if mail_settings_correct: + # The email settings are correct, so the most + # likely cause of an error is a wrong email + # address. We remove the account: + # Remove the account: + self.context.acl_users.userFolderDelUsers( + [user_id], REQUEST=self.request + ) + self._finishedRegister = False + IStatusMessage(self.request).addStatusMessage( + _( + "status_fatal_password_mail", + default="Failed to create your account: we were " + "unable to send instructions for setting a password " + "to your email address: ${address}", + mapping={"address": data.get("email", "")}, + ), + type="error", + ) + else: + # This should only happen when an admin registers + # a user. The admin should have seen a warning + # already, but we warn again for clarity. + IStatusMessage(self.request).addStatusMessage( + _( + "status_nonfatal_password_mail", + default="This account has been created, but we " + "were unable to send instructions for setting a " + "password to this email address: ${address}", + mapping={"address": data.get("email", "")}, + ), + type="warning", + ) + + def applyProperties(self, userid, data): + portal = getToolByName(self.context, "portal_url").getPortalObject() + mt = getToolByName(self.context, "portal_membership") + member = mt.getMemberById(userid) + + # cache adapters + adapters = {} + + # Set any fields that are simply properties for the new user, rather + # than fields to help create the new user + register_fields = getFieldNames(IRegisterSchema) + getFieldNames(IAddUserSchema) + for k, value in data.items(): + # skip fields not available in the schema + if k in ["login_name", "user_id"]: + continue + + # skip fields that are handled exclusively on user registration and + # are not part of personal information form + if k in register_fields: + continue + + # get schema adapter + schema = self.fields[k].field.interface + if schema in adapters: + adapter = adapters[schema] + else: + adapters[schema] = adapter = getAdapter(portal, schema) + adapter.context = member + adapter.schema = schema + + # finally set value + setattr(adapter, k, value) + + +class RegistrationForm(BaseRegistrationForm): + """Dynamically get fields from user data, through admin config settings.""" + + label = _("heading_registration_form", default="Registration form") + description = "" + template = ViewPageTemplateFile("register_form.pt") + + @property + def showForm(self): + """The form should not be displayed to the user if the system is + incapable of sending emails and email validation is switched on + (users are not allowed to select their own passwords). + """ + portal = getUtility(ISiteRoot) + ctrlOverview = getMultiAdapter( + (portal, self.request), name="overview-controlpanel" + ) + + settings = self._get_security_settings() + # hide form if mailhost_warning == True and + # enable_user_pwd_choice == False + return not ( + ctrlOverview.mailhost_warning() and not settings.enable_user_pwd_choice + ) + + def updateFields(self): + if not self.showForm: + # We do not want to spend time calculating fields that + # will never get displayed. + return + + super().updateFields() + defaultFields = field.Fields(self.fields) + + # Can the user actually set his/her own password? + settings = self._get_security_settings() + if not settings.enable_user_pwd_choice: + # No? Remove the password fields. + defaultFields = defaultFields.omit("password", "password_ctl") + else: + # The portal is not interested in validating emails, and + # the user is not interested in getting an email with a + # link to set his password if he can set this password in + # the current form already. + defaultFields = defaultFields.omit("mail_me") + + self.fields = defaultFields + + def updateWidgets(self): + if not self.showForm: + # We do not want to spend time calculating widgets that + # will never get displayed. + return + + super().updateWidgets() + settings = self._get_security_settings() + if not settings.enable_user_pwd_choice: + # Show a message indicating that a password reset link + # will be mailed to the user. + widget = self.widgets["mail_me"] + widget.mode = DISPLAY_MODE + widget.value = ["selected"] + widget.label = _( + "label_password_change_mail", + default="A URL will be generated and e-mailed to you; " + "follow the link to reach a page where you can " + "change your password and complete the registration " + "process.", + ) + widget.terms = None + widget.updateTerms() + + +class RegisteredView(BrowserView): + def expire_date(self): + ppr = getToolByName(self.context, "portal_password_reset") + expire_length = datetime.timedelta(days=ppr.getExpirationTimeout()) + expiration_date = datetime.datetime.now() + expire_length + ploneview = getMultiAdapter((self.context, self.request), name="plone") + return ploneview.toLocalizedTime(expiration_date, long_format=1) + + +class AddUserForm(BaseRegistrationForm): + label = _("heading_add_user_form", default="Add New User") + description = "" + template = ViewPageTemplateFile("newuser_form.pt") + + def updateFields(self): + super().updateFields() + defaultFields = field.Fields(self.fields) + + # The mail_me field needs special handling depending on the + # enable_user_pwd_choice setting and on the correctness of the mail + # settings. + portal = getUtility(ISiteRoot) + ctrlOverview = getMultiAdapter( + (portal, self.request), name="overview-controlpanel" + ) + mail_settings_correct = not ctrlOverview.mailhost_warning() + if mail_settings_correct: + # Make the password fields optional: either specify a + # password or mail the user (or both). The validation + # will check that at least one of the options is chosen. + defaultFields["password"].field.required = False + defaultFields["password_ctl"].field.required = False + settings = self._get_security_settings() + defaultFields["mail_me"].field.default = not settings.enable_user_pwd_choice + + # Append the manager-focused fields + registry = getUtility(IRegistry) + user_group_settings = registry.forInterface( + IUserGroupsSettingsSchema, prefix="plone" + ) + many_groups = user_group_settings.many_groups + if not many_groups: + allFields = defaultFields + field.Fields(IAddUserSchema) + allFields["groups"].widgetFactory = CheckBoxFieldWidget + else: + allFields = defaultFields + self.fields = allFields + + def updateWidgets(self): + super().updateWidgets() + + # set display mode for mail_me field if no mailhost is configured + portal = getUtility(ISiteRoot) + ctrlOverview = getMultiAdapter( + (portal, self.request), name="overview-controlpanel" + ) + mail_settings_correct = not ctrlOverview.mailhost_warning() + if not mail_settings_correct: + widget = self.widgets["mail_me"] + widget.mode = DISPLAY_MODE + widget.value = ["selected"] + widget.label = _( + "label_cant_mail_password_reset", + default="Normally we would offer to send the user an email " + "with instructions to set a password on completion " + "of this form. But this site does not have a valid " + "email setup. You can fix this in the Mail settings.", + ) + widget.terms = None + widget.updateTerms() + + @button.buttonAndHandler(_("label_register", default="Register"), name="register") + def action_join(self, action): + data, errors = self.extractData() + + # extra password validation + self.validate_registration(action, data) + + if action.form.widgets.errors: + self.status = self.formErrorsMessage + return + + self.handle_join_success(data) + + if not self._finishedRegister: + return + + portal_groups = getToolByName(self.context, "portal_groups") + user_id = data["user_id"] + is_zope_manager = getSecurityManager().checkPermission( + ManagePortal, + self.context, + ) + try: + # Add user to the selected group(s) + if data.get("groups", None) is not None: + for groupname in data["groups"]: + group = portal_groups.getGroupById(groupname) + if "Manager" in group.getRoles() and not is_zope_manager: + raise Forbidden + portal_groups.addPrincipalToGroup(user_id, groupname, self.request) + except (AttributeError, ValueError) as err: + IStatusMessage(self.request).addStatusMessage(err, type="error") + return + + IStatusMessage(self.request).addStatusMessage(_("User added."), type="info") + self.request.response.redirect( + self.context.absolute_url() + + "/@@usergroup-userprefs?searchstring=" + + user_id + ) diff --git a/src/plone/app/layout/users/register_form.pt b/src/plone/app/layout/users/register_form.pt new file mode 100644 index 000000000..384761c4e --- /dev/null +++ b/src/plone/app/layout/users/register_form.pt @@ -0,0 +1,61 @@ + + + + + + + + + + + + diff --git a/src/plone/app/layout/users/registered.pt b/src/plone/app/layout/users/registered.pt new file mode 100644 index 000000000..f2b63f7c1 --- /dev/null +++ b/src/plone/app/layout/users/registered.pt @@ -0,0 +1,157 @@ + + + + + + +
+ Cookie authentication has been disabled. +
+ +
+ +

+ Welcome! +

+ +
+ You have been registered. +
+ +
+

+ You will receive an e-mail shortly containing a URL that will allow you to + set your password. When you receive this e-mail, please follow the link to + complete your registration process. NOTE: The password reset URL will expire + on + . If your + password reset request expires, you may use the "Forgot your password" link + to generate another password reset request. +

+ + +

+ Click the button to log in immediately. +

+ + + + +
+ + + + + + + + + + + +
+
+
+ +
+ +
+ + From c8be755a93b064b3631b93645479dd59d18b5e62 Mon Sep 17 00:00:00 2001 From: Thomas Lotze Date: Fri, 29 May 2026 15:33:35 +0200 Subject: [PATCH 5/9] move memberregistration.pt over from plone.app.users --- .../app/layout/users/memberregistration.pt | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/plone/app/layout/users/memberregistration.pt diff --git a/src/plone/app/layout/users/memberregistration.pt b/src/plone/app/layout/users/memberregistration.pt new file mode 100644 index 000000000..a2d21dea3 --- /dev/null +++ b/src/plone/app/layout/users/memberregistration.pt @@ -0,0 +1,57 @@ + + + + + + +
+

Users and Groups

+ +
+ Portal status message +
+ + +
+
+ +
+ +
+ + From 6e47624a170b3f7fc3d87527430dd54fd71e99e9 Mon Sep 17 00:00:00 2001 From: Thomas Lotze Date: Fri, 29 May 2026 16:01:38 +0200 Subject: [PATCH 6/9] move password panel over from plone.app.users --- src/plone/app/layout/users/configure.zcml | 13 ++ src/plone/app/layout/users/passwordpanel.py | 140 ++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 src/plone/app/layout/users/passwordpanel.py diff --git a/src/plone/app/layout/users/configure.zcml b/src/plone/app/layout/users/configure.zcml index ec54c524a..96e43b246 100644 --- a/src/plone/app/layout/users/configure.zcml +++ b/src/plone/app/layout/users/configure.zcml @@ -31,6 +31,13 @@ permission="plone.app.controlpanel.UsersAndGroups" /> + + + + diff --git a/src/plone/app/layout/users/passwordpanel.py b/src/plone/app/layout/users/passwordpanel.py new file mode 100644 index 000000000..b32553d15 --- /dev/null +++ b/src/plone/app/layout/users/passwordpanel.py @@ -0,0 +1,140 @@ +from Acquisition import aq_inner +from plone.app.layout.users.account import AccountPanelForm +from plone.app.users.utils import notifyWidgetActionExecutionError +from plone.base import PloneMessageFactory as _ +from Products.CMFCore.utils import getToolByName +from Products.statusmessages.interfaces import IStatusMessage +from z3c.form import button +from zope import schema +from zope.cachedescriptors.property import Lazy as lazy_property +from zope.interface import Interface + + +class IPasswordSchema(Interface): + """Provide schema for password form""" + + current_password = schema.Password( + title=_("label_current_password", default="Current password"), + description=_("help_current_password", default="Enter your current password."), + # constraint=checkCurrentPassword, + ) + + new_password = schema.Password( + title=_("label_new_password", default="New password"), + description=_("help_new_password", default="Enter your new password."), + ) + + new_password_ctl = schema.Password( + title=_("label_confirm_password", default="Confirm password"), + description=_( + "help_confirm_password", + default="Re-enter the password. Make sure the passwords are identical.", + ), + ) + + +class PasswordPanelAdapter: + def __init__(self, context): + self.context = getToolByName(context, "portal_membership") + + def get_dummy(self): + """We don't actually need to 'get' anything ...""" + return "" + + current_password = property(get_dummy) + + new_password = property(get_dummy) + + new_password_ctl = property(get_dummy) + + +class PasswordPanel(AccountPanelForm): + """Implementation of password reset form that uses z3c.form.""" + + description = _("Change Password") + form_name = _("legend_password_details", default="Password Details") + schema = IPasswordSchema + + @lazy_property + def member(self): + # Only show current username in label + mtool = getToolByName(self.context, "portal_membership") + return mtool.getAuthenticatedMember() + + def updateFields(self): + super().updateFields() + # Change the password description based on PAS Plugin The user needs a + # list of instructions on what kind of password is required. We'll + # reuse password errors as instructions e.g. "Must contain a letter and + # a number". Assume PASPlugin errors are already translated + registration = getToolByName(self.context, "portal_registration") + err_str = registration.testPasswordValidity("") + if err_str: + msg = _("Enter your new password. ${errors}", mapping=dict(errors=err_str)) + self.fields["new_password"].field.description = msg + + def validate_password(self, action, data): + context = aq_inner(self.context) + registration = getToolByName(context, "portal_registration") + membertool = getToolByName(context, "portal_membership") + + # check if password is correct + current_password = data.get("current_password") + if current_password: + if not membertool.testCurrentPassword(current_password): + # add error to current_password widget + err_str = _("Incorrect value for current password") + notifyWidgetActionExecutionError(action, "current_password", err_str) + + # check if passwords are same and valid according to plugin + new_password = data.get("new_password") + new_password_ctl = data.get("new_password_ctl") + if new_password and new_password_ctl: + err_str = registration.testPasswordValidity(new_password, new_password_ctl) + + if err_str: + # add error to new_password widget + notifyWidgetActionExecutionError(action, "new_password", err_str) + notifyWidgetActionExecutionError(action, "new_password_ctl", err_str) + + @button.buttonAndHandler( + _("label_change_password", default="Change Password"), name="reset_passwd" + ) + def action_reset_passwd(self, action): + data, errors = self.extractData() + + # extra password validation + self.validate_password(action, data) + + if action.form.widgets.errors: + self.status = self.formErrorsMessage + return + + membertool = getToolByName(self.context, "portal_membership") + + password = data["new_password"] + + try: + membertool.setPassword(password, None, REQUEST=self.request) + except AttributeError: + failMessage = _( + "While changing your password an AttributeError " + "occurred. This is usually caused by your user " + "being defined outside the portal." + ) + + IStatusMessage(self.request).addStatusMessage(_(failMessage), type="error") + return + + IStatusMessage(self.request).addStatusMessage( + _("Password changed"), type="info" + ) + + # hide inherited Save and Cancel buttons + @button.buttonAndHandler(_("Save"), condition=lambda form: False) + def handleSave(self, action): + pass + + @button.buttonAndHandler(_("Cancel"), condition=lambda form: False) + def cancel(self, action): + pass From 2a81ab8fa164f5ce89a623a003ef5b4ad169e41e Mon Sep 17 00:00:00 2001 From: Thomas Lotze Date: Fri, 29 May 2026 16:17:48 +0200 Subject: [PATCH 7/9] move personal preferences UI over from plone.app.users --- .../app/layout/tests/test_user_data_panel.py | 25 ++++++++ .../app/layout/users/account-configlet.pt | 61 +++++++++++++++++++ src/plone/app/layout/users/configure.zcml | 34 +++++++++++ .../app/layout/users/personalpreferences.py | 49 +++++++++++++++ src/plone/app/layout/users/userdatapanel.py | 55 +++++++++++++++++ 5 files changed, 224 insertions(+) create mode 100644 src/plone/app/layout/tests/test_user_data_panel.py create mode 100644 src/plone/app/layout/users/account-configlet.pt create mode 100644 src/plone/app/layout/users/personalpreferences.py create mode 100644 src/plone/app/layout/users/userdatapanel.py diff --git a/src/plone/app/layout/tests/test_user_data_panel.py b/src/plone/app/layout/tests/test_user_data_panel.py new file mode 100644 index 000000000..782e8fbd3 --- /dev/null +++ b/src/plone/app/layout/tests/test_user_data_panel.py @@ -0,0 +1,25 @@ +from plone.app.layout.users.userdatapanel import UserDataPanel +from plone.app.users.tests.base import BaseTestCase +from zExceptions import NotFound +from zope.i18n import translate + + +class TestUserDataPanel(BaseTestCase): + def test_regression(self): + portal = self.layer["portal"] + request = self.layer["request"] + request.form.update({"userid": "admin"}) + form = UserDataPanel(portal, request) + description = translate(form.description, context=request) + self.assertTrue("admin" in description) + # form can be called without raising exception. + self.assertTrue(form()) + + def test_escape_html(self): + portal = self.layer["portal"] + request = self.layer["request"] + request.form.update({"userid": 'admin'}) + form = UserDataPanel(portal, request) + description = translate(form.description, context=request) + self.assertTrue("