feat: add view to change current user password
This commit is contained in:
parent
70d13ee1e7
commit
a2ba88ca8f
|
@ -34,6 +34,7 @@ dependencies = [
|
||||||
"pyramid_beaker",
|
"pyramid_beaker",
|
||||||
"pyramid_deform",
|
"pyramid_deform",
|
||||||
"pyramid_mako",
|
"pyramid_mako",
|
||||||
|
"pyramid_tm",
|
||||||
"waitress",
|
"waitress",
|
||||||
"WebHelpers2",
|
"WebHelpers2",
|
||||||
"WuttJamaican[db]>=0.7.0",
|
"WuttJamaican[db]>=0.7.0",
|
||||||
|
|
|
@ -122,6 +122,7 @@ def make_pyramid_config(settings):
|
||||||
pyramid_config.include('pyramid_beaker')
|
pyramid_config.include('pyramid_beaker')
|
||||||
pyramid_config.include('pyramid_deform')
|
pyramid_config.include('pyramid_deform')
|
||||||
pyramid_config.include('pyramid_mako')
|
pyramid_config.include('pyramid_mako')
|
||||||
|
pyramid_config.include('pyramid_tm')
|
||||||
|
|
||||||
return pyramid_config
|
return pyramid_config
|
||||||
|
|
||||||
|
|
|
@ -337,6 +337,16 @@ class Form:
|
||||||
with label and containing a widget.
|
with label and containing a widget.
|
||||||
|
|
||||||
Actual output will depend on the field attributes etc.
|
Actual output will depend on the field attributes etc.
|
||||||
|
Typical output might look like:
|
||||||
|
|
||||||
|
.. code-block:: html
|
||||||
|
|
||||||
|
<b-field label="Foo"
|
||||||
|
horizontal
|
||||||
|
type="is-danger"
|
||||||
|
message="something went wrong!">
|
||||||
|
<!-- widget element(s) -->
|
||||||
|
</b-field>
|
||||||
"""
|
"""
|
||||||
dform = self.get_deform()
|
dform = self.get_deform()
|
||||||
field = dform[fieldname]
|
field = dform[fieldname]
|
||||||
|
@ -354,8 +364,50 @@ class Form:
|
||||||
'label': label,
|
'label': label,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# next we will build array of messages to display..some
|
||||||
|
# fields always show a "helptext" msg, and some may have
|
||||||
|
# validation errors..
|
||||||
|
field_type = None
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
# show errors if present
|
||||||
|
errors = self.get_field_errors(fieldname)
|
||||||
|
if errors:
|
||||||
|
field_type = 'is-danger'
|
||||||
|
messages.extend(errors)
|
||||||
|
|
||||||
|
# ..okay now we can declare the field messages and type
|
||||||
|
if field_type:
|
||||||
|
attrs['type'] = field_type
|
||||||
|
if messages:
|
||||||
|
if len(messages) == 1:
|
||||||
|
msg = messages[0]
|
||||||
|
if msg.startswith('`') and msg.endswith('`'):
|
||||||
|
attrs[':message'] = msg
|
||||||
|
else:
|
||||||
|
attrs['message'] = msg
|
||||||
|
# TODO
|
||||||
|
# else:
|
||||||
|
# # nb. must pass an array as JSON string
|
||||||
|
# attrs[':message'] = '[{}]'.format(', '.join([
|
||||||
|
# "'{}'".format(msg.replace("'", r"\'"))
|
||||||
|
# for msg in messages]))
|
||||||
|
|
||||||
return HTML.tag('b-field', c=[html], **attrs)
|
return HTML.tag('b-field', c=[html], **attrs)
|
||||||
|
|
||||||
|
def get_field_errors(self, field):
|
||||||
|
"""
|
||||||
|
Return a list of error messages for the given field.
|
||||||
|
|
||||||
|
Not useful unless a call to :meth:`validate()` failed.
|
||||||
|
"""
|
||||||
|
dform = self.get_deform()
|
||||||
|
if field in dform:
|
||||||
|
error = dform[field].errormsg
|
||||||
|
if error:
|
||||||
|
return [error]
|
||||||
|
return []
|
||||||
|
|
||||||
def get_vue_field_value(self, field):
|
def get_vue_field_value(self, field):
|
||||||
"""
|
"""
|
||||||
This method returns a JSON string which will be assigned as
|
This method returns a JSON string which will be assigned as
|
||||||
|
@ -400,7 +452,10 @@ class Form:
|
||||||
the :attr:`validated` attribute.
|
the :attr:`validated` attribute.
|
||||||
|
|
||||||
However if the data is not valid, ``False`` is returned, and
|
However if the data is not valid, ``False`` is returned, and
|
||||||
there will be no :attr:`validated` attribute.
|
there will be no :attr:`validated` attribute. In that case
|
||||||
|
you should inspect the form errors to learn/display what went
|
||||||
|
wrong for the user's sake. See also
|
||||||
|
:meth:`get_field_errors()`.
|
||||||
|
|
||||||
:returns: Data dict, or ``False``.
|
:returns: Data dict, or ``False``.
|
||||||
"""
|
"""
|
||||||
|
|
7
src/wuttaweb/templates/auth/change_password.mako
Normal file
7
src/wuttaweb/templates/auth/change_password.mako
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
## -*- coding: utf-8; -*-
|
||||||
|
<%inherit file="/form.mako" />
|
||||||
|
|
||||||
|
<%def name="title()">Change Password</%def>
|
||||||
|
|
||||||
|
|
||||||
|
${parent.body()}
|
|
@ -316,6 +316,7 @@
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
<a class="navbar-link">${request.user}</a>
|
<a class="navbar-link">${request.user}</a>
|
||||||
<div class="navbar-dropdown">
|
<div class="navbar-dropdown">
|
||||||
|
${h.link_to("Change Password", url('change_password'), class_='navbar-item')}
|
||||||
${h.link_to("Logout", url('logout'), class_='navbar-item')}
|
${h.link_to("Logout", url('logout'), class_='navbar-item')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
13
src/wuttaweb/templates/deform/checked_password.pt
Normal file
13
src/wuttaweb/templates/deform/checked_password.pt
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<div tal:define="name name|field.name;
|
||||||
|
vmodel vmodel|'model_'+name;">
|
||||||
|
${field.start_mapping()}
|
||||||
|
<b-input name="${name}"
|
||||||
|
value="${field.widget.redisplay and cstruct or ''}"
|
||||||
|
type="password"
|
||||||
|
placeholder="Password" />
|
||||||
|
<b-input name="${name}-confirm"
|
||||||
|
value="${field.widget.redisplay and confirm or ''}"
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm Password" />
|
||||||
|
${field.end_mapping()}
|
||||||
|
</div>
|
|
@ -1,6 +1,12 @@
|
||||||
## -*- coding: utf-8; -*-
|
## -*- coding: utf-8; -*-
|
||||||
<%inherit file="/page.mako" />
|
<%inherit file="/page.mako" />
|
||||||
|
|
||||||
|
<%def name="page_content()">
|
||||||
|
<div style="margin-top: 2rem; width: 50%;">
|
||||||
|
${form.render_vue_tag()}
|
||||||
|
</div>
|
||||||
|
</%def>
|
||||||
|
|
||||||
<%def name="render_this_page_template()">
|
<%def name="render_this_page_template()">
|
||||||
${parent.render_this_page_template()}
|
${parent.render_this_page_template()}
|
||||||
${form.render_vue_template()}
|
${form.render_vue_template()}
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
% endfor
|
% endfor
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div style="margin-top: 1.5rem; display: flex; gap: 0.5rem; justify-content: end; width: 100%;">
|
<div style="margin-top: 1.5rem; display: flex; gap: 0.5rem; justify-content: ${'end' if form.align_buttons_right else 'start'}; width: 100%; padding-left: 10rem;">
|
||||||
|
|
||||||
% if form.show_button_reset:
|
% if form.show_button_reset:
|
||||||
<b-button native-type="reset">
|
<b-button native-type="reset">
|
||||||
|
|
|
@ -43,7 +43,7 @@ def get_form_data(request):
|
||||||
# https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr
|
# https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr
|
||||||
if not request.POST and (
|
if not request.POST and (
|
||||||
getattr(request, 'is_xhr', False)
|
getattr(request, 'is_xhr', False)
|
||||||
or request.content_type == 'application/json'):
|
or getattr(request, 'content_type', None) == 'application/json'):
|
||||||
return request.json_body
|
return request.json_body
|
||||||
return request.POST
|
return request.POST
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ Auth Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import colander
|
import colander
|
||||||
from deform.widget import TextInputWidget, PasswordWidget
|
from deform.widget import TextInputWidget, PasswordWidget, CheckedPasswordWidget
|
||||||
|
|
||||||
from wuttaweb.views import View
|
from wuttaweb.views import View
|
||||||
from wuttaweb.db import Session
|
from wuttaweb.db import Session
|
||||||
|
@ -45,7 +45,7 @@ class AuthView(View):
|
||||||
Upon successful login, user is redirected to home page.
|
Upon successful login, user is redirected to home page.
|
||||||
|
|
||||||
* route: ``login``
|
* route: ``login``
|
||||||
* template: ``/login.mako``
|
* template: ``/auth/login.mako``
|
||||||
"""
|
"""
|
||||||
auth = self.app.get_auth_handler()
|
auth = self.app.get_auth_handler()
|
||||||
|
|
||||||
|
@ -138,6 +138,66 @@ class AuthView(View):
|
||||||
referrer = self.request.route_url('login')
|
referrer = self.request.route_url('login')
|
||||||
return self.redirect(referrer, headers=headers)
|
return self.redirect(referrer, headers=headers)
|
||||||
|
|
||||||
|
def change_password(self):
|
||||||
|
"""
|
||||||
|
View allowing a user to change their own password.
|
||||||
|
|
||||||
|
This view shows a change-password form, and handles its
|
||||||
|
submission. If successful, user is redirected to home page.
|
||||||
|
|
||||||
|
If current user is not authenticated, no form is shown and
|
||||||
|
user is redirected to home page.
|
||||||
|
|
||||||
|
* route: ``change_password``
|
||||||
|
* template: ``/auth/change_password.mako``
|
||||||
|
"""
|
||||||
|
if not self.request.user:
|
||||||
|
return self.redirect(self.request.route_url('home'))
|
||||||
|
|
||||||
|
form = self.make_form(schema=self.change_password_make_schema(),
|
||||||
|
show_button_reset=True)
|
||||||
|
|
||||||
|
data = form.validate()
|
||||||
|
if data:
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
auth.set_user_password(self.request.user, data['new_password'])
|
||||||
|
self.request.session.flash("Your password has been changed.")
|
||||||
|
# TODO: should use request.get_referrer() instead
|
||||||
|
referrer = self.request.route_url('home')
|
||||||
|
return self.redirect(referrer)
|
||||||
|
|
||||||
|
return {'index_title': str(self.request.user),
|
||||||
|
'form': form}
|
||||||
|
|
||||||
|
def change_password_make_schema(self):
|
||||||
|
schema = colander.Schema()
|
||||||
|
|
||||||
|
schema.add(colander.SchemaNode(
|
||||||
|
colander.String(),
|
||||||
|
name='current_password',
|
||||||
|
widget=PasswordWidget(),
|
||||||
|
validator=self.change_password_validate_current_password))
|
||||||
|
|
||||||
|
schema.add(colander.SchemaNode(
|
||||||
|
colander.String(),
|
||||||
|
name='new_password',
|
||||||
|
widget=CheckedPasswordWidget(),
|
||||||
|
validator=self.change_password_validate_new_password))
|
||||||
|
|
||||||
|
return schema
|
||||||
|
|
||||||
|
def change_password_validate_current_password(self, node, value):
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
user = self.request.user
|
||||||
|
if not auth.check_user_password(user, value):
|
||||||
|
node.raise_invalid("Current password is incorrect.")
|
||||||
|
|
||||||
|
def change_password_validate_new_password(self, node, value):
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
user = self.request.user
|
||||||
|
if auth.check_user_password(user, value):
|
||||||
|
node.raise_invalid("New password must be different from old password.")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def defaults(cls, config):
|
def defaults(cls, config):
|
||||||
cls._auth_defaults(config)
|
cls._auth_defaults(config)
|
||||||
|
@ -149,13 +209,19 @@ class AuthView(View):
|
||||||
config.add_route('login', '/login')
|
config.add_route('login', '/login')
|
||||||
config.add_view(cls, attr='login',
|
config.add_view(cls, attr='login',
|
||||||
route_name='login',
|
route_name='login',
|
||||||
renderer='/login.mako')
|
renderer='/auth/login.mako')
|
||||||
|
|
||||||
# logout
|
# logout
|
||||||
config.add_route('logout', '/logout')
|
config.add_route('logout', '/logout')
|
||||||
config.add_view(cls, attr='logout',
|
config.add_view(cls, attr='logout',
|
||||||
route_name='logout')
|
route_name='logout')
|
||||||
|
|
||||||
|
# change password
|
||||||
|
config.add_route('change_password', '/change-password')
|
||||||
|
config.add_view(cls, attr='change_password',
|
||||||
|
route_name='change_password',
|
||||||
|
renderer='/auth/change_password.mako')
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
def defaults(config, **kwargs):
|
||||||
base = globals()
|
base = globals()
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
# -*- coding: utf-8; -*-
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import colander
|
import colander
|
||||||
import deform
|
import deform
|
||||||
|
@ -179,12 +180,41 @@ class TestForm(TestCase):
|
||||||
|
|
||||||
def test_render_vue_field(self):
|
def test_render_vue_field(self):
|
||||||
self.pyramid_config.include('pyramid_deform')
|
self.pyramid_config.include('pyramid_deform')
|
||||||
|
|
||||||
schema = self.make_schema()
|
schema = self.make_schema()
|
||||||
form = self.make_form(schema=schema)
|
form = self.make_form(schema=schema)
|
||||||
|
dform = form.get_deform()
|
||||||
|
|
||||||
|
# typical
|
||||||
html = form.render_vue_field('foo')
|
html = form.render_vue_field('foo')
|
||||||
self.assertIn('<b-field :horizontal="true" label="Foo">', html)
|
self.assertIn('<b-field :horizontal="true" label="Foo">', html)
|
||||||
self.assertIn('<b-input name="foo"', html)
|
self.assertIn('<b-input name="foo"', html)
|
||||||
|
# nb. no error message
|
||||||
|
self.assertNotIn('message', html)
|
||||||
|
|
||||||
|
# with single "static" error
|
||||||
|
dform['foo'].error = MagicMock(msg="something is wrong")
|
||||||
|
html = form.render_vue_field('foo')
|
||||||
|
self.assertIn(' message="something is wrong"', html)
|
||||||
|
|
||||||
|
# with single "dynamic" error
|
||||||
|
dform['foo'].error = MagicMock(msg="`something is wrong`")
|
||||||
|
html = form.render_vue_field('foo')
|
||||||
|
self.assertIn(':message="`something is wrong`"', html)
|
||||||
|
|
||||||
|
def test_get_field_errors(self):
|
||||||
|
schema = self.make_schema()
|
||||||
|
form = self.make_form(schema=schema)
|
||||||
|
dform = form.get_deform()
|
||||||
|
|
||||||
|
# no error
|
||||||
|
errors = form.get_field_errors('foo')
|
||||||
|
self.assertEqual(len(errors), 0)
|
||||||
|
|
||||||
|
# simple error
|
||||||
|
dform['foo'].error = MagicMock(msg="something is wrong")
|
||||||
|
errors = form.get_field_errors('foo')
|
||||||
|
self.assertEqual(len(errors), 1)
|
||||||
|
self.assertEqual(errors[0], "something is wrong")
|
||||||
|
|
||||||
def test_get_vue_field_value(self):
|
def test_get_vue_field_value(self):
|
||||||
schema = self.make_schema()
|
schema = self.make_schema()
|
||||||
|
|
|
@ -70,3 +70,75 @@ class TestAuthView(TestCase):
|
||||||
redirect = view.logout()
|
redirect = view.logout()
|
||||||
self.request.session.delete.assert_called_once_with()
|
self.request.session.delete.assert_called_once_with()
|
||||||
self.assertIsInstance(redirect, HTTPFound)
|
self.assertIsInstance(redirect, HTTPFound)
|
||||||
|
|
||||||
|
def test_change_password(self):
|
||||||
|
view = mod.AuthView(self.request)
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
|
||||||
|
# unauthenticated user is redirected
|
||||||
|
redirect = view.change_password()
|
||||||
|
self.assertIsInstance(redirect, HTTPFound)
|
||||||
|
|
||||||
|
# now "login" the user, and set initial password
|
||||||
|
self.request.user = self.user
|
||||||
|
auth.set_user_password(self.user, 'foo')
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# view should now return context w/ form
|
||||||
|
context = view.change_password()
|
||||||
|
self.assertIn('form', context)
|
||||||
|
|
||||||
|
# submit valid form, ensure password is changed
|
||||||
|
# (nb. this also would redirect user to home page)
|
||||||
|
self.request.method = 'POST'
|
||||||
|
self.request.POST = {
|
||||||
|
'current_password': 'foo',
|
||||||
|
# nb. new_password requires colander mapping structure
|
||||||
|
'__start__': 'new_password:mapping',
|
||||||
|
'new_password': 'bar',
|
||||||
|
'new_password-confirm': 'bar',
|
||||||
|
'__end__': 'new_password:mapping',
|
||||||
|
}
|
||||||
|
redirect = view.change_password()
|
||||||
|
self.assertIsInstance(redirect, HTTPFound)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.refresh(self.user)
|
||||||
|
self.assertFalse(auth.check_user_password(self.user, 'foo'))
|
||||||
|
self.assertTrue(auth.check_user_password(self.user, 'bar'))
|
||||||
|
|
||||||
|
# at this point 'foo' is the password, now let's submit some
|
||||||
|
# invalid forms and make sure we get back a context w/ form
|
||||||
|
|
||||||
|
# first try empty data
|
||||||
|
self.request.POST = {}
|
||||||
|
context = view.change_password()
|
||||||
|
self.assertIn('form', context)
|
||||||
|
dform = context['form'].get_deform()
|
||||||
|
self.assertEqual(dform['current_password'].errormsg, "Required")
|
||||||
|
self.assertEqual(dform['new_password'].errormsg, "Required")
|
||||||
|
|
||||||
|
# now try bad current password
|
||||||
|
self.request.POST = {
|
||||||
|
'current_password': 'blahblah',
|
||||||
|
'__start__': 'new_password:mapping',
|
||||||
|
'new_password': 'baz',
|
||||||
|
'new_password-confirm': 'baz',
|
||||||
|
'__end__': 'new_password:mapping',
|
||||||
|
}
|
||||||
|
context = view.change_password()
|
||||||
|
self.assertIn('form', context)
|
||||||
|
dform = context['form'].get_deform()
|
||||||
|
self.assertEqual(dform['current_password'].errormsg, "Current password is incorrect.")
|
||||||
|
|
||||||
|
# now try bad new password
|
||||||
|
self.request.POST = {
|
||||||
|
'current_password': 'bar',
|
||||||
|
'__start__': 'new_password:mapping',
|
||||||
|
'new_password': 'bar',
|
||||||
|
'new_password-confirm': 'bar',
|
||||||
|
'__end__': 'new_password:mapping',
|
||||||
|
}
|
||||||
|
context = view.change_password()
|
||||||
|
self.assertIn('form', context)
|
||||||
|
dform = context['form'].get_deform()
|
||||||
|
self.assertEqual(dform['new_password'].errormsg, "New password must be different from old password.")
|
||||||
|
|
Loading…
Reference in a new issue