diff --git a/.gitignore b/.gitignore index 6ba63b2..ce22bec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # ignore sqlite database ordr2.sqlite +ordr2_development.sqlite # ignore test mails mail/ diff --git a/ordr2/schemas/account.py b/ordr2/schemas/account.py index aedb17b..12fea1b 100644 --- a/ordr2/schemas/account.py +++ b/ordr2/schemas/account.py @@ -18,9 +18,7 @@ class RegistrationSchema(CSRFSchema): username = colander.SchemaNode( colander.String(), - widget=deform.widget.TextInputWidget( - readonly=True - ), + widget=deform.widget.TextInputWidget(readonly=True), description='automagically generated for you', validator=deferred_unique_username_validator, ) @@ -60,8 +58,70 @@ class ResetPasswordSchema(CSRFSchema): @classmethod def as_form(cls, request, **override): settings = { - 'buttons': ('Create Account', 'Cancel'), + 'buttons': ('Reset Password', 'Cancel'), 'css_class': 'form reset-password' } settings.update(override) return super().as_form(request, **settings) + + + +class SettingsSectionGeneralSchema(colander.Schema): + ''' Section 'General' for account settings schema ''' + + username = colander.SchemaNode( + colander.String(), + widget=deform.widget.TextInputWidget(readonly=True), + ) + first_name = colander.SchemaNode( + colander.String() + ) + last_name = colander.SchemaNode( + colander.String() + ) + email = colander.SchemaNode( + colander.String(), + validator=deferred_unique_email_validator + ) + role = colander.SchemaNode( + colander.String(), + widget=deform.widget.TextInputWidget(readonly=True), + ) + + +class SettingsSectionChangePasswordSchema(colander.Schema): + ''' Section 'Change Password' for account settings schema ''' + + new_password = colander.SchemaNode( + colander.String(), + widget=deform.widget.CheckedPasswordWidget(), + missing='' + ) + + +class SettingsSectionConfirmChangesSchema(colander.Schema): + ''' Section 'ConfirmChanges' for account settings schema ''' + + password = colander.SchemaNode( + colander.String(), + widget=deform.widget.PasswordWidget(), + validator=deferred_password_validator, + description='Enter your current password to confirm changes' + ) + + +class AccountSettingsSchema(CSRFSchema): + ''' account settings schema ''' + + general = SettingsSectionGeneralSchema() + change_password = SettingsSectionChangePasswordSchema() + confirm_changes = SettingsSectionConfirmChangesSchema() + + @classmethod + def as_form(cls, request, **override): + settings = { + 'buttons': ('Save Settings', 'Cancel'), + 'css_class': 'form account-settings' + } + settings.update(override) + return super().as_form(request, **settings) diff --git a/ordr2/static/style.css b/ordr2/static/style.css index 6b07e77..91737d0 100644 --- a/ordr2/static/style.css +++ b/ordr2/static/style.css @@ -27,7 +27,7 @@ main h1 { margin-top:2rem; } -.item-password > div { +.item-password > div, .item-new_password > div { margin-bottom: 1rem; } @@ -47,3 +47,13 @@ main h1 { .form-group.has-error .form-text:nth-of-type(2) { margin-top: -20px; } + +.form .panel { + margin-bottom:3rem; + } + +.form .panel-heading { + font-size:150%; + margin-bottom:1.5rem; + border-bottom:1px solid #ccc; +} diff --git a/ordr2/templates/account/email_confirmation.jinja2 b/ordr2/templates/account/email_confirmation.jinja2 new file mode 100644 index 0000000..d99c5a0 --- /dev/null +++ b/ordr2/templates/account/email_confirmation.jinja2 @@ -0,0 +1,20 @@ +{% extends "ordr2:templates/layout.jinja2" %} + +{% block title %} Ordr | Account Settings {% endblock title %} + +{% block content %} + +

Verify Your Email Address

+ + {{ macros.show_flash_messages() }} + +

+ An email has been sent to your new email address. +

+

+ Please follow the link in the email to verify your address. +

+ + {{ macros.white_space_column('col-6') }} + +{% endblock content %} diff --git a/ordr2/templates/account/settings.jinja2 b/ordr2/templates/account/settings.jinja2 new file mode 100644 index 0000000..ae9651d --- /dev/null +++ b/ordr2/templates/account/settings.jinja2 @@ -0,0 +1,13 @@ +{% extends "ordr2:templates/layout.jinja2" %} + +{% block title %} Ordr | Account Settings {% endblock title %} + +{% block content %} + +

Account Settings

+ {{ form.render()|safe }} + + {{ macros.white_space_column('col-6') }} + + +{% endblock content %} diff --git a/ordr2/templates/emails/mail_change.jinja2 b/ordr2/templates/emails/mail_change.jinja2 new file mode 100755 index 0000000..73247dc --- /dev/null +++ b/ordr2/templates/emails/mail_change.jinja2 @@ -0,0 +1,25 @@ + + + + + [ordr] verify your email address + + + +

Hi there!

+

+ Please verify your email address for the account "{{ user.username }}" by following this link + {{ request.resource_url(context, data.token.hash) }} +

+

The link will expire on {{ data.token.expires.strftime('%d.%m.%y at %H:%M') }}. +

+ Regards, +
+ ordr +

+

+ Please don't respont to this email! This is an automatically generated notification by the system. + +

+ + diff --git a/ordr2/views/account.py b/ordr2/views/account.py index 6159820..9c20114 100644 --- a/ordr2/views/account.py +++ b/ordr2/views/account.py @@ -7,9 +7,13 @@ from pyramid.security import remember, forget from pyramid.view import view_config from sqlalchemy import or_ -from ordr2.events import CompleteRegistration, PasswordReset +from ordr2.events import ChangedEmail, CompleteRegistration, PasswordReset from ordr2.models.account import User, Role, TokenSubject -from ordr2.schemas.account import RegistrationSchema, ResetPasswordSchema +from ordr2.schemas.account import ( + RegistrationSchema, + ResetPasswordSchema, + AccountSettingsSchema + ) PROPOSED_PASSWORD_LENGTH = 12 @@ -20,6 +24,18 @@ PROPOSED_PASSWORD_LENGTH = 12 @view_config( context='ordr2:resources.account.AccountResource', name='login', + request_method='GET', + permission='login', + renderer='ordr2:templates/account/login.jinja2' + ) +def login_form(context, request): + return {} + + +@view_config( + context='ordr2:resources.account.AccountResource', + name='login', + request_method='POST', permission='login', renderer='ordr2:templates/account/login.jinja2' ) @@ -38,6 +54,11 @@ def login(context, request): request.resource_url(request.root, 'orders'), headers=headers ) + request.session.flash( + 'You entered the wrong username or password', + 'error' + ) + return {} @@ -241,3 +262,107 @@ def reset_password_form_processing(context, request): request.session.flash('Your password was changed', 'ok') return HTTPFound(request.resource_url(context.__parent__, 'login')) + +# account settings +@view_config( + context='ordr2:resources.account.AccountResource', + request_method='GET', + name='settings', + permission='settings', + renderer='ordr2:templates/account/settings.jinja2' + ) +def settings_form(context, request): + prefill = { + 'general': { + 'username': context.model.username, + 'first_name': context.model.first_name, + 'last_name': context.model.last_name, + 'email': context.model.email, + 'role': str(context.model.role) + } + } + form = AccountSettingsSchema.as_form(request) + form.set_appstruct(prefill) + return {'form': form} + + +@view_config( + context='ordr2:resources.account.AccountResource', + request_method='POST', + name='settings', + permission='settings', + renderer='ordr2:templates/account/settings.jinja2' + ) +def settings_form_processing(context, request): + if 'Cancel' in request.POST: + return HTTPFound(request.resource_url(request.root)) + + # validate the form data + form = AccountSettingsSchema.as_form(request) + data = request.POST.items() + try: + appstruct = form.validate(data) + except deform.ValidationFailure as e: + return {'form': form} + + context.model.first_name = appstruct['general']['first_name'] + context.model.last_name = appstruct['general']['last_name'] + + new_password = appstruct['change_password']['new_password'] + if new_password: + context.model.set_password(new_password) + request.session.flash( + 'Password updated sucessfully', + 'success' + ) + # issue a warning on a short password + if len(new_password) < PROPOSED_PASSWORD_LENGTH: + request.session.flash( + 'You should really consider a longer password', + 'warning' + ) + + # email address changed + # the email address is not updated directly, + # but a email sent to confirm the new address + new_email = appstruct['general']['email'] + if new_email != context.model.email: + # create a verify-new-email token and send email + token = context.model.issue_token( + request, + TokenSubject.CHANGE_EMAIL, + {'new_email': new_email} + ) + notification = ChangedEmail( + request, + context.model, + {'token': token}, + send_to=new_email + ) + request.registry.notify(notification) + return HTTPFound(request.resource_url(context, 'verify-new-email')) + + return HTTPFound(request.resource_url(request.root)) + + +@view_config( + context='ordr2:resources.account.AccountResource', + name='verify-new-email', + permission='settings', + renderer='ordr2:templates/account/email_confirmation.jinja2' + ) +def email_confirmation(context, request): + ''' email sent to new address ''' + return {} + + +@view_config( + context='ordr2:resources.account.EmailVerificationToken', + permission='settings' + ) +def email_change_confirmed(context, request): + ''' changed email address is confirmed ''' + context.model.owner.email = context.model.payload['new_email'] + request.dbsession.delete(context.model) + request.session.flash('Email change sucessful', 'success') + return HTTPFound(request.resource_url(request.root, 'login'))