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>
<%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>
+
+<%def name="login_form()">
+
%def>
${self.logo()}
-
+${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>
+<%def name="mobile_body()">
+
+
+
+
+ ${self.mobile_usermenu()}
+
+ ${self.mobile_header()}
+
+ ${self.mobile_page_body()}
+
+ ${self.mobile_footer()}
+
+
+
+
+%def>
+
+<%def name="app_title()">Rattail Demo%def>
+
+<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}%def>
+
+<%def name="page_url()">${request.current_route_url()}%def>
+
+<%def name="page_title()">${self.title()}%def>
+
+<%def name="mobile_header()">
+
+ ${self.mobile_header_link()}
+
${self.global_title()}
+
+%def>
+
+<%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>
+
+<%def name="mobile_usermenu()">
+
+%def>
+
+<%def name="mobile_page_body()">
+
+ % if capture(self.page_title):
+
${self.page_title()}
+ % endif
+
+ ${self.body()}
+
+
+
+
+%def>
+
+<%def name="mobile_footer()">
+
+
powered by ${h.link_to("Rattail", url('mobile.about'))}
+
+%def>
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()}
+
+
+%def>
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>
+<%def name="title()">Home%def>
+
+<%def name="page_title()">%def>
${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%def>
+
+${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)