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
2 changes: 2 additions & 0 deletions news/3953.breaking.2
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Move ``plone.app.users`` template/views to ``plone.app.layout.views.users``
[tlotze]
4 changes: 4 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -57,6 +58,9 @@
"plone.memoize",
"plone.portlets",
"plone.protect",
"plone.schemaeditor",
"plone.supermodel",
"plone.z3cform",
"Products.CMFEditions >=1.2.2",
"Products.GenericSetup",
"Products.statusmessages",
Expand Down
17 changes: 17 additions & 0 deletions src/plone/app/layout/tests/test_member_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from plone.app.layout.users.membersearch import extractCriteriaFromRequest

import unittest


class TestMemberSearch(unittest.TestCase):
def test_extract_criteria_from_request(self):
data = {
"_authenticator": "ab4731...",
"form.buttons.search": "Search",
"form.widgets.something": "any form value",
"form.widgets.roles-empty-marker": True,
}

result = extractCriteriaFromRequest(data)

self.assertEqual(result, {"something": "any form value"})
25 changes: 25 additions & 0 deletions src/plone/app/layout/tests/test_user_data_panel.py
Original file line number Diff line number Diff line change
@@ -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<script>alert("userid")</script>'})
form = UserDataPanel(portal, request)
description = translate(form.description, context=request)
self.assertTrue("<script>" not in description)
self.assertRaises(NotFound, form)
61 changes: 61 additions & 0 deletions src/plone/app/layout/users/account-configlet.pt
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:i18n="http://xml.zope.org/namespaces/i18n"
xmlns:metal="http://xml.zope.org/namespaces/metal"
xmlns:tal="http://xml.zope.org/namespaces/tal"
lang="en"
metal:use-macro="here/prefs_main_template/macros/master"
xml:lang="en"
i18n:domain="plone"
>

<div metal:fill-slot="prefs_configlet_main">
<header>
<h1 class="documentFirstHeading"
i18n:translate=""
>User Details</h1>
<div>
<a class="mt-n2"
href="${portal_url}/@@usergroup-userprefs"
i18n:translate="label_go_to_users"
>Up to List of Users</a>
</div>
<div metal:use-macro="context/global_statusmessage/macros/portal_message">
Portal status message
</div>
</header>
<article id="user-content">
<div id="content-core">
<p class="lead mt-4">${python:view.description}</p>
<nav class="nav nav-tabs mb-2"
tal:define="
userquery python:view.makeQuery();
"
>
<li class="nav-item">
<a class="nav-link ${python:'active' if getattr(view, 'tab', None)=='userdata' else ''}"
href="${portal_url}/@@user-information${userquery}"
i18n:translate="title_personal_information_form"
>Personal Information</a>
</li>
<li class="nav-item">
<a class="nav-link ${python:'active' if getattr(view, 'tab', None)=='userprefs' else ''}"
href="${portal_url}/@@user-preferences${userquery}"
i18n:translate=""
>Personal Preferences</a>
</li>
<li class="nav-item">
<a class="nav-link"
href="${portal_url}/@@usergroup-usermembership${userquery}"
i18n:translate="label_group_memberships"
>Group Memberships</a>
</li>
</nav>
<metal:b use-macro="context/@@ploneform-macros/titlelessform" />

</div>

</article>

</div>

</html>
46 changes: 46 additions & 0 deletions src/plone/app/layout/users/account-panel.pt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:i18n="http://xml.zope.org/namespaces/i18n"
xmlns:metal="http://xml.zope.org/namespaces/metal"
xmlns:tal="http://xml.zope.org/namespaces/tal"
metal:use-macro="context/main_template/macros/master"
i18n:domain="plone"
>

<metal:b fill-slot="content-title">
<h1 class="documentFirstHeading">${view/label}</h1>
</metal:b>

<metal:b fill-slot="content-description">
<p class="lead">${view/description}</p>
</metal:b>

<metal:b fill-slot="content-core">
<div class="autotabs">
<nav class="autotoc-nav mb-3"
tal:define="
view_actions view/prepareObjectTabs;
"
>
<ul class="nav nav-tabs">
<li class="nav-item"
tal:repeat="action view_actions"
>
<a id="contentview-${action/id}"
href="${action/url}"
tal:define="
selected action/selected|nothing;
"
tal:attributes="
class python:'autotoc-level-1' + (' nav-link active' if selected else ' nav-link');
"
i18n:translate=""
>${action/title}</a>
</li>
</ul>
</nav>

<metal:b use-macro="context/@@ploneform-macros/titlelessform" />
</div>
</metal:b>

</html>
187 changes: 187 additions & 0 deletions src/plone/app/layout/users/account.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading