1
0
Fork 0

feat: add view to change current user password

This commit is contained in:
Lance Edgar 2024-08-05 11:45:00 -05:00
parent 70d13ee1e7
commit a2ba88ca8f
13 changed files with 259 additions and 7 deletions

View file

@ -34,6 +34,7 @@ dependencies = [
"pyramid_beaker",
"pyramid_deform",
"pyramid_mako",
"pyramid_tm",
"waitress",
"WebHelpers2",
"WuttJamaican[db]>=0.7.0",

View file

@ -122,6 +122,7 @@ def make_pyramid_config(settings):
pyramid_config.include('pyramid_beaker')
pyramid_config.include('pyramid_deform')
pyramid_config.include('pyramid_mako')
pyramid_config.include('pyramid_tm')
return pyramid_config

View file

@ -337,6 +337,16 @@ class Form:
with label and containing a widget.
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()
field = dform[fieldname]
@ -354,8 +364,50 @@ class Form:
'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)
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):
"""
This method returns a JSON string which will be assigned as
@ -400,7 +452,10 @@ class Form:
the :attr:`validated` attribute.
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``.
"""

View file

@ -0,0 +1,7 @@
## -*- coding: utf-8; -*-
<%inherit file="/form.mako" />
<%def name="title()">Change Password</%def>
${parent.body()}

View file

@ -316,6 +316,7 @@
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">${request.user}</a>
<div class="navbar-dropdown">
${h.link_to("Change Password", url('change_password'), class_='navbar-item')}
${h.link_to("Logout", url('logout'), class_='navbar-item')}
</div>
</div>

View 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>

View file

@ -1,6 +1,12 @@
## -*- coding: utf-8; -*-
<%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()">
${parent.render_this_page_template()}
${form.render_vue_template()}

View file

@ -9,7 +9,7 @@
% endfor
</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:
<b-button native-type="reset">

View file

@ -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
if not request.POST and (
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.POST

View file

@ -25,7 +25,7 @@ Auth Views
"""
import colander
from deform.widget import TextInputWidget, PasswordWidget
from deform.widget import TextInputWidget, PasswordWidget, CheckedPasswordWidget
from wuttaweb.views import View
from wuttaweb.db import Session
@ -45,7 +45,7 @@ class AuthView(View):
Upon successful login, user is redirected to home page.
* route: ``login``
* template: ``/login.mako``
* template: ``/auth/login.mako``
"""
auth = self.app.get_auth_handler()
@ -138,6 +138,66 @@ class AuthView(View):
referrer = self.request.route_url('login')
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
def defaults(cls, config):
cls._auth_defaults(config)
@ -149,13 +209,19 @@ class AuthView(View):
config.add_route('login', '/login')
config.add_view(cls, attr='login',
route_name='login',
renderer='/login.mako')
renderer='/auth/login.mako')
# logout
config.add_route('logout', '/logout')
config.add_view(cls, attr='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):
base = globals()

View file

@ -1,6 +1,7 @@
# -*- coding: utf-8; -*-
from unittest import TestCase
from unittest.mock import MagicMock
import colander
import deform
@ -179,12 +180,41 @@ class TestForm(TestCase):
def test_render_vue_field(self):
self.pyramid_config.include('pyramid_deform')
schema = self.make_schema()
form = self.make_form(schema=schema)
dform = form.get_deform()
# typical
html = form.render_vue_field('foo')
self.assertIn('<b-field :horizontal="true" label="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):
schema = self.make_schema()

View file

@ -70,3 +70,75 @@ class TestAuthView(TestCase):
redirect = view.logout()
self.request.session.delete.assert_called_once_with()
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.")