summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/leap_web_users/.gitkeep0
-rw-r--r--app/assets/javascripts/application.js2
-rw-r--r--app/assets/javascripts/buttons.js39
-rw-r--r--app/assets/javascripts/leap.js7
-rw-r--r--app/assets/javascripts/platform.js93
m---------app/assets/javascripts/srp0
-rw-r--r--app/assets/javascripts/users.js181
-rw-r--r--app/assets/stylesheets/leap.scss63
-rw-r--r--app/assets/stylesheets/leap_web_users/.gitkeep0
-rw-r--r--app/controllers/.gitkeep0
-rw-r--r--app/controllers/account_settings_controller.rb0
-rw-r--r--app/controllers/controller_extension/authentication.rb81
-rw-r--r--app/controllers/controller_extension/token_authentication.rb27
-rw-r--r--app/controllers/keys_controller.rb18
-rw-r--r--app/controllers/sessions_controller.rb28
-rw-r--r--app/controllers/users_base_controller.rb18
-rw-r--r--app/controllers/users_controller.rb69
-rw-r--r--app/controllers/v1/certs_controller.rb20
-rw-r--r--app/controllers/v1/messages_controller.rb24
-rw-r--r--app/controllers/v1/services_controller.rb8
-rw-r--r--app/controllers/v1/sessions_controller.rb45
-rw-r--r--app/controllers/v1/users_controller.rb32
-rw-r--r--app/controllers/webfinger_controller.rb19
-rw-r--r--app/designs/message/by_user_ids_to_show.js7
-rw-r--r--app/designs/message/by_user_ids_to_show_and_created_at.js9
-rw-r--r--app/designs/user/by_created_at_and_one_month_warning_not_sent.js5
-rw-r--r--app/helpers/.gitkeep0
-rw-r--r--app/helpers/core_helper.rb13
-rw-r--r--app/helpers/download_helper.rb33
-rw-r--r--app/helpers/email_aliases_helper.rb11
-rw-r--r--app/helpers/navigation_helper.rb82
-rw-r--r--app/helpers/sessions_helper.rb2
-rw-r--r--app/helpers/users_helper.rb14
-rw-r--r--app/models/account.rb68
-rw-r--r--app/models/anonymous_service_level.rb30
-rw-r--r--app/models/anonymous_user.rb31
-rw-r--r--app/models/client_certificate.rb113
-rw-r--r--app/models/email.rb26
-rw-r--r--app/models/identity.rb142
-rw-r--r--app/models/local_email.rb68
-rw-r--r--app/models/login_format_validation.rb21
-rw-r--r--app/models/message.rb29
-rw-r--r--app/models/pgp_key.rb48
-rw-r--r--app/models/service_level.rb46
-rw-r--r--app/models/session.rb32
-rw-r--r--app/models/token.rb73
-rw-r--r--app/models/user.rb188
-rw-r--r--app/views/.gitkeep0
-rw-r--r--app/views/common/_action_buttons.html.haml11
-rw-r--r--app/views/common/_download_button.html.haml8
-rw-r--r--app/views/common/_home_page_buttons.html.haml8
-rw-r--r--app/views/emails/_email.html.haml6
-rw-r--r--app/views/home/_content.html.haml2
-rw-r--r--app/views/kaminari/_first_page.html.haml9
-rw-r--r--app/views/kaminari/_gap.html.haml8
-rw-r--r--app/views/kaminari/_last_page.html.haml9
-rw-r--r--app/views/kaminari/_next_page.html.haml12
-rw-r--r--app/views/kaminari/_page.html.haml14
-rw-r--r--app/views/kaminari/_paginator.html.haml19
-rw-r--r--app/views/kaminari/_prev_page.html.haml12
-rw-r--r--app/views/layouts/_footer.html.haml4
-rw-r--r--app/views/layouts/_header.html.haml2
-rw-r--r--app/views/layouts/_masthead.html.haml2
-rw-r--r--app/views/layouts/application.html.haml3
-rw-r--r--app/views/pages/pricing.html.haml6
-rw-r--r--app/views/sessions/new.html.haml9
-rw-r--r--app/views/sessions/new.json.erb3
-rw-r--r--app/views/users/_change_password.html.haml21
-rw-r--r--app/views/users/_change_pgp_key.html.haml13
-rw-r--r--app/views/users/_change_service_level.html.haml18
-rw-r--r--app/views/users/_destroy_account.html.haml27
-rw-r--r--app/views/users/_edit.html.haml14
-rw-r--r--app/views/users/_user.html.haml4
-rw-r--r--app/views/users/_warnings.html.haml12
-rw-r--r--app/views/users/edit.html.haml1
-rw-r--r--app/views/users/index.html.haml13
-rw-r--r--app/views/users/new.html.haml21
-rw-r--r--app/views/users/show.html.haml30
-rw-r--r--app/views/v1/sessions/new.json.erb3
-rw-r--r--app/views/webfinger/host_meta.xml.erb11
-rw-r--r--app/views/webfinger/search.xml.erb7
81 files changed, 2135 insertions, 72 deletions
diff --git a/app/assets/images/leap_web_users/.gitkeep b/app/assets/images/leap_web_users/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/app/assets/images/leap_web_users/.gitkeep
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 03a40da..9af373d 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -20,4 +20,4 @@
//= require platform
//= require tickets
//= require users
-//= require_tree .
+//= require buttons
diff --git a/app/assets/javascripts/buttons.js b/app/assets/javascripts/buttons.js
new file mode 100644
index 0000000..7142957
--- /dev/null
+++ b/app/assets/javascripts/buttons.js
@@ -0,0 +1,39 @@
+/*
+ * Buttons.js
+ *
+ * Use bootstrap loading state after submitting a form.
+ *
+ * Some form inputs are validaded before the submission
+ * so triggering loading state on click events is not a
+ * good idea. If the validation fails the errors will
+ * be displayed but the button would be in loading state.
+ *
+ * We used to trigger it based on form submission but
+ * we have a few forms that contain multiple buttons.
+ * So now we mark the buttons as clicked on click and
+ * put the clicked button into loading state on submit.
+ *
+ */
+
+(function() {
+ markAsClicked = function () {
+ var btn = $(this)
+ btn.addClass('clicked')
+ setTimeout(function () {
+ btn.removeClass('clicked')
+ }, 1000)
+ };
+
+ markAsLoading = function(submitEvent) {
+ var form = submitEvent.target;
+ $(form).addClass('submitted')
+ // bootstrap loading state:
+ $(form).find('.btn.clicked[type="submit"]').button('loading');
+ };
+
+ $(document).ready(function() {
+ $('form').submit(markAsLoading);
+ $('.btn[type="submit"]').click(markAsClicked);
+ });
+
+}).call(this);
diff --git a/app/assets/javascripts/leap.js b/app/assets/javascripts/leap.js
new file mode 100644
index 0000000..94e602d
--- /dev/null
+++ b/app/assets/javascripts/leap.js
@@ -0,0 +1,7 @@
+
+//
+// add a bootstrap alert to the page via javascript.
+//
+function alert_message(msg) {
+ $('#messages').append('<div class="alert alert-error"><a class="close" data-dismiss="alert">×</a><span>'+msg+'</span></div>');
+}
diff --git a/app/assets/javascripts/platform.js b/app/assets/javascripts/platform.js
new file mode 100644
index 0000000..108c162
--- /dev/null
+++ b/app/assets/javascripts/platform.js
@@ -0,0 +1,93 @@
+/* Inspired by mozillas platform detection:
+ https://github.com/mozilla/bedrock/tree/master/media/js/base
+*/
+ (function () {
+ 'use strict';
+ function getPlatform() {
+ var ua = navigator.userAgent,
+ pf = navigator.platform;
+ if (/Win(16|9[x58]|NT( [1234]| 5\.0| [^0-9]|[^ -]|$))/.test(ua) ||
+ /Windows ([MC]E|9[x58]|3\.1|4\.10|NT( [1234]| 5\.0| [^0-9]|[^ ]|$))/.test(ua) ||
+ /Windows_95/.test(ua)) {
+ /**
+ * Officially unsupported platforms are Windows 95, 98, ME, NT 4.x, 2000
+ * These regular expressions match:
+ * - Win16
+ * - Win9x
+ * - Win95
+ * - Win98
+ * - WinNT (not followed by version or followed by version <= 5)
+ * - Windows ME
+ * - Windows CE
+ * - Windows 9x
+ * - Windows 95
+ * - Windows 98
+ * - Windows 3.1
+ * - Windows 4.10
+ * - Windows NT (not followed by version or followed by version <= 5)
+ * - Windows_95
+ */
+ return 'oldwin';
+ }
+ if (ua.indexOf("MSIE 6.0") !== -1 &&
+ ua.indexOf("Windows NT 5.1") !== -1 &&
+ ua.indexOf("SV1") === -1) {
+ // Windows XP SP1
+ return 'oldwin';
+ }
+ if (pf.indexOf("Win32") !== -1 ||
+ pf.indexOf("Win64") !== -1) {
+ return 'windows';
+ }
+ if (/android/i.test(ua)) {
+ return 'android';
+ }
+ if (/armv[6-7]l/.test(pf)) {
+ return 'android';
+ }
+ if (pf.indexOf("Linux") !== -1) {
+ return 'linux';
+ //if (pf.indexOf("64") !== -1) {
+ // return 'linux64';
+ //} else {
+ // return 'linux32';
+ //}
+ }
+ if (pf.indexOf("MacPPC") !== -1) {
+ return 'oldmac';
+ }
+ if (/Mac OS X 10.[0-5]/.test(ua)) {
+ return 'oldmac';
+ }
+ if (pf.indexOf('iPhone') !== -1 ||
+ pf.indexOf('iPad') !== -1 ||
+ pf.indexOf('iPod') !== -1 ) {
+ return 'ios';
+ }
+ if (ua.indexOf("Mac OS X") !== -1) {
+ return 'osx';
+ }
+ if (ua.indexOf("MSIE 5.2") !== -1) {
+ return 'oldmac';
+ }
+ if (pf.indexOf("Mac") !== -1) {
+ return 'oldmac';
+ }
+ if (navigator.platform === '' &&
+ navigator.userAgent.indexOf("Firefox") !== -1 &&
+ navigator.userAgent.indexOf("Mobile") !== -1) {
+ return 'fxos';
+ }
+
+ return 'other';
+ }
+ (function () {
+ // Immediately set the platform classname on the html-element
+ // to avoid lots of flickering
+ var h = document.documentElement;
+ window.site = {
+ platform : getPlatform()
+ };
+ h.className = window.site.platform;
+ })();
+ })();
diff --git a/app/assets/javascripts/srp b/app/assets/javascripts/srp
new file mode 160000
+Subproject 8f33d32d40b1e21ae7fb9a92c78a275422af421
diff --git a/app/assets/javascripts/users.js b/app/assets/javascripts/users.js
new file mode 100644
index 0000000..e6c2fcc
--- /dev/null
+++ b/app/assets/javascripts/users.js
@@ -0,0 +1,181 @@
+(function() {
+
+
+ var settings = {
+ "error_class":"help-inline",
+ "error_tag":"span",
+ "wrapper_error_class":"error",
+ "wrapper_tag":"div",
+ "wrapper_class":"control-group"
+ }
+
+ //
+ // LOCAL FUNCTIONS
+ //
+
+ var poll_users,
+ prevent_default,
+ clear_errors,
+ clear_field_errors,
+ validate_password_confirmation,
+ validate_password_length,
+ update_user;
+
+ prevent_default = function(event) {
+ return event.preventDefault();
+ };
+
+ poll_users = function(query, process) {
+ return $.get("/1/users.json", {
+ query: query
+ }).done(process);
+ };
+
+ clear_errors = function() {
+ return $('#messages').empty();
+ };
+
+ clear_field_errors = function() {
+ return $(settings.error_tag + '.' + settings.error_class).remove();
+ };
+
+ update_user = function(submitEvent) {
+ var form = submitEvent.target;
+ var token = form.dataset.token;
+ var url = form.action;
+ var req = $.ajax({
+ url: url,
+ type: 'PUT',
+ headers: { Authorization: 'Token token="' + token + '"' },
+ data: $(form).serialize()
+ });
+ req.done( function() {
+ $(form).find('.btn[type="submit"]').button('reset');
+ });
+ };
+
+ validate_password_confirmation = function(submitEvent) {
+ var form = submitEvent.target;
+ var password = $(form).find('input#srp_password').val();
+ var confirmation = $(form).find('input#srp_password_confirmation').val();
+ if (password === confirmation) {
+ return true;
+ }
+ else {
+ displayFieldError("password_confirmation", "does not match.");
+ submitEvent.stopImmediatePropagation()
+ return false;
+ }
+ };
+
+ validate_password_length = function(submitEvent) {
+ var form = submitEvent.target;
+ var password = $(form).find('input#srp_password').val();
+ if (password.length > 7) {
+ return true;
+ }
+ else {
+ displayFieldError("password", "needs to be at least 8 characters long.");
+ submitEvent.stopImmediatePropagation()
+ return false;
+ }
+ };
+
+ //
+ // PUBLIC FUNCTIONS
+ //
+
+ srp.session = new srp.Session();
+
+ srp.signedUp = function() {
+ return srp.login();
+ };
+
+ srp.loggedIn = function() {
+ return window.location = '/';
+ };
+
+ srp.updated = function() {
+ return window.location = '/users/' + srp.session.id();
+ };
+
+ //
+ // if a json request returns an error, this function gets called and
+ // decorates the appropriate fields with the error messages.
+ //
+ srp.error = function(message) {
+ clear_errors();
+ var errors = extractErrors(message);
+ displayErrors(errors);
+ $('.btn[type="submit"]').button('reset');
+ }
+
+ function extractErrors(message) {
+ if ($.isPlainObject(message) && message.errors) {
+ return message.errors;
+ } else {
+ return {
+ base: (message.error || JSON.stringify(message))
+ };
+ }
+ }
+
+ function displayErrors(errors) {
+ for (var field in errors) {
+ var error = errors[field];
+ if (field === 'base') {
+ alert_message(error);
+ } else {
+ displayFieldError(field, error);
+ }
+ }
+ }
+
+ function displayFieldError(field, error) {
+ var message = $.isArray(error) ? error[0] : error;
+ var element = $('form input[name$="[' + field + ']"]');
+ if (element) {
+ addError(element, settings, message);
+ }
+ };
+
+ function addError(element, settings, message) {
+ var errorElement, wrapper;
+
+ errorElement = element.parent().find("" + settings.error_tag + "." + settings.error_class);
+ wrapper = element.closest(settings.wrapper_tag + '.' + settings.wrapper_class);
+ if (errorElement[0] == null) {
+ errorElement = $("<" + settings.error_tag + "/>", {
+ "class": settings.error_class,
+ text: message
+ });
+ element.after(errorElement);
+ }
+ wrapper.addClass(settings.wrapper_error_class);
+ return errorElement.text(message);
+ }
+
+//
+// INIT
+//
+
+ $(document).ready(function() {
+ $('.hidden.js-show').removeClass('hidden');
+ $('.js-show').show();
+ $('#new_user').submit(prevent_default);
+ $('#new_user').submit(clear_field_errors);
+ $('#new_user').submit(validate_password_length);
+ $('#new_user').submit(validate_password_confirmation);
+ $('#new_user').submit(srp.signup);
+ $('#new_session').submit(prevent_default);
+ $('#new_session').submit(srp.login);
+ $('#update_login_and_password').submit(prevent_default);
+ $('#update_login_and_password').submit(srp.update);
+ $('#update_pgp_key').submit(prevent_default);
+ $('#update_pgp_key').submit(update_user);
+ return $('#user-typeahead').typeahead({
+ source: poll_users
+ });
+ });
+
+}).call(this);
diff --git a/app/assets/stylesheets/leap.scss b/app/assets/stylesheets/leap.scss
index 4c0dfe3..77104e5 100644
--- a/app/assets/stylesheets/leap.scss
+++ b/app/assets/stylesheets/leap.scss
@@ -44,65 +44,6 @@
}
//
-// OS specific
-//
-
-.os-android {
- display: none !important;
-}
-
-html.android .os-android {
- display: inherit !important;
-}
-
-.os-linux {
- display: none !important;
-}
-
-html.linux .os-linux {
- display: inherit !important;
-}
-
-// .os-linux32 {
-// display: none !important;
-// }
-// html.linux32 .os-linux32 {
-// display: inherit !important;
-// }
-// .os-linux64 {
-// display: none !important;
-// }
-// html.linux64 .os-linux64 {
-// display: inherit !important;
-// }
-
-.os-windows {
- display: none !important;
-}
-
-html.windows .os-windows {
- display: inherit !important;
-}
-
-.os-osx {
- display: none !important;
-}
-
-html.osx .os-osx {
- display: inherit !important;
-}
-
-.os-other {
- display: none !important;
-}
-
-html.oldmac, html.oldwin, html.ios, html.fxos, html.other {
- .os-other {
- display: inherit !important;
- }
-}
-
-//
// ICONS
//
@@ -230,14 +171,14 @@ input, textarea {
}
.download {
a.btn {
- width: 14em;
+ width: 15em;
+ font-weight: bold;
small {
font-weight: normal;
}
}
}
a.btn {
- font-weight: bold;
width: 11em;
margin: 10px auto;
display: block;
diff --git a/app/assets/stylesheets/leap_web_users/.gitkeep b/app/assets/stylesheets/leap_web_users/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/app/assets/stylesheets/leap_web_users/.gitkeep
diff --git a/app/controllers/.gitkeep b/app/controllers/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/app/controllers/.gitkeep
diff --git a/app/controllers/account_settings_controller.rb b/app/controllers/account_settings_controller.rb
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/app/controllers/account_settings_controller.rb
diff --git a/app/controllers/controller_extension/authentication.rb b/app/controllers/controller_extension/authentication.rb
new file mode 100644
index 0000000..1f73f38
--- /dev/null
+++ b/app/controllers/controller_extension/authentication.rb
@@ -0,0 +1,81 @@
+module ControllerExtension::Authentication
+ extend ActiveSupport::Concern
+
+ private
+
+ included do
+ helper_method :current_user, :logged_in?, :admin?
+ end
+
+ def current_user
+ @current_user ||= token_authenticate || warden.user || anonymous
+ end
+
+ def logged_in?
+ current_user.is_a? User
+ end
+
+ def require_login
+ access_denied unless logged_in?
+ end
+
+ # some actions only make sense if you are not logged in yet.
+ # (login, signup). If a user tries to perform these they will
+ # be redirected to their dashboard.
+ def redirect_if_logged_in
+ redirect_to home_url if logged_in?
+ end
+
+ def access_denied
+ respond_to do |format|
+ format.html do
+ if logged_in?
+ redirect_to home_url, :alert => t(:not_authorized)
+ else
+ redirect_to login_url, :alert => t(:not_authorized_login)
+ end
+ end
+ format.json do
+ render :json => {'error' => t(:not_authorized)}, status: :unprocessable_entity
+ end
+ end
+ end
+
+ def admin?
+ current_user.is_admin?
+ end
+
+ def require_admin
+ access_denied unless admin?
+ end
+
+ def authentication_errors
+ return unless attempted_login?
+ errors = get_warden_errors
+ errors.inject({}) do |translated,err|
+ translated[err.first] = I18n.t(err.last)
+ translated
+ end
+ end
+
+ def get_warden_errors
+ if strategy = warden.winning_strategy
+ message = strategy.message
+ # in case we get back the default message to fail!
+ message.respond_to?(:inject) ? message : { base: message }
+ else
+ { login: :all_strategies_failed }
+ end
+ end
+
+ def attempted_login?
+ request.env['warden.options'] &&
+ request.env['warden.options'][:attempted_path]
+ end
+
+ protected
+
+ def anonymous
+ AnonymousUser.new
+ end
+end
diff --git a/app/controllers/controller_extension/token_authentication.rb b/app/controllers/controller_extension/token_authentication.rb
new file mode 100644
index 0000000..6e0a6ce
--- /dev/null
+++ b/app/controllers/controller_extension/token_authentication.rb
@@ -0,0 +1,27 @@
+module ControllerExtension::TokenAuthentication
+ extend ActiveSupport::Concern
+
+ def token
+ @token ||= authenticate_with_http_token do |token_id, options|
+ Token.find(token_id)
+ end
+ end
+
+ def token_authenticate
+ @token_authenticated ||= token.authenticate if token
+ end
+
+ def require_token
+ access_denied unless token_authenticate
+ end
+
+ def logout
+ super
+ clear_token
+ end
+
+ def clear_token
+ token.destroy if token
+ end
+end
+
diff --git a/app/controllers/keys_controller.rb b/app/controllers/keys_controller.rb
new file mode 100644
index 0000000..fb28901
--- /dev/null
+++ b/app/controllers/keys_controller.rb
@@ -0,0 +1,18 @@
+class KeysController < ApplicationController
+
+ #
+ # Render the user's key as plain text, without a layout.
+ #
+ # We will show blank page if user doesn't have key (which shouldn't generally occur)
+ # and a 404 error if user doesn't exist
+ #
+ def show
+ user = User.find_by_login(params[:login])
+ if user
+ render text: user.public_key, content_type: 'text/text'
+ else
+ raise ActionController::RoutingError.new('Not Found')
+ end
+ end
+
+end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
new file mode 100644
index 0000000..8919a4d
--- /dev/null
+++ b/app/controllers/sessions_controller.rb
@@ -0,0 +1,28 @@
+class SessionsController < ApplicationController
+
+ before_filter :redirect_if_logged_in, :only => [:new]
+
+ def new
+ @session = Session.new
+ if authentication_errors
+ @errors = authentication_errors
+ render :status => 422
+ end
+ end
+
+ def destroy
+ logout
+ redirect_to home_url
+ end
+
+ #
+ # this is a bad hack, but user_url(user) is not available
+ # also, this doesn't work because the redirect happens as a PUT. no idea why.
+ #
+ #Warden::Manager.after_authentication do |user, auth, opts|
+ # response = Rack::Response.new
+ # response.redirect "/users/#{user.id}"
+ # throw :warden, response.finish
+ #end
+
+end
diff --git a/app/controllers/users_base_controller.rb b/app/controllers/users_base_controller.rb
new file mode 100644
index 0000000..9becf0d
--- /dev/null
+++ b/app/controllers/users_base_controller.rb
@@ -0,0 +1,18 @@
+#
+# common base class for all user related controllers
+#
+
+class UsersBaseController < ApplicationController
+
+ protected
+
+ def fetch_user
+ @user = User.find(params[:user_id] || params[:id])
+ if !@user && admin?
+ redirect_to users_url, :alert => t(:no_such_thing, :thing => 'user')
+ elsif !admin? && @user != current_user
+ access_denied
+ end
+ end
+
+end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
new file mode 100644
index 0000000..c8e09b6
--- /dev/null
+++ b/app/controllers/users_controller.rb
@@ -0,0 +1,69 @@
+#
+# This is an HTML-only controller. For the JSON-only controller, see v1/users_controller.rb
+#
+
+class UsersController < UsersBaseController
+
+ before_filter :require_login, :except => [:new]
+ before_filter :redirect_if_logged_in, :only => [:new]
+ before_filter :require_admin, :only => [:index, :deactivate, :enable]
+ before_filter :fetch_user, :only => [:show, :edit, :update, :destroy, :deactivate, :enable]
+
+ respond_to :html
+
+ def index
+ if params[:query]
+ if @user = User.find_by_login(params[:query])
+ redirect_to @user
+ return
+ else
+ @users = User.by_login.startkey(params[:query]).endkey(params[:query].succ)
+ end
+ else
+ @users = User.by_created_at.descending
+ end
+ @users = @users.limit(100)
+ end
+
+ def new
+ @user = User.new
+ end
+
+ def show
+ end
+
+ def edit
+ end
+
+ ## added so updating service level works, but not sure we will actually want this. also not sure that this is place to prevent user from updating own effective service level, but here as placeholder:
+ def update
+ @user.update_attributes(params[:user]) unless (!admin? and params[:user][:effective_service_level])
+ respond_with @user
+ end
+
+ def deactivate
+ @user.enabled = false
+ @user.save
+ respond_with @user
+ end
+
+ def enable
+ @user.enabled = true
+ @user.save
+ respond_with @user
+ end
+
+ def destroy
+ @user.account.destroy
+ flash[:notice] = I18n.t(:account_destroyed)
+ # admins can destroy other users
+ if @user != current_user
+ redirect_to users_url
+ else
+ # let's remove the invalid session
+ logout
+ redirect_to bye_url
+ end
+ end
+
+end
diff --git a/app/controllers/v1/certs_controller.rb b/app/controllers/v1/certs_controller.rb
new file mode 100644
index 0000000..73409ef
--- /dev/null
+++ b/app/controllers/v1/certs_controller.rb
@@ -0,0 +1,20 @@
+class V1::CertsController < ApplicationController
+
+ before_filter :require_login, :unless => :anonymous_certs_allowed?
+
+ # GET /cert
+ def show
+ @cert = ClientCertificate.new(:prefix => service_level.cert_prefix)
+ render text: @cert.to_s, content_type: 'text/plain'
+ end
+
+ protected
+
+ def anonymous_certs_allowed?
+ APP_CONFIG[:allow_anonymous_certs]
+ end
+
+ def service_level
+ current_user.effective_service_level
+ end
+end
diff --git a/app/controllers/v1/messages_controller.rb b/app/controllers/v1/messages_controller.rb
new file mode 100644
index 0000000..85156b7
--- /dev/null
+++ b/app/controllers/v1/messages_controller.rb
@@ -0,0 +1,24 @@
+module V1
+ class MessagesController < ApplicationController
+
+ skip_before_filter :verify_authenticity_token
+ before_filter :require_token
+
+ respond_to :json
+
+ def index
+ render json: current_user.messages
+ end
+
+ def update
+ if message = Message.find(params[:id])
+ message.mark_as_read_by(current_user)
+ message.save
+ render json: true
+ else
+ render json: false
+ end
+ end
+
+ end
+end
diff --git a/app/controllers/v1/services_controller.rb b/app/controllers/v1/services_controller.rb
new file mode 100644
index 0000000..594940e
--- /dev/null
+++ b/app/controllers/v1/services_controller.rb
@@ -0,0 +1,8 @@
+class V1::ServicesController < ApplicationController
+
+ respond_to :json
+
+ def show
+ respond_with current_user.effective_service_level
+ end
+end
diff --git a/app/controllers/v1/sessions_controller.rb b/app/controllers/v1/sessions_controller.rb
new file mode 100644
index 0000000..d88fcdc
--- /dev/null
+++ b/app/controllers/v1/sessions_controller.rb
@@ -0,0 +1,45 @@
+module V1
+ class SessionsController < ApplicationController
+
+ skip_before_filter :verify_authenticity_token
+ before_filter :require_token, only: :destroy
+
+ def new
+ @session = Session.new
+ if authentication_errors
+ @errors = authentication_errors
+ render :status => 422
+ end
+ end
+
+ def create
+ logout if logged_in?
+ if params['A']
+ authenticate!
+ else
+ @user = User.find_by_login(params['login'])
+ render :json => {salt: @user.salt}
+ end
+ end
+
+ def update
+ authenticate!
+ @token = Token.create(:user_id => current_user.id)
+ session[:token] = @token.id
+ render :json => login_response
+ end
+
+ def destroy
+ logout
+ head :no_content
+ end
+
+ protected
+
+ def login_response
+ handshake = session.delete(:handshake) || {}
+ handshake.to_hash.merge(:id => current_user.id, :token => @token.to_s)
+ end
+
+ end
+end
diff --git a/app/controllers/v1/users_controller.rb b/app/controllers/v1/users_controller.rb
new file mode 100644
index 0000000..8897d01
--- /dev/null
+++ b/app/controllers/v1/users_controller.rb
@@ -0,0 +1,32 @@
+module V1
+ class UsersController < UsersBaseController
+
+ skip_before_filter :verify_authenticity_token
+ before_filter :fetch_user, :only => [:update]
+ before_filter :require_admin, :only => [:index]
+ before_filter :require_token, :only => [:update]
+
+ respond_to :json
+
+ # used for autocomplete for admins in the web ui
+ def index
+ if params[:query]
+ @users = User.by_login.startkey(params[:query]).endkey(params[:query].succ)
+ respond_with @users.map(&:login).sort
+ else
+ render :json => {'error' => 'query required', 'status' => :unprocessable_entity}
+ end
+ end
+
+ def create
+ @user = Account.create(params[:user])
+ respond_with @user # return ID instead?
+ end
+
+ def update
+ @user.account.update params[:user]
+ respond_with @user
+ end
+
+ end
+end
diff --git a/app/controllers/webfinger_controller.rb b/app/controllers/webfinger_controller.rb
new file mode 100644
index 0000000..8872802
--- /dev/null
+++ b/app/controllers/webfinger_controller.rb
@@ -0,0 +1,19 @@
+class WebfingerController < ApplicationController
+
+ respond_to :xml, :json
+ layout false
+
+ def host_meta
+ @host_meta = Webfinger::HostMetaPresenter.new(request)
+ respond_with @host_meta
+ end
+
+ def search
+ username = params[:q].split('@')[0].to_s.downcase
+ user = User.find_by_login(username)
+ raise RECORD_NOT_FOUND, 'User not found' unless user.present?
+ @presenter = Webfinger::UserPresenter.new(user, request)
+ respond_with @presenter
+ end
+
+end
diff --git a/app/designs/message/by_user_ids_to_show.js b/app/designs/message/by_user_ids_to_show.js
new file mode 100644
index 0000000..e33566b
--- /dev/null
+++ b/app/designs/message/by_user_ids_to_show.js
@@ -0,0 +1,7 @@
+function (doc) {
+ if (doc.type === 'Message' && doc.user_ids_to_show && Array.isArray(doc.user_ids_to_show)) {
+ doc.user_ids_to_show.forEach(function (userId) {
+ emit(userId, 1);
+ });
+ }
+}
diff --git a/app/designs/message/by_user_ids_to_show_and_created_at.js b/app/designs/message/by_user_ids_to_show_and_created_at.js
new file mode 100644
index 0000000..54e4604
--- /dev/null
+++ b/app/designs/message/by_user_ids_to_show_and_created_at.js
@@ -0,0 +1,9 @@
+// not using at moment
+// call with something like Message.by_user_ids_to_show_and_created_at.startkey([user_id, start_date]).endkey([user_id,end_date])
+function (doc) {
+ if (doc.type === 'Message' && doc.user_ids_to_show && Array.isArray(doc.user_ids_to_show)) {
+ doc.user_ids_to_show.forEach(function (userId) {
+ emit([userId, doc.created_at], 1);
+ });
+ }
+}
diff --git a/app/designs/user/by_created_at_and_one_month_warning_not_sent.js b/app/designs/user/by_created_at_and_one_month_warning_not_sent.js
new file mode 100644
index 0000000..53a95de
--- /dev/null
+++ b/app/designs/user/by_created_at_and_one_month_warning_not_sent.js
@@ -0,0 +1,5 @@
+function (doc) {
+ if ((doc['type'] == 'User') && (doc['created_at'] != null) && (doc['one_month_warning_sent'] == null)) {
+ emit(doc['created_at'], 1);
+ }
+}
diff --git a/app/helpers/.gitkeep b/app/helpers/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/app/helpers/.gitkeep
diff --git a/app/helpers/core_helper.rb b/app/helpers/core_helper.rb
new file mode 100644
index 0000000..4126906
--- /dev/null
+++ b/app/helpers/core_helper.rb
@@ -0,0 +1,13 @@
+#
+# Misc. helpers needed throughout.
+#
+module CoreHelper
+
+ #
+ # insert common buttons (download, login, etc)
+ #
+ def home_page_buttons
+ render 'common/home_page_buttons'
+ end
+
+end
diff --git a/app/helpers/download_helper.rb b/app/helpers/download_helper.rb
new file mode 100644
index 0000000..ee0fe73
--- /dev/null
+++ b/app/helpers/download_helper.rb
@@ -0,0 +1,33 @@
+module DownloadHelper
+
+ def alternative_client_links(os = nil)
+ alternative_clients(os).map do |client|
+ link_to(I18n.t("os."+client), client_download_url(client))
+ end
+ end
+
+ def alternative_clients(os = nil)
+ available_clients - [os]
+ end
+
+ def client_download_url(os = nil)
+ client_download_domain + client_download_path(os)
+ end
+
+ def client_download_path(os)
+ download_paths[os.to_s] || download_paths['other'] || ''
+ end
+
+ def available_clients
+ APP_CONFIG[:available_clients] || []
+ end
+
+ def client_download_domain
+ APP_CONFIG[:client_download_domain] || ''
+ end
+
+ def download_paths
+ APP_CONFIG[:download_paths] || {}
+ end
+
+end
diff --git a/app/helpers/email_aliases_helper.rb b/app/helpers/email_aliases_helper.rb
new file mode 100644
index 0000000..b56b068
--- /dev/null
+++ b/app/helpers/email_aliases_helper.rb
@@ -0,0 +1,11 @@
+module EmailAliasesHelper
+
+ def email_alias_form(options = {})
+ simple_form_for [@user, EmailAlias.new()],
+ :html => {:class => "form-horizontal email-alias form"},
+ :validate => true do |f|
+ yield f
+ end
+ end
+
+end
diff --git a/app/helpers/navigation_helper.rb b/app/helpers/navigation_helper.rb
new file mode 100644
index 0000000..19cb934
--- /dev/null
+++ b/app/helpers/navigation_helper.rb
@@ -0,0 +1,82 @@
+module NavigationHelper
+
+ #
+ # used to create a side navigation link.
+ #
+ # Signature is the same as link_to, except it accepts an :active value in the html_options
+ #
+ def link_to_navigation(*args)
+ if args.last.is_a? Hash
+ html_options = args.pop.dup
+ active_class = html_options.delete(:active) ? 'active' : nil
+ html_options[:class] = [html_options[:class], active_class].join(' ')
+ args << html_options
+ else
+ active_class = nil
+ end
+ content_tag :li, :class => active_class do
+ link_to(*args)
+ end
+ end
+
+ #
+ # returns true if params[:action] matches one of the args.
+ #
+ def action?(*actions)
+ actions.detect do |action|
+ if action.is_a? String
+ action == action_string
+ elsif action.is_a? Symbol
+ if action == :none
+ action_string == nil
+ else
+ action == action_symbol
+ end
+ end
+ end
+ end
+
+ #
+ # returns true if params[:controller] matches one of the args.
+ #
+ # for example:
+ # controller?(:me, :home)
+ # controller?('groups/') <-- matches any controller in namespace 'groups'
+ #
+ def controller?(*controllers)
+ controllers.each do |cntr|
+ if cntr.is_a? String
+ if cntr.ends_with?('/')
+ return true if controller_string.starts_with?(cntr.chop)
+ end
+ return true if cntr == controller_string
+ elsif cntr.is_a? Symbol
+ return true if cntr == controller_symbol
+ end
+ end
+ return false
+ end
+
+ private
+
+ def controller_string
+ @controller_string ||= params[:controller].to_s.gsub(/^\//, '')
+ end
+
+ def controller_symbol
+ @controller_symbol ||= params[:controller].gsub(/^\//,'').gsub('/','_').to_sym
+ end
+
+ def action_string
+ params[:action]
+ end
+
+ def action_symbol
+ @action_symbol ||= if params[:action].present?
+ params[:action].to_sym
+ else
+ nil
+ end
+ end
+
+end
diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb
new file mode 100644
index 0000000..309f8b2
--- /dev/null
+++ b/app/helpers/sessions_helper.rb
@@ -0,0 +1,2 @@
+module SessionsHelper
+end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
new file mode 100644
index 0000000..1b2dc5d
--- /dev/null
+++ b/app/helpers/users_helper.rb
@@ -0,0 +1,14 @@
+module UsersHelper
+
+ def user_form_class(*classes)
+ (classes + ['user', 'hidden', 'js-show', (@user.new_record? ? 'new' : 'edit')]).compact.join(' ')
+ end
+
+ def wrapped(item, options = {})
+ options[:as] ||= :div
+ content_tag options[:as], :class => dom_class(item), :id => dom_id(item) do
+ yield
+ end
+ end
+
+end
diff --git a/app/models/account.rb b/app/models/account.rb
new file mode 100644
index 0000000..cf998e4
--- /dev/null
+++ b/app/models/account.rb
@@ -0,0 +1,68 @@
+#
+# The Account model takes care of the livecycle of a user.
+# It composes a User record and it's identity records.
+# It also allows for other engines to hook into the livecycle by
+# monkeypatching the create, update and destroy methods.
+# There's an ActiveSupport load_hook at the end of this file to
+# make this more easy.
+#
+class Account
+
+ attr_reader :user
+
+ def initialize(user = nil)
+ @user = user
+ end
+
+ # Returns the user record so it can be used in views.
+ def self.create(attrs)
+ @user = User.create(attrs).tap do |user|
+ Identity.create_for user
+ end
+ end
+
+ def update(attrs)
+ if attrs[:password_verifier].present?
+ update_login(attrs[:login])
+ @user.update_attributes attrs.slice(:password_verifier, :password_salt)
+ end
+ # TODO: move into identity controller
+ key = update_pgp_key(attrs[:public_key])
+ @user.errors.set :public_key, key.errors.full_messages
+ @user.save && save_identities
+ @user.refresh_identity
+ end
+
+ def destroy
+ return unless @user
+ Identity.disable_all_for(@user)
+ @user.destroy
+ end
+
+ protected
+
+ def update_login(login)
+ return unless login.present?
+ @old_identity = Identity.for(@user)
+ @user.login = login
+ @new_identity = Identity.for(@user) # based on the new login
+ @old_identity.destination = @user.email_address # alias old -> new
+ end
+
+ def update_pgp_key(key)
+ PgpKey.new(key).tap do |key|
+ if key.present? && key.valid?
+ @new_identity ||= Identity.for(@user)
+ @new_identity.set_key(:pgp, key)
+ end
+ end
+ end
+
+ def save_identities
+ @new_identity.try(:save) && @old_identity.try(:save)
+ end
+
+ # You can hook into the account lifecycle from different engines using
+ # ActiveSupport.on_load(:account) do ...
+ ActiveSupport.run_load_hooks(:account, self)
+end
diff --git a/app/models/anonymous_service_level.rb b/app/models/anonymous_service_level.rb
new file mode 100644
index 0000000..4366a4a
--- /dev/null
+++ b/app/models/anonymous_service_level.rb
@@ -0,0 +1,30 @@
+class AnonymousServiceLevel
+
+ delegate :to_json, to: :config_hash
+
+ def cert_prefix
+ if APP_CONFIG[:allow_limited_certs]
+ APP_CONFIG[:limited_cert_prefix]
+ elsif APP_CONFIG[:allow_unlimited_certs]
+ APP_CONFIG[:unlimited_cert_prefix]
+ end
+ end
+
+ def description
+ if APP_CONFIG[:allow_anonymous_certs]
+ "anonymous access to the VPN"
+ else
+ "please login to access our services"
+ end
+ end
+
+ protected
+
+ def config_hash
+ { name: "anonymous",
+ description: description,
+ eip_rate_limit: APP_CONFIG[:allow_limited_certs]
+ }
+ end
+
+end
diff --git a/app/models/anonymous_user.rb b/app/models/anonymous_user.rb
new file mode 100644
index 0000000..87239eb
--- /dev/null
+++ b/app/models/anonymous_user.rb
@@ -0,0 +1,31 @@
+# The nil object for the user class
+class AnonymousUser < Object
+
+ def effective_service_level
+ AnonymousServiceLevel.new
+ end
+
+ def is_admin?
+ false
+ end
+
+ def id
+ nil
+ end
+
+ def email
+ nil
+ end
+
+ def email_address
+ nil
+ end
+
+ def login
+ nil
+ end
+
+ def messages
+ []
+ end
+end
diff --git a/app/models/client_certificate.rb b/app/models/client_certificate.rb
new file mode 100644
index 0000000..76b07a2
--- /dev/null
+++ b/app/models/client_certificate.rb
@@ -0,0 +1,113 @@
+#
+# Model for certificates
+#
+# This file must be loaded after Config has been loaded.
+#
+require 'base64'
+require 'digest/md5'
+require 'openssl'
+require 'certificate_authority'
+require 'date'
+
+class ClientCertificate
+
+ attr_accessor :key # the client private RSA key
+ attr_accessor :cert # the client x509 certificate, signed by the CA
+
+ #
+ # generate the private key and client certificate
+ #
+ def initialize(options = {})
+ cert = CertificateAuthority::Certificate.new
+
+ # set subject
+ cert.subject.common_name = common_name(options[:prefix])
+
+ # set expiration
+ cert.not_before = yesterday
+ cert.not_after = months_from_yesterday(APP_CONFIG[:client_cert_lifespan])
+
+ # generate key
+ cert.serial_number.number = cert_serial_number
+ cert.key_material.generate_key(APP_CONFIG[:client_cert_bit_size])
+
+ # sign
+ cert.parent = ClientCertificate.root_ca
+ cert.sign! client_signing_profile
+
+ self.key = cert.key_material.private_key
+ self.cert = cert
+ end
+
+ def to_s
+ self.key.to_pem + self.cert.to_pem
+ end
+
+ private
+
+ def self.root_ca
+ @root_ca ||= begin
+ crt = File.read(APP_CONFIG[:client_ca_cert])
+ key = File.read(APP_CONFIG[:client_ca_key])
+ openssl_cert = OpenSSL::X509::Certificate.new(crt)
+ cert = CertificateAuthority::Certificate.from_openssl(openssl_cert)
+ cert.key_material.private_key = OpenSSL::PKey::RSA.new(key, APP_CONFIG[:ca_key_password])
+ cert
+ end
+ end
+
+ #
+ # For cert serial numbers, we need a non-colliding number less than 160 bits.
+ # md5 will do nicely, since there is no need for a secure hash, just a short one.
+ # (md5 is 128 bits)
+ #
+ def cert_serial_number
+ Digest::MD5.hexdigest("#{rand(10**10)} -- #{Time.now}").to_i(16)
+ end
+
+ def common_name(prefix = nil)
+ [prefix, random_common_name].join
+ end
+
+ #
+ # for the random common name, we need a text string that will be unique across all certs.
+ # ruby 1.8 doesn't have a built-in uuid generator, or we would use SecureRandom.uuid
+ #
+ def random_common_name
+ cert_serial_number.to_s(36)
+ end
+
+ def client_signing_profile
+ {
+ "digest" => APP_CONFIG[:client_cert_hash],
+ "extensions" => {
+ "keyUsage" => {
+ "usage" => ["digitalSignature"]
+ },
+ "extendedKeyUsage" => {
+ "usage" => ["clientAuth"]
+ }
+ }
+ }
+ end
+
+ ##
+ ## TIME HELPERS
+ ##
+ ## note: we use 'yesterday' instead of 'today', because times are in UTC, and some people on the planet
+ ## are behind UTC.
+ ##
+
+ def yesterday
+ t = Time.now - 24*60*60
+ Time.utc t.year, t.month, t.day
+ end
+
+ def months_from_yesterday(num)
+ t = yesterday
+ date = Date.new t.year, t.month, t.day
+ date = date >> num # >> is months in the future operator
+ Time.utc date.year, date.month, date.day
+ end
+
+end
diff --git a/app/models/email.rb b/app/models/email.rb
new file mode 100644
index 0000000..a9a503f
--- /dev/null
+++ b/app/models/email.rb
@@ -0,0 +1,26 @@
+class Email < String
+ include ActiveModel::Validations
+
+ validates :email,
+ :format => {
+ :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/, #local part of email is case-sensitive, so allow uppercase letter.
+ :message => "needs to be a valid email address"
+ }
+
+ def to_partial_path
+ "emails/email"
+ end
+
+ def to_param
+ to_s
+ end
+
+ def email
+ self
+ end
+
+ def handle
+ self.split('@').first
+ end
+
+end
diff --git a/app/models/identity.rb b/app/models/identity.rb
new file mode 100644
index 0000000..ad8c01e
--- /dev/null
+++ b/app/models/identity.rb
@@ -0,0 +1,142 @@
+class Identity < CouchRest::Model::Base
+ include LoginFormatValidation
+
+ use_database :identities
+
+ belongs_to :user
+
+ property :address, LocalEmail
+ property :destination, Email
+ property :keys, HashWithIndifferentAccess
+
+ validate :unique_forward
+ validate :alias_available
+ validate :address_local_email
+ validate :destination_email
+
+ design do
+ view :by_user_id
+ view :by_address_and_destination
+ view :by_address
+ view :pgp_key_by_email,
+ map: <<-EOJS
+ function(doc) {
+ if (doc.type != 'Identity') {
+ return;
+ }
+ if (typeof doc.keys === "object") {
+ emit(doc.address, doc.keys["pgp"]);
+ }
+ }
+ EOJS
+ view :disabled,
+ map: <<-EOJS
+ function(doc) {
+ if (doc.type != 'Identity') {
+ return;
+ }
+ if (typeof doc.user_id === "undefined") {
+ emit(doc._id, 1);
+ }
+ }
+ EOJS
+
+ end
+
+ def self.for(user, attributes = {})
+ find_for(user, attributes) || build_for(user, attributes)
+ end
+
+ def self.find_for(user, attributes = {})
+ attributes.reverse_merge! attributes_from_user(user)
+ find_by_address_and_destination [attributes[:address], attributes[:destination]]
+ end
+
+ def self.build_for(user, attributes = {})
+ attributes.reverse_merge! attributes_from_user(user)
+ Identity.new(attributes)
+ end
+
+ def self.create_for(user, attributes = {})
+ identity = build_for(user, attributes)
+ identity.save
+ identity
+ end
+
+ def self.disable_all_for(user)
+ Identity.by_user_id.key(user.id).each do |identity|
+ identity.disable
+ identity.save
+ end
+ end
+
+ def self.destroy_all_for(user)
+ Identity.by_user_id.key(user.id).each do |identity|
+ identity.destroy
+ end
+ end
+
+ def self.destroy_all_disabled
+ Identity.disabled.each do |identity|
+ identity.destroy
+ end
+ end
+
+ def self.attributes_from_user(user)
+ { user_id: user.id,
+ address: user.email_address,
+ destination: user.email_address
+ }
+ end
+
+ def enabled?
+ self.destination && self.user_id
+ end
+
+ def disable
+ self.destination = nil
+ self.user_id = nil
+ end
+
+ def keys
+ read_attribute('keys') || HashWithIndifferentAccess.new
+ end
+
+ def set_key(type, key)
+ return if keys[type] == key.to_s
+ write_attribute('keys', keys.merge(type => key.to_s))
+ end
+
+ # for LoginFormatValidation
+ def login
+ self.address.handle
+ end
+
+ protected
+
+ def unique_forward
+ same = Identity.find_by_address_and_destination([address, destination])
+ if same && same != self
+ errors.add :base, "This alias already exists"
+ end
+ end
+
+ def alias_available
+ same = Identity.find_by_address(address)
+ if same && same.user != self.user
+ errors.add :base, "This email has already been taken"
+ end
+ end
+
+ def address_local_email
+ return if address.valid? #this ensures it is LocalEmail
+ self.errors.add(:address, address.errors.messages[:email].first) #assumes only one error
+ end
+
+ def destination_email
+ return if destination.nil? # this identity is disabled
+ return if destination.valid? # this ensures it is Email
+ self.errors.add(:destination, destination.errors.messages[:email].first) #assumes only one error #TODO
+ end
+
+end
diff --git a/app/models/local_email.rb b/app/models/local_email.rb
new file mode 100644
index 0000000..2b4c65e
--- /dev/null
+++ b/app/models/local_email.rb
@@ -0,0 +1,68 @@
+class LocalEmail < Email
+
+ BLACKLIST_FROM_RFC2142 = [
+ 'postmaster', 'hostmaster', 'domainadmin', 'webmaster', 'www',
+ 'abuse', 'noc', 'security', 'usenet', 'news', 'uucp',
+ 'ftp', 'sales', 'marketing', 'support', 'info'
+ ]
+
+ def self.domain
+ APP_CONFIG[:domain]
+ end
+
+ validates :email,
+ :format => {
+ :with => /@#{domain}\Z/i,
+ :message => "needs to end in @#{domain}"
+ }
+
+ validate :handle_allowed
+
+ def initialize(s)
+ super
+ append_domain_if_needed
+ end
+
+ def to_key
+ [handle]
+ end
+
+ def domain
+ LocalEmail.domain
+ end
+
+ protected
+
+ def append_domain_if_needed
+ unless self.index('@')
+ self << '@' + domain
+ end
+ end
+
+ def handle_allowed
+ errors.add(:handle, "is reserved.") if handle_reserved?
+ end
+
+ def handle_reserved?
+ # *ARRAY in a case statement tests if ARRAY includes the handle.
+ case handle
+ when *APP_CONFIG[:handle_blacklist]
+ true
+ when *APP_CONFIG[:handle_whitelist]
+ false
+ when *BLACKLIST_FROM_RFC2142
+ true
+ else
+ handle_in_passwd?
+ end
+ end
+
+ def handle_in_passwd?
+ begin
+ !!Etc.getpwnam(handle)
+ rescue ArgumentError
+ # handle was not found
+ return false
+ end
+ end
+end
diff --git a/app/models/login_format_validation.rb b/app/models/login_format_validation.rb
new file mode 100644
index 0000000..c1fcf70
--- /dev/null
+++ b/app/models/login_format_validation.rb
@@ -0,0 +1,21 @@
+module LoginFormatValidation
+ extend ActiveSupport::Concern
+
+ #TODO: Probably will replace this. Playing with using it for aliases too, but won't want it connected to login field.
+
+ included do
+ # Have multiple regular expression validations so we can get specific error messages:
+ validates :login,
+ :format => { :with => /\A.{2,}\z/,
+ :message => "Must have at least two characters"}
+ validates :login,
+ :format => { :with => /\A[a-z\d_\.-]+\z/,
+ :message => "Only lowercase letters, digits, . - and _ allowed."}
+ validates :login,
+ :format => { :with => /\A[a-z].*\z/,
+ :message => "Must begin with a lowercase letter"}
+ validates :login,
+ :format => { :with => /\A.*[a-z\d]\z/,
+ :message => "Must end with a letter or digit"}
+ end
+end
diff --git a/app/models/message.rb b/app/models/message.rb
new file mode 100644
index 0000000..424f094
--- /dev/null
+++ b/app/models/message.rb
@@ -0,0 +1,29 @@
+class Message < CouchRest::Model::Base
+
+ use_database :messages
+
+ property :text, String
+ property :user_ids_to_show, [String]
+ property :user_ids_have_shown, [String] # is this necessary to store?
+
+ timestamps!
+
+ design do
+ own_path = Pathname.new(File.dirname(__FILE__))
+ load_views(own_path.join('..', 'designs', 'message'))
+ end
+
+ def mark_as_read_by(user)
+ user_ids_to_show.delete(user.id)
+ # is it necessary to keep track of what users have already seen it?
+ user_ids_have_shown << user.id unless read_by?(user)
+ end
+
+ def read_by?(user)
+ user_ids_have_shown.include?(user.id)
+ end
+
+ def unread_by?(user)
+ user_ids_to_show.include?(user.id)
+ end
+end
diff --git a/app/models/pgp_key.rb b/app/models/pgp_key.rb
new file mode 100644
index 0000000..66f8660
--- /dev/null
+++ b/app/models/pgp_key.rb
@@ -0,0 +1,48 @@
+class PgpKey
+ include ActiveModel::Validations
+
+ KEYBLOCK_IDENTIFIERS = [
+ '-----BEGIN PGP PUBLIC KEY BLOCK-----',
+ '-----END PGP PUBLIC KEY BLOCK-----',
+ ]
+
+ # mostly for testing.
+ attr_accessor :keyblock
+
+ validate :validate_keyblock_format
+
+ def initialize(keyblock = nil)
+ @keyblock = keyblock
+ end
+
+ def to_s
+ @keyblock
+ end
+
+ def present?
+ @keyblock.present?
+ end
+
+ # allow comparison with plain keyblock strings.
+ def ==(other)
+ self.equal?(other) or
+ # relax the comparison on line ends.
+ self.to_s.tr_s("\n\r", '') == other.tr_s("\r\n", '')
+ end
+
+ protected
+
+ def validate_keyblock_format
+ if keyblock_identifier_missing?
+ errors.add :public_key_block,
+ "does not look like an armored pgp public key block"
+ end
+ end
+
+ def keyblock_identifier_missing?
+ KEYBLOCK_IDENTIFIERS.find do |identify|
+ !@keyblock.include?(identify)
+ end
+ end
+
+end
diff --git a/app/models/service_level.rb b/app/models/service_level.rb
new file mode 100644
index 0000000..a8df55b
--- /dev/null
+++ b/app/models/service_level.rb
@@ -0,0 +1,46 @@
+class ServiceLevel
+
+ def initialize(attributes = {})
+ @id = attributes[:id] || APP_CONFIG[:default_service_level]
+ end
+
+ def self.select_options
+ APP_CONFIG[:service_levels].map do |id,config_hash|
+ [config_hash[:description], id]
+ end
+ end
+
+ def id
+ @id
+ end
+
+ delegate :to_json, to: :config_hash
+
+ def cert_prefix
+ if limited_cert?
+ APP_CONFIG[:limited_cert_prefix]
+ elsif APP_CONFIG[:allow_unlimited_certs]
+ APP_CONFIG[:unlimited_cert_prefix]
+ end
+ end
+
+ def provides?(service)
+ services.include? service
+ end
+
+ def services
+ config_hash[:services] || []
+ end
+
+ protected
+
+ def limited_cert?
+ APP_CONFIG[:allow_limited_certs] &&
+ (!APP_CONFIG[:allow_unlimited_certs] || config_hash[:eip_rate_limit])
+ end
+
+ def config_hash
+ @config_hash || APP_CONFIG[:service_levels][@id].with_indifferent_access
+ end
+
+end
diff --git a/app/models/session.rb b/app/models/session.rb
new file mode 100644
index 0000000..0d7e10e
--- /dev/null
+++ b/app/models/session.rb
@@ -0,0 +1,32 @@
+class Session < SRP::Session
+ include ActiveModel::Validations
+ include LoginFormatValidation
+
+ attr_accessor :login
+
+ validates :login, :presence => true
+
+ def initialize(user = nil, aa = nil)
+ super(user, aa) if user
+ end
+
+ def persisted?
+ false
+ end
+
+ def new_record?
+ true
+ end
+
+ def to_model
+ self
+ end
+
+ def to_key
+ [object_id]
+ end
+
+ def to_param
+ nil
+ end
+end
diff --git a/app/models/token.rb b/app/models/token.rb
new file mode 100644
index 0000000..e759ee3
--- /dev/null
+++ b/app/models/token.rb
@@ -0,0 +1,73 @@
+class Token < CouchRest::Model::Base
+
+ use_database :tokens
+
+ belongs_to :user
+
+ # timestamps! does not create setters and only sets updated_at
+ # if the object has changed and been saved. Instead of triggering
+ # that we rather use our own property we have control over:
+ property :last_seen_at, Time, accessible: false
+
+ validates :user_id, presence: true
+
+ design do
+ view :by_last_seen_at
+ end
+
+ def self.expires_after
+ APP_CONFIG[:auth] && APP_CONFIG[:auth][:token_expires_after]
+ end
+
+ def self.expired
+ return [] unless expires_after
+ by_last_seen_at.endkey(expires_after.minutes.ago)
+ end
+
+ def self.destroy_all_expired
+ self.expired.each do |token|
+ token.destroy
+ end
+ end
+
+ def to_s
+ id
+ end
+
+ def authenticate
+ if expired?
+ destroy
+ return nil
+ else
+ touch
+ return user
+ end
+ end
+
+ # Tokens can be cleaned up in different ways.
+ # So let's make sure we don't crash if they disappeared
+ def destroy_with_rescue
+ destroy_without_rescue
+ rescue RestClient::ResourceNotFound
+ end
+ alias_method_chain :destroy, :rescue
+
+ def touch
+ self.last_seen_at = Time.now
+ save
+ end
+
+ def expired?
+ Token.expires_after and
+ last_seen_at < Token.expires_after.minutes.ago
+ end
+
+ def initialize(*args)
+ super
+ if new_record?
+ self.id = SecureRandom.urlsafe_base64(32).gsub(/^_*/, '')
+ self.last_seen_at = Time.now
+ end
+ end
+end
+
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 0000000..6678de6
--- /dev/null
+++ b/app/models/user.rb
@@ -0,0 +1,188 @@
+class User < CouchRest::Model::Base
+ include LoginFormatValidation
+
+ use_database :users
+
+ property :login, String, :accessible => true
+ property :password_verifier, String, :accessible => true
+ property :password_salt, String, :accessible => true
+
+ property :enabled, TrueClass, :default => true
+
+ # these will be null by default but we shouldn't ever pull them directly, but only via the methods that will return the full ServiceLevel
+ property :desired_service_level_code, Integer, :accessible => true
+ property :effective_service_level_code, Integer, :accessible => true
+
+ property :one_month_warning_sent, TrueClass
+
+ before_save :update_effective_service_level
+
+ validates :login, :password_salt, :password_verifier,
+ :presence => true
+
+ validates :login,
+ :uniqueness => true,
+ :if => :serverside?
+
+ validate :login_is_unique_alias
+
+ validates :password_salt, :password_verifier,
+ :format => { :with => /\A[\dA-Fa-f]+\z/, :message => "Only hex numbers allowed" }
+
+ validates :password, :presence => true,
+ :confirmation => true,
+ :format => { :with => /.{8}.*/, :message => "needs to be at least 8 characters long" }
+
+ timestamps!
+
+ design do
+ own_path = Pathname.new(File.dirname(__FILE__))
+ load_views(own_path.join('..', 'designs', 'user'))
+ view :by_login
+ view :by_created_at
+ end # end of design
+
+ def to_json(options={})
+ {
+ :login => login,
+ :ok => valid?
+ }.to_json(options)
+ end
+
+ def salt
+ password_salt.hex
+ end
+
+ def verifier
+ password_verifier.hex
+ end
+
+ def username
+ login
+ end
+
+ # use this if you want to get a working email address only.
+ def email
+ if effective_service_level.provides?('email')
+ email_address
+ end
+ end
+
+ # use this if you want the email address associated with a
+ # user no matter if the user actually has a local email account
+ def email_address
+ LocalEmail.new(login)
+ end
+
+ # Since we are storing admins by login, we cannot allow admins to change their login.
+ def is_admin?
+ APP_CONFIG['admins'].include? self.login
+ end
+
+ def most_recent_tickets(count=3)
+ Ticket.for_user(self).limit(count).all #defaults to having most recent updated first
+ end
+
+ def messages(unseen = true)
+ #TODO for now this only shows unseen messages. Will we ever want seen ones? Is it necessary to store?
+
+ # we don't want to emit all the userids associated with a message, so only emit id and text.
+ Message.by_user_ids_to_show.key(self.id).map { |message| [message.id, message.text] }
+
+ end
+
+ # DEPRECATED
+ #
+ # Please set the key on the identity directly
+ # WARNING: This will not be serialized with the user record!
+ # It is only a workaround for the key form.
+ def public_key=(value)
+ identity.set_key(:pgp, value)
+ end
+
+ # DEPRECATED
+ #
+ # Please access identity.keys[:pgp] directly
+ def public_key
+ identity.keys[:pgp]
+ end
+
+ def account
+ Account.new(self)
+ end
+
+ def identity
+ @identity ||= Identity.for(self)
+ end
+
+ def refresh_identity
+ @identity = Identity.for(self)
+ end
+
+ def desired_service_level
+ code = self.desired_service_level_code || APP_CONFIG[:default_service_level]
+ ServiceLevel.new({id: code})
+ end
+
+ def effective_service_level
+ code = self.effective_service_level_code || self.desired_service_level.id
+ ServiceLevel.new({id: code})
+ end
+
+
+ def self.send_one_month_warnings
+
+ # To determine warnings to send, need to get all users where one_month_warning_sent is not set, and where it was created greater than or equal to 1 month ago.
+ # TODO: might want to further limit to enabled accounts, and, based on provider's service level configuration, for particular service levels.
+ users_to_warn = User.by_created_at_and_one_month_warning_not_sent.endkey(Time.now-1.month)
+
+ users_to_warn.each do |user|
+ # instead of loop could use something like:
+ # message.user_ids_to_show = users_to_warn.map(&:id)
+ # but would still need to loop through users to store one_month_warning_sent
+
+ if !@message
+ # create a message for today's date
+ # only want to create once, and only if it will be used.
+ @message = Message.new(:text => I18n.t(:payment_one_month_warning, :date_in_one_month => (Time.now+1.month).strftime("%Y-%d-%m")))
+ end
+
+ @message.user_ids_to_show << user.id
+ user.one_month_warning_sent = true
+ user.save
+ end
+ @message.save if @message
+
+ end
+
+ protected
+
+ ##
+ # Validation Functions
+ ##
+
+ def login_is_unique_alias
+ alias_identity = Identity.find_by_address(self.email_address)
+ return if alias_identity.blank?
+ if alias_identity.user != self
+ errors.add(:login, "has already been taken")
+ end
+ end
+
+ def password
+ password_verifier
+ end
+
+ # used as a condition for validations that are server side only
+ def serverside?
+ true
+ end
+
+ def update_effective_service_level
+ # TODO: Is this always the case? Might there be a situation where the admin has set the effective service level and we don't want it changed to match the desired one?
+ if self.desired_service_level_code_changed?
+ self.effective_service_level_code = self.desired_service_level_code
+ end
+ end
+
+end
diff --git a/app/views/.gitkeep b/app/views/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/app/views/.gitkeep
diff --git a/app/views/common/_action_buttons.html.haml b/app/views/common/_action_buttons.html.haml
new file mode 100644
index 0000000..c74fcd1
--- /dev/null
+++ b/app/views/common/_action_buttons.html.haml
@@ -0,0 +1,11 @@
+.home-buttons
+ .row-fluid.second
+ .login.span4
+ %span.link= link_to(icon('ok-sign', icon_color) + t(:login), login_path, :class => 'btn')
+ %span.info= t(:login_info)
+ .signup.span4
+ %span.link= link_to(icon('user', icon_color) + t(:signup), signup_path, :class => 'btn')
+ %span.info= t(:signup_info)
+ .help.span4
+ %span.link= link_to(icon('question-sign', icon_color) + t(:get_help), new_ticket_path, :class => 'btn')
+ %span.info= t(:help_info)
diff --git a/app/views/common/_download_button.html.haml b/app/views/common/_download_button.html.haml
new file mode 100644
index 0000000..e57c56b
--- /dev/null
+++ b/app/views/common/_download_button.html.haml
@@ -0,0 +1,8 @@
+.home-buttons
+ .row-fluid.first
+ .span2
+ .download.span8
+ = link_to client_download_url, class: "btn btn-large btn-primary" do
+ = big_icon('download')
+ = t(:download_client)
+ .span2
diff --git a/app/views/common/_home_page_buttons.html.haml b/app/views/common/_home_page_buttons.html.haml
new file mode 100644
index 0000000..8c47983
--- /dev/null
+++ b/app/views/common/_home_page_buttons.html.haml
@@ -0,0 +1,8 @@
+- icon_color = :black
+
+= render 'common/download_button'
+- if local_assigns[:divider]
+ .row-fluid
+ .span12
+ = render local_assigns[:divider]
+= render 'common/action_buttons', icon_color: icon_color
diff --git a/app/views/emails/_email.html.haml b/app/views/emails/_email.html.haml
new file mode 100644
index 0000000..ea59cec
--- /dev/null
+++ b/app/views/emails/_email.html.haml
@@ -0,0 +1,6 @@
+= wrapped(email, local_assigns) do
+ = email
+ - if local_assigns[:with].try(:include?, :delete)
+ = link_to(user_email_alias_path(@user, email), :method => :delete) do
+ %i.icon-remove
+
diff --git a/app/views/home/_content.html.haml b/app/views/home/_content.html.haml
index 3d351e9..e47fdaf 100644
--- a/app/views/home/_content.html.haml
+++ b/app/views/home/_content.html.haml
@@ -9,6 +9,4 @@
.row-fluid
%hr
%p
- = link_to "fetch a cert", cert_path
- %p
= link_to "make donation", new_payment_path if APP_CONFIG[:payment].present?
diff --git a/app/views/kaminari/_first_page.html.haml b/app/views/kaminari/_first_page.html.haml
new file mode 100644
index 0000000..34436e3
--- /dev/null
+++ b/app/views/kaminari/_first_page.html.haml
@@ -0,0 +1,9 @@
+-# Link to the "First" page
+-# available local variables
+-# url: url to the first page
+-# current_page: a page object for the currently displayed page
+-# num_pages: total number of pages
+-# per_page: number of items to fetch per page
+-# remote: data-remote
+%li
+ = link_to_unless current_page.first?, raw(t 'views.pagination.first'), url, :remote => remote
diff --git a/app/views/kaminari/_gap.html.haml b/app/views/kaminari/_gap.html.haml
new file mode 100644
index 0000000..51de678
--- /dev/null
+++ b/app/views/kaminari/_gap.html.haml
@@ -0,0 +1,8 @@
+-# Non-link tag that stands for skipped pages...
+-# available local variables
+-# current_page: a page object for the currently displayed page
+-# num_pages: total number of pages
+-# per_page: number of items to fetch per page
+-# remote: data-remote
+%li.disabled
+ = raw(t 'views.pagination.truncate')
diff --git a/app/views/kaminari/_last_page.html.haml b/app/views/kaminari/_last_page.html.haml
new file mode 100644
index 0000000..c90433c
--- /dev/null
+++ b/app/views/kaminari/_last_page.html.haml
@@ -0,0 +1,9 @@
+-# Link to the "Last" page
+-# available local variables
+-# url: url to the last page
+-# current_page: a page object for the currently displayed page
+-# num_pages: total number of pages
+-# per_page: number of items to fetch per page
+-# remote: data-remote
+%li
+ = link_to_unless current_page.last?, raw(t 'views.pagination.last'), url, {:remote => remote}
diff --git a/app/views/kaminari/_next_page.html.haml b/app/views/kaminari/_next_page.html.haml
new file mode 100644
index 0000000..ea6cab2
--- /dev/null
+++ b/app/views/kaminari/_next_page.html.haml
@@ -0,0 +1,12 @@
+-# Link to the "Next" page
+-# available local variables
+-# url: url to the next page
+-# current_page: a page object for the currently displayed page
+-# num_pages: total number of pages
+-# per_page: number of items to fetch per page
+-# remote: data-remote
+- if current_page.last?
+ %li.disabled
+ %span= raw(t 'views.pagination.next')
+- else
+ %li= link_to(raw(t 'views.pagination.next'), url, :rel => 'next', :remote => remote)
diff --git a/app/views/kaminari/_page.html.haml b/app/views/kaminari/_page.html.haml
new file mode 100644
index 0000000..2f2f142
--- /dev/null
+++ b/app/views/kaminari/_page.html.haml
@@ -0,0 +1,14 @@
+-# Link showing page number
+-# available local variables
+-# page: a page object for "this" page
+-# url: url to this page
+-# current_page: a page object for the currently displayed page
+-# num_pages: total number of pages
+-# per_page: number of items to fetch per page
+-# remote: data-remote
+
+- if page.current?
+ %li.active
+ %span= page
+- else
+ %li= link_to(page, url, {:remote => remote, :rel => page.next? ? 'next' : page.prev? ? 'prev' : nil})
diff --git a/app/views/kaminari/_paginator.html.haml b/app/views/kaminari/_paginator.html.haml
new file mode 100644
index 0000000..79c5b92
--- /dev/null
+++ b/app/views/kaminari/_paginator.html.haml
@@ -0,0 +1,19 @@
+-# The container tag
+-# available local variables
+-# current_page: a page object for the currently displayed page
+-# num_pages: total number of pages
+-# per_page: number of items to fetch per page
+-# remote: data-remote
+-# paginator: the paginator that renders the pagination tags inside
+= paginator.render do
+ .pagination
+ %ul
+ -#= first_page_tag unless current_page.first?
+ = prev_page_tag #unless current_page.first?
+ - each_page do |page|
+ - if page.left_outer? || page.right_outer? || page.inside_window?
+ = page_tag page
+ - elsif !page.was_truncated?
+ = gap_tag
+ = next_page_tag #unless current_page.last?
+ -#= last_page_tag unless current_page.last?
diff --git a/app/views/kaminari/_prev_page.html.haml b/app/views/kaminari/_prev_page.html.haml
new file mode 100644
index 0000000..d274bf4
--- /dev/null
+++ b/app/views/kaminari/_prev_page.html.haml
@@ -0,0 +1,12 @@
+-# Link to the "Previous" page
+-# available local variables
+-# url: url to the previous page
+-# current_page: a page object for the currently displayed page
+-# num_pages: total number of pages
+-# per_page: number of items to fetch per page
+-# remote: data-remote
+- if current_page.first?
+ %li.disabled
+ %span= raw(t 'views.pagination.previous')
+- else
+ %li= link_to(raw(t 'views.pagination.previous'), url, :rel => 'prev', :remote => remote)
diff --git a/app/views/layouts/_footer.html.haml b/app/views/layouts/_footer.html.haml
index 5909bdd..340d36c 100644
--- a/app/views/layouts/_footer.html.haml
+++ b/app/views/layouts/_footer.html.haml
@@ -6,5 +6,5 @@
= link_to icon('info-sign') + t(:about), about_path
- if lookup_context.exists?('pages/contact')
= link_to icon('comment') + t(:contact), contact_path
- - if APP_CONFIG[:service_levels]
- = link_to icon('shopping-cart') + t(:pricing), pricing_path \ No newline at end of file
+ - if APP_CONFIG[:service_levels].present?
+ = link_to icon('shopping-cart') + t(:pricing), pricing_path
diff --git a/app/views/layouts/_header.html.haml b/app/views/layouts/_header.html.haml
index 157f1df..a1dd47a 100644
--- a/app/views/layouts/_header.html.haml
+++ b/app/views/layouts/_header.html.haml
@@ -8,5 +8,5 @@
%li
= link_to t(:logout), logout_path, :method => :delete
- if @user && @show_navigation
- .user_heading
+ .lead
= @user.email_address
diff --git a/app/views/layouts/_masthead.html.haml b/app/views/layouts/_masthead.html.haml
index 35225a1..fde5915 100644
--- a/app/views/layouts/_masthead.html.haml
+++ b/app/views/layouts/_masthead.html.haml
@@ -2,5 +2,3 @@
.title
%span.sitename
%a{:href => home_path}= APP_CONFIG[:domain]
- - if @show_navigation
- = t(:user_control_panel) \ No newline at end of file
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 1cd4ec3..d213fe1 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -14,6 +14,9 @@
= render 'layouts/masthead'
#main
.container-fluid
+ - if @show_navigation
+ .row-fluid
+ %h1= t(:user_control_panel)
- if logged_in?
.row-fluid
.span12
diff --git a/app/views/pages/pricing.html.haml b/app/views/pages/pricing.html.haml
index 77699d8..e339d27 100644
--- a/app/views/pages/pricing.html.haml
+++ b/app/views/pages/pricing.html.haml
@@ -13,9 +13,9 @@
%td= level[:name]
%td= level[:description]
%td
- - if level[:cost].nil? || level[:cost] == 0
+ - if level[:rate].nil? || level[:rate] == 0
= t(:free)
- else
- = level[:cost].map{|currency,cost| "#{cost} #{currency}"}.join(', ')
+ = level[:rate].collect{|currency, price| "#{currency} #{price}"}.join(', ')
- else
- No service levels are configured. \ No newline at end of file
+ No service levels are configured.
diff --git a/app/views/sessions/new.html.haml b/app/views/sessions/new.html.haml
new file mode 100644
index 0000000..bb7e4bd
--- /dev/null
+++ b/app/views/sessions/new.html.haml
@@ -0,0 +1,9 @@
+.span1
+.span9
+ %h2=t :login
+ .lead=t :login_info
+ = render :partial => 'users/warnings'
+ = simple_form_for [:api, @session], validate: true, html: { id: :new_session, class: 'form-horizontal hidden js-show', style: "display:none;" } do |f|
+ = f.input :login, :required => false, :label => t(:username), :input_html => { :id => :srp_username }
+ = f.input :password, :required => false, :input_html => { :id => :srp_password }
+ = f.button :wrapped, value: t(:login), cancel: home_path
diff --git a/app/views/sessions/new.json.erb b/app/views/sessions/new.json.erb
new file mode 100644
index 0000000..36154b8
--- /dev/null
+++ b/app/views/sessions/new.json.erb
@@ -0,0 +1,3 @@
+{
+"errors": <%= raw @errors.to_json %>
+}
diff --git a/app/views/users/_change_password.html.haml b/app/views/users/_change_password.html.haml
new file mode 100644
index 0000000..425e3ee
--- /dev/null
+++ b/app/views/users/_change_password.html.haml
@@ -0,0 +1,21 @@
+-#
+-# CHANGE PASSWORD
+-#
+-# * everything about this form is handled with javascript. So take care when changing any ids.
+-# * the login is required when changing the password because it is used as part of the salt when calculating the password verifier.
+-# however, we don't want the user to change their login without generating a new key, so we hide the ui for this
+-# (although it works perfectly fine to change username if the field was visible).
+-#
+
+- form_options = {:url => '/not-used', :html => {:class => user_form_class('form-horizontal'), :id => 'update_login_and_password', :data => {token: session[:token]}}, :validate => true}
+= simple_form_for @user, form_options do |f|
+ %legend= t(:change_password)
+ = hidden_field_tag 'user_param', @user.to_param
+ .hidden
+ = f.input :login, :label => t(:username), :required => false, :input_html => {:id => :srp_username}
+ = f.input :password, :required => false, :validate => true, :input_html => { :id => :srp_password }
+ = f.input :password_confirmation, :required => false, :input_html => { :id => :srp_password_confirmation }
+ .control-group
+ .controls
+ = f.submit t(:save), :class => 'btn btn-primary'
+
diff --git a/app/views/users/_change_pgp_key.html.haml b/app/views/users/_change_pgp_key.html.haml
new file mode 100644
index 0000000..e465125
--- /dev/null
+++ b/app/views/users/_change_pgp_key.html.haml
@@ -0,0 +1,13 @@
+-#
+-# CHANGE PGP KEY
+-#
+-# this will be replaced by a identities controller/view at some point
+-#
+
+- form_options = {:html => {:class => user_form_class('form-horizontal'), :id => 'update_pgp_key', :data => {token: session[:token]}}, :validate => true}
+= simple_form_for [:api, @user], form_options do |f|
+ %legend= t(:advanced_options)
+ = f.input :public_key, :as => :text, :hint => t(:use_ascii_key), :input_html => {:class => "full-width", :rows => 4}
+ .control-group
+ .controls
+ = f.submit t(:save), :class => 'btn', :data => {"loading-text" => "Saving..."}
diff --git a/app/views/users/_change_service_level.html.haml b/app/views/users/_change_service_level.html.haml
new file mode 100644
index 0000000..42315a2
--- /dev/null
+++ b/app/views/users/_change_service_level.html.haml
@@ -0,0 +1,18 @@
+-# TODO: probably won't want here, but here for now. Also, we will need way to ensure payment if they pick a non-free plan.
+-#
+-# SERVICE LEVEL
+-#
+- if APP_CONFIG[:service_levels]
+ - form_options = {:html => {:class => user_form_class('form-horizontal'), :id => 'update_service_level', :data => {token: session[:token]}}, :validate => true}
+ = simple_form_for @user, form_options do |f|
+ %legend= t(:service_level)
+ - if @user != current_user
+ = t(:desired_service_level)
+ = f.select :desired_service_level_code, ServiceLevel.select_options, :selected => @user.desired_service_level.id
+ - if @user != current_user
+ %p
+ = t(:effective_service_level)
+ = f.select :effective_service_level_code, ServiceLevel.select_options, :selected => @user.effective_service_level.id
+ .control-group
+ .controls
+ = f.submit t(:save), :class => 'btn', :data => {"loading-text" => "Saving..."}
diff --git a/app/views/users/_destroy_account.html.haml b/app/views/users/_destroy_account.html.haml
new file mode 100644
index 0000000..445f3c4
--- /dev/null
+++ b/app/views/users/_destroy_account.html.haml
@@ -0,0 +1,27 @@
+-#
+-# DESTROY ACCOUNT
+-#
+
+%legend
+ - if @user == current_user
+ = t(:destroy_my_account)
+ - else
+ = t(:admin_destroy_account, :username => @user.login)
+%p= t(:destroy_account_info)
+= link_to user_path(@user), :method => :delete, :confirm => t(:are_you_sure), :class => "btn btn-danger" do
+ %i.icon-remove.icon-white
+ = t(:destroy_my_account)
+- if @user != current_user and @user.enabled?
+ %legend
+ = t(:deactivate_account, :username => @user.login)
+ %p= t(:deactivate_description)
+ = link_to deactivate_user_path(@user), :method => :post, :class => "btn btn-warning" do
+ %i.icon-pause.icon-white
+ = t(:deactivate)
+- elsif @user != current_user and !@user.enabled?
+ %legend
+ = t(:enable_account, :username => @user.login)
+ %p= t(:enable_description)
+ = link_to enable_user_path(@user), :method => :post, :class => "btn btn-warning" do
+ %i.icon-ok.icon-white
+ = t(:enable)
diff --git a/app/views/users/_edit.html.haml b/app/views/users/_edit.html.haml
new file mode 100644
index 0000000..1d2b68a
--- /dev/null
+++ b/app/views/users/_edit.html.haml
@@ -0,0 +1,14 @@
+-#
+-# edit user form, used by both show and edit actions.
+-#
+-# We render a bunch of forms here. Which we use depends upon config settings
+-# user_actions and admin_actions. They both include an array of actions
+-# allowed to users and admins.
+-# Possible forms are:
+-# 'change_password'
+-# 'change_pgp_key'
+-# 'change_service_level'
+-# 'destroy_account'
+- actions = APP_CONFIG[admin? ? :admin_actions : :user_actions] || []
+- actions.each do |action|
+ = render action
diff --git a/app/views/users/_user.html.haml b/app/views/users/_user.html.haml
new file mode 100644
index 0000000..583d22f
--- /dev/null
+++ b/app/views/users/_user.html.haml
@@ -0,0 +1,4 @@
+%tr
+ %td= link_to user.login, user
+ %td= l(user.created_at, :format => :short)
+ %td= l(user.updated_at, :format => :short)
diff --git a/app/views/users/_warnings.html.haml b/app/views/users/_warnings.html.haml
new file mode 100644
index 0000000..baf80a4
--- /dev/null
+++ b/app/views/users/_warnings.html.haml
@@ -0,0 +1,12 @@
+%noscript
+ %div.alert.alert-error=t :js_required_html
+#cookie_warning.alert.alert-error{:style => "display:none"}
+ =t :cookie_disabled_warning
+:javascript
+ document.cookie = "testing=cookies_enabled; path=/";
+ if(document.cookie.indexOf("testing=cookies_enabled") < 0)
+ {
+ document.getElementById('cookie_warning').style.display = 'block';
+ } else {
+ document.getElementById('cookie_warning').style.display = 'none';
+ }
diff --git a/app/views/users/edit.html.haml b/app/views/users/edit.html.haml
new file mode 100644
index 0000000..434c025
--- /dev/null
+++ b/app/views/users/edit.html.haml
@@ -0,0 +1 @@
+= render 'edit'
diff --git a/app/views/users/index.html.haml b/app/views/users/index.html.haml
new file mode 100644
index 0000000..fc1001e
--- /dev/null
+++ b/app/views/users/index.html.haml
@@ -0,0 +1,13 @@
+- @show_navigation = false
+
+= form_tag users_path, :method => :get, :class => "form-search" do
+ .input-append
+ = text_field_tag :query, params[:query], :id => 'user-typeahead', :class => "search-query", :autocomplete => :off
+ %button.btn{:type => :submit}= t(:search)
+
+%table.table.table-striped
+ %tr
+ %th= t(:username)
+ %th= t(:created)
+ %th= t(:updated)
+ = render @users.all
diff --git a/app/views/users/new.html.haml b/app/views/users/new.html.haml
new file mode 100644
index 0000000..bc36068
--- /dev/null
+++ b/app/views/users/new.html.haml
@@ -0,0 +1,21 @@
+-#
+-# This form is handled entirely by javascript
+-# Please take care when changing element ids.
+-#
+-# The form is hidden when no js is available
+-# to prevent submission in the clear.
+-#
+
+- form_options = {url: '/not-used', html: {id: 'new_user', class: user_form_class('form-horizontal'), style: 'display:none'}, validate: true}
+
+.span1
+.span9
+ %h2=t :signup
+ .lead=t :signup_info
+ = render :partial => 'warnings'
+ = simple_form_for(@user, form_options) do |f|
+ = f.input :login, :label => t(:username), :required => false, :input_html => { :id => :srp_username }
+ = f.input :password, :required => false, :validate => true, :input_html => { :id => :srp_password }
+ = f.input :password_confirmation, :required => false, :validate => true, :input_html => { :id => :srp_password_confirmation }
+ = f.button :wrapped, value: t(:signup), cancel: home_path
+
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
new file mode 100644
index 0000000..ddc33ab
--- /dev/null
+++ b/app/views/users/show.html.haml
@@ -0,0 +1,30 @@
+.overview
+
+ %h2.first= t(:overview_welcome, :username => @user.login)
+
+ - if admin?
+ %p
+ = t(:created)
+ = @user.created_at
+ %br
+ = t(:updated)
+ = @user.updated_at
+ %br
+ = t(:enabled)
+ = @user.enabled?
+
+ %p= t(:overview_intro)
+
+ %ul.unstyled
+ %li= icon('user') + link_to(t(:overview_account), edit_user_path(@user))
+ - # %li= icon('envelope') + link_to(t(:overview_email), {insert path for user identities, presuambly}
+ %li= icon('question-sign') + link_to(t(:overview_tickets), user_tickets_path(@user))
+ %li= icon('shopping-cart') + link_to(t(:overview_billing), billing_top_link(@user)) if APP_CONFIG[:billing]
+
+
+ .container-fluid
+ .row-fluid
+ %h4 To use bitmask services:
+ = link_to client_download_url, class: "btn btn-primary" do
+ %i.icon-arrow-down.icon-white
+ = t(:download_client)
diff --git a/app/views/v1/sessions/new.json.erb b/app/views/v1/sessions/new.json.erb
new file mode 100644
index 0000000..36154b8
--- /dev/null
+++ b/app/views/v1/sessions/new.json.erb
@@ -0,0 +1,3 @@
+{
+"errors": <%= raw @errors.to_json %>
+}
diff --git a/app/views/webfinger/host_meta.xml.erb b/app/views/webfinger/host_meta.xml.erb
new file mode 100644
index 0000000..cfcbcc0
--- /dev/null
+++ b/app/views/webfinger/host_meta.xml.erb
@@ -0,0 +1,11 @@
+<?xml version='1.0' encoding='UTF-8'?>
+ <XRD xmlns='http://docs.oasis-open.org/ns/xri/xrd-1.0'>
+
+ <Subject><%= @host_meta.subject %></Subject>
+
+ <%- @host_meta.links.each do |rel, link| %>
+ <Link rel='<%= rel %>'
+ type='<%= link[:type] %>'
+ template='<%= link[:template] %>' />
+ <%- end %>
+ </XRD>
diff --git a/app/views/webfinger/search.xml.erb b/app/views/webfinger/search.xml.erb
new file mode 100644
index 0000000..7328552
--- /dev/null
+++ b/app/views/webfinger/search.xml.erb
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
+ <Subject><%= @presenter.subject %></Subject>
+ <%- @presenter.links.each do |rel, link| %>
+ <Link rel=<%=rel%> type=<%=link[:type]%> href="<%= link[:key] %>"/>
+ <% end %>
+</XRD>