diff --git a/tailbone/static/css/login.css b/tailbone/static/css/login.css index 19efe6b2..aadefc91 100644 --- a/tailbone/static/css/login.css +++ b/tailbone/static/css/login.css @@ -19,13 +19,13 @@ div.field-wrapper { } div.field-wrapper label { - margin: 0px; - padding-top: 3px; text-align: right; - width: 100px; + width: auto; } -div.field-wrapper input { +div.field-wrapper div.field input[type="text"], +div.field-wrapper div.field input[type="password"] { + margin-left: 1em; width: 150px; } diff --git a/tailbone/static/css/mobile.css b/tailbone/static/css/mobile.css new file mode 100644 index 00000000..121213f9 --- /dev/null +++ b/tailbone/static/css/mobile.css @@ -0,0 +1,14 @@ + +/**************************************** + * Global styles for mobile templates + ****************************************/ + +.replacement-header { + display: none; +} + +/* used by login page */ +.error { + color: red; + margin-bottom: 1em; +} diff --git a/tailbone/static/js/tailbone.mobile.js b/tailbone/static/js/tailbone.mobile.js new file mode 100644 index 00000000..3e633849 --- /dev/null +++ b/tailbone/static/js/tailbone.mobile.js @@ -0,0 +1,39 @@ + +/************************************************************ + * + * tailbone.mobile.js + * + * Global logic for mobile app + * + ************************************************************/ + + +$(function() { + + // must init header/footer toolbars since ours are "external" + $('[data-role="header"], [data-role="footer"]').toolbar({theme: 'a'}); +}); + + +$(document).on('pagecontainerchange', function(event, ui) { + + // in some cases (i.e. when no user is logged in) we may want the (external) + // header toolbar button to change between pages. here's how we do that. + // note however that we do this *always* even when not technically needed + var link = $('[data-role="header"] a'); + var newlink = ui.toPage.find('.replacement-header a'); + link.text(newlink.text()); + link.attr('href', newlink.attr('href')); + link.removeClass('ui-icon-home ui-icon-user'); + link.addClass(newlink.attr('class')); +}); + + +$(document).on('pageshow', function() { + + // on login page, auto-focus username + el = $('#username'); + if (el.is(':visible')) { + el.focus(); + } +}); diff --git a/tailbone/templates/about.mako b/tailbone/templates/about.mako index 5f71b73b..d85b7950 100644 --- a/tailbone/templates/about.mako +++ b/tailbone/templates/about.mako @@ -8,3 +8,6 @@ % for name, version in packages.iteritems():

${name} ${version}

% endfor + +
+

Please see rattailproject.org for more info.

diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako index 852175a1..bec67e91 100644 --- a/tailbone/templates/login.mako +++ b/tailbone/templates/login.mako @@ -10,33 +10,31 @@ <%def name="logo()"> - ${h.image(request.static_url('tailbone:static/img/home_logo.png'), "Rattail Logo", id='logo')} + ${h.image(request.static_url('tailbone:static/img/home_logo.png'), "Rattail Logo", id='logo')} + + +<%def name="login_form()"> +
+ ${form.begin(**{'data-ajax': 'false'})} + ${form.hidden('referrer', value=referrer)} + + ## this is used by mobile view + % if error: +
${error}
+ % endif + + ${form.field_div('username', form.text('username'))} + ${form.field_div('password', form.password('password'))} + +
+ ${form.submit('submit', "Login")} + +
+ + ${form.end()} +
${self.logo()} -
- ${h.form('')} - - - % if error: -
${error}
- % endif - -
- - -
- -
- - -
- -
- ${h.submit('submit', "Login")} - -
- - ${h.end_form()} -
+${self.login_form()} diff --git a/tailbone/templates/mobile/base.mako b/tailbone/templates/mobile/base.mako index f62d010a..e956b2c9 100644 --- a/tailbone/templates/mobile/base.mako +++ b/tailbone/templates/mobile/base.mako @@ -3,40 +3,92 @@ - ${self.global_title()} » ${capture(self.title)} + ${self.global_title()} » ${self.title()} ${h.javascript_link('https://code.jquery.com/jquery-1.12.4.min.js')} ${h.javascript_link('https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js')} ${h.stylesheet_link('https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css')} + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.mobile.js'))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/mobile.css'))} % if not request.rattail_config.production(): % endif - - -
- -
- ${h.link_to("Home", url('mobile.home'), class_='ui-btn-left')} - ${h.link_to("About", url('mobile.about'), class_='ui-btn-right')} -

${self.global_title()}

-
- -
- % if capture(self.title): -

${self.title()}

- % endif - ${self.body()} -
- -
-

powered by ${h.link_to("Rattail", url('mobile.about'))}

-
- -
- + ${self.mobile_body()} -<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}Rattail Demo +<%def name="mobile_body()"> + + +
+ + ${self.mobile_usermenu()} + + ${self.mobile_header()} + + ${self.mobile_page_body()} + + ${self.mobile_footer()} + +
+ + + + +<%def name="app_title()">Rattail Demo + +<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()} + +<%def name="page_url()">${request.current_route_url()} + +<%def name="page_title()">${self.title()} + +<%def name="mobile_header()"> +
+ ${self.mobile_header_link()} +

${self.global_title()}

+
+ + +<%def name="mobile_header_link()"> + % if request.user: + ${h.link_to(request.user.get_short_name(), '#usermenu', class_='ui-btn-left ui-btn ui-btn-inline ui-mini ui-corner-all ui-btn-icon-left ui-icon-user')} + % elif request.matched_route.name in ('mobile.login', 'mobile.about'): + ${h.link_to("Home", url('mobile.home'), class_='ui-btn-left ui-btn ui-btn-inline ui-mini ui-corner-all ui-btn-icon-left ui-icon-home')} + % else: + ${h.link_to("Login", url('mobile.login'), class_='ui-btn-left ui-btn ui-btn-inline ui-mini ui-corner-all ui-btn-icon-left ui-icon-user')} + % endif + + +<%def name="mobile_usermenu()"> +
+ +
+ + +<%def name="mobile_page_body()"> +
+ % if capture(self.page_title): +

${self.page_title()}

+ % endif + + ${self.body()} + +
+ ${self.mobile_header_link()} +
+ +
+ + +<%def name="mobile_footer()"> +
+

powered by ${h.link_to("Rattail", url('mobile.about'))}

+
+ diff --git a/tailbone/templates/mobile/base_external_toolbars.mako b/tailbone/templates/mobile/base_external_toolbars.mako new file mode 100644 index 00000000..058a627a --- /dev/null +++ b/tailbone/templates/mobile/base_external_toolbars.mako @@ -0,0 +1,20 @@ +## -*- coding: utf-8 -*- +<%inherit file="tailbone:templates/mobile/base.mako" /> + +<%def name="mobile_body()"> + + + ${self.mobile_header()} + +
+ + ${self.mobile_usermenu()} + + ${self.mobile_page_body()} + +
+ + ${self.mobile_footer()} + + + diff --git a/tailbone/templates/mobile/home.mako b/tailbone/templates/mobile/home.mako index 3d617892..9d87d296 100644 --- a/tailbone/templates/mobile/home.mako +++ b/tailbone/templates/mobile/home.mako @@ -1,7 +1,9 @@ ## -*- coding: utf-8 -*- <%inherit file="/mobile/base.mako" /> -<%def name="title()"> +<%def name="title()">Home + +<%def name="page_title()">
${h.image(request.static_url('tailbone:static/img/home_logo.png'), "Rattail Logo", width='400')} diff --git a/tailbone/templates/mobile/login.mako b/tailbone/templates/mobile/login.mako new file mode 100644 index 00000000..5a5efb9f --- /dev/null +++ b/tailbone/templates/mobile/login.mako @@ -0,0 +1,7 @@ +## -*- coding: utf-8 -*- +<%inherit file="/mobile/base.mako" /> +<%namespace file="/login.mako" import="login_form" /> + +<%def name="title()">Login + +${login_form()} diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 4b1f8e4f..0cb2f60f 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -28,164 +28,181 @@ from __future__ import unicode_literals, absolute_import from rattail.db.auth import authenticate_user, set_user_password -import formencode -from pyramid.httpexceptions import HTTPFound, HTTPForbidden +import formencode as fe +from pyramid.httpexceptions import HTTPForbidden from pyramid.security import remember, forget from pyramid_simpleform import Form -from webhelpers.html import literal -from webhelpers.html import tags +from webhelpers.html import tags, literal +from tailbone import forms from tailbone.db import Session -from tailbone.forms.simpleform import FormRenderer +from tailbone.views import View -def forbidden(request): - """ - Access forbidden view. - - This is triggered whenever access is not allowed for an otherwise - appropriate view. - """ - msg = literal("You do not have permission to do that.") - if not request.authenticated_userid: - msg += literal("  (Perhaps you should %s?)" % - tags.link_to("log in", request.route_url('login'))) - # Store current URL in session, for smarter redirect after login. - request.session['next_url'] = request.current_route_url() - request.session.flash(msg, allow_duplicate=False) - return HTTPFound(location=request.get_referrer()) - - -class UserLogin(formencode.Schema): +class UserLogin(fe.Schema): allow_extra_fields = True filter_extra_fields = True - username = formencode.validators.NotEmpty() - password = formencode.validators.NotEmpty() + username = fe.validators.NotEmpty() + password = fe.validators.NotEmpty() -def login(request): - """ - The login view, responsible for displaying and handling the login form. - """ - referrer = request.get_referrer() - - # Redirect if already logged in. - if request.user: - return HTTPFound(location=referrer) - - form = Form(request, schema=UserLogin) - if form.validate(): - user = authenticate_user(Session(), - form.data['username'], - form.data['password']) - if user: - headers = remember(request, user.uuid) - # Treat URL from session as referrer, if available. - referrer = request.session.pop('next_url', referrer) - return HTTPFound(location=referrer, headers=headers) - request.session.flash("Invalid username or password") - - return {'form': FormRenderer(form), 'referrer': referrer} - - -def logout(request): - """ - View responsible for logging out the current user. - - This deletes/invalidates the current session and then redirects to the - login page. - """ - - request.session.delete() - request.session.invalidate() - headers = forget(request) - referrer = request.get_referrer() - return HTTPFound(location=referrer, headers=headers) - - -def become_root(request): - """ - Elevate the current request to 'root' for full system access. - """ - if not request.is_admin: - raise HTTPForbidden() - request.session['is_root'] = True - request.session.flash("You have been elevated to 'root' and now have full system access") - return HTTPFound(location=request.get_referrer()) - - -def stop_root(request): - """ - Lower the current request from 'root' back to normal access. - """ - if not request.is_admin: - raise HTTPForbidden() - request.session['is_root'] = False - request.session.flash("Your normal system access has been restored") - return HTTPFound(location=request.get_referrer()) - - -class CurrentPasswordCorrect(formencode.validators.FancyValidator): +class CurrentPasswordCorrect(fe.validators.FancyValidator): def _to_python(self, value, state): user = state if not authenticate_user(Session, user.username, value): - raise formencode.Invalid("The password is incorrect.", value, state) + raise fe.Invalid("The password is incorrect.", value, state) return value -class ChangePassword(formencode.Schema): +class ChangePassword(fe.Schema): allow_extra_fields = True filter_extra_fields = True - current_password = formencode.All( - formencode.validators.NotEmpty(), + current_password = fe.All( + fe.validators.NotEmpty(), CurrentPasswordCorrect()) - new_password = formencode.validators.NotEmpty() - confirm_password = formencode.validators.NotEmpty() + new_password = fe.validators.NotEmpty() + confirm_password = fe.validators.NotEmpty() - chained_validators = [formencode.validators.FieldsMatch( + chained_validators = [fe.validators.FieldsMatch( 'new_password', 'confirm_password')] -def change_password(request): - """ - Allows a user to change his or her password. - """ +class AuthenticationView(View): - if not request.user: - return HTTPFound(location=request.route_url('home')) + def forbidden(self): + """ + Access forbidden view. - form = Form(request, schema=ChangePassword, state=request.user) - if form.validate(): - set_user_password(request.user, form.data['new_password']) - return HTTPFound(location=request.get_referrer()) + This is triggered whenever access is not allowed for an otherwise + appropriate view. + """ + msg = literal("You do not have permission to do that.") + if not self.request.authenticated_userid: + msg += literal("  (Perhaps you should %s?)" % + tags.link_to("log in", self.request.route_url('login'))) + # Store current URL in session, for smarter redirect after login. + self.request.session['next_url'] = self.request.current_route_url() + self.request.session.flash(msg, allow_duplicate=False) + return self.redirect(self.request.get_referrer()) - return {'form': FormRenderer(form)} + def login(self, mobile=False): + """ + The login view, responsible for displaying and handling the login form. + """ + home = 'mobile.home' if mobile else 'home' + referrer = self.request.get_referrer(default=self.request.route_url(home)) + # redirect if already logged in + if self.request.user: + if not mobile: + self.request.session.flash("{} is already logged in".format(self.request.user), 'error') + return self.redirect(referrer) + + form = Form(self.request, schema=UserLogin) + context = {'form': forms.FormRenderer(form), 'referrer': referrer, 'dialog': mobile} + if form.validate(): + user = authenticate_user(Session(), + form.data['username'], + form.data['password']) + if user: + # okay now they're truly logged in + headers = remember(self.request, user.uuid) + # Treat URL from session as referrer, if available. + referrer = self.request.session.pop('next_url', referrer) + return self.redirect(referrer, headers=headers) + else: + if mobile: + context['error'] = "Invalid username or password" + else: + self.request.session.flash("Invalid username or password") + return context + + def mobile_login(self): + return self.login(mobile=True) + + def logout(self, mobile=False): + """ + View responsible for logging out the current user. + + This deletes/invalidates the current session and then redirects to the + login page. + """ + self.request.session.delete() + self.request.session.invalidate() + headers = forget(self.request) + login = 'mobile.login' if mobile else 'login' + referrer = self.request.get_referrer(default=self.request.route_url(login)) + return self.redirect(referrer, headers=headers) + + def mobile_logout(self): + return self.logout(mobile=True) + + def change_password(self): + """ + Allows a user to change his or her password. + """ + if not self.request.user: + return self.redirect(self.request.route_url('home')) + + form = Form(self.request, schema=ChangePassword, state=self.request.user) + if form.validate(): + set_user_password(self.request.user, form.data['new_password']) + return self.redirect(self.request.get_referrer()) + + return {'form': forms.FormRenderer(form)} + + def become_root(self): + """ + Elevate the current request to 'root' for full system access. + """ + if not self.request.is_admin: + raise HTTPForbidden() + self.request.session['is_root'] = True + self.request.session.flash("You have been elevated to 'root' and now have full system access") + return self.redirect(self.request.get_referrer()) + + def stop_root(self): + """ + Lower the current request from 'root' back to normal access. + """ + if not self.request.is_admin: + raise HTTPForbidden() + self.request.session['is_root'] = False + self.request.session.flash("Your normal system access has been restored") + return self.redirect(self.request.get_referrer()) + + @classmethod + def defaults(cls, config): + + # forbidden + config.add_forbidden_view(cls, attr='forbidden') + + # login + config.add_route('login', '/login') + config.add_view(cls, attr='login', route_name='login', renderer='/login.mako') + config.add_route('mobile.login', '/mobile/login') + config.add_view(cls, attr='mobile_login', route_name='mobile.login', renderer='/mobile/login.mako') + + # logout + config.add_route('logout', '/logout') + config.add_view(cls, attr='logout', route_name='logout') + config.add_route('mobile.logout', '/mobile/logout') + config.add_view(cls, attr='mobile_logout', route_name='mobile.logout') + + # change password + config.add_route('change_password', '/change-password') + config.add_view(cls, attr='change_password', route_name='change_password', renderer='/change_password.mako') + + # become/stop root + config.add_route('become_root', '/root/yes') + config.add_view(cls, attr='become_root', route_name='become_root') + config.add_route('stop_root', '/root/no') + config.add_view(cls, attr='stop_root', route_name='stop_root') -def add_routes(config): - config.add_route('login', '/login') - config.add_route('logout', '/logout') - config.add_route('become_root', '/root/yes') - config.add_route('stop_root', '/root/no') - config.add_route('change_password', '/change-password') - def includeme(config): - add_routes(config) - - config.add_forbidden_view(forbidden) - - config.add_view(login, route_name='login', - renderer='/login.mako') - - config.add_view(logout, route_name='logout') - - config.add_view(become_root, route_name='become_root') - config.add_view(stop_root, route_name='stop_root') - - config.add_view(change_password, route_name='change_password', - renderer='/change_password.mako') + AuthenticationView.defaults(config)