1
0
مراية لـ https://github.com/postalserver/postal.git تم المزامنة 2026-01-16 21:23:37 +00:00

feat: openid connect support (#2873)

هذا الالتزام موجود في:
Adam Cooke
2024-03-12 17:40:07 +00:00
ملتزم من قبل GitHub
الأصل 4e13577891
التزام 5ed94f6f85
28 ملفات معدلة مع 854 إضافات و232 حذوفات

عرض الملف

@@ -1,22 +1,32 @@
.loginForm {
}
.loginForm {}
.loginForm__input {
margin-bottom:15px;
margin-bottom: 15px;
}
.loginForm__submit {
display:flex;
justify-content:space-between;
align-items:center;
display: flex;
justify-content: space-between;
align-items: center;
}
.loginForm__links {
font-size:12px;
color:#999;
font-size: 12px;
color: #999;
text-decoration: underline;
line-height:1.7;
line-height: 1.7;
}
.loginForm__divider {
margin-top: 25px;
margin-bottom: 25px;
border-top: 1px solid #e4e8ef;
}
.loginForm__localTitle {
text-align: center;
margin-bottom: 15px;
color: #999;
}

عرض الملف

@@ -1,74 +1,67 @@
.userList {
border-radius:4px;
color:$darkBlue;
overflow:hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2);
border-radius: 4px;
color: $darkBlue;
overflow: hidden;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
}
.userList__item {
display:block;
background:#fff;
padding:15px;
display:flex;
display: block;
background: #fff;
padding: 15px;
display: flex;
align-items: center;
}
.userList__item:nth-child(even) {
background:none;
background: none;
}
.userList__item + .userList__item {
border-top:1px solid lighten(#ccd4e0, 10%);
}
.userList__avatar {
width:50px;
height:50px;
border-radius:50%;
background:#fff;
border:2px solid #efefef;
padding:3px;
flex: 0 0 auto;
.userList__item+.userList__item {
border-top: 1px solid lighten(#ccd4e0, 10%);
}
.userList__details {
flex: 1 1 auto;
margin:0 25px;
margin: 0 0;
}
.userList__actions {
flex: 0 0 auto;
width:180px;
font-size:12px;
line-height:1.5;
color:#999;
text-decoration: underline;
width: 120px;
font-size: 12px;
line-height: 1.5;
color: #999;
a {
text-decoration: underline;
}
}
.userList__name {
font-weight:600;
font-size:16px;
margin-bottom:3px;
font-weight: 600;
font-size: 16px;
margin-bottom: 3px;
}
.userList__owner {
vertical-align:2px;
margin-left:5px;
background-color:$orange;
vertical-align: 2px;
margin-left: 5px;
background-color: $orange;
}
.userList__pending {
vertical-align:2px;
margin-left:5px;
background-color:#ccc;
vertical-align: 2px;
margin-left: 5px;
background-color: #ccc;
}
.userList__admin {
vertical-align:2px;
margin-left:5px;
background-color:$blue;
.userList__tag {
vertical-align: 2px;
margin-left: 3px;
}
.userList__revoke {
color:$red;
color: $red;
}

عرض الملف

@@ -1,98 +1,117 @@
.button {
display:inline-block;
font:inherit;
border-radius:4px;
appearance:none;
background:$blue;
color:#fff;
font-size:14px !important;
margin:0;
vertical-align:top;
padding:6px 15px;
border:2px solid transparent;
border-bottom:2px solid darken($blue, 20%);
display: inline-block;
font: inherit;
border-radius: 4px;
appearance: none;
background: $blue;
color: #fff;
font-size: 14px !important;
margin: 0;
vertical-align: top;
padding: 6px 15px;
border: 2px solid transparent;
border-bottom: 2px solid darken($blue, 20%);
&:active {
background-color:darken($blue, 15%);
background-color: darken($blue, 15%);
}
&:focus {
border-color:darken($blue, 15%);
background-color:lighten($blue, 5%);
border-color: darken($blue, 15%);
background-color: lighten($blue, 5%);
}
&.is-spinning {
color:transparent;
background-repeat:no-repeat;
color: transparent;
background-repeat: no-repeat;
background-position: center center;
background-size:25px;
background-image:image-url('button-spinner.gif');
background-size: 25px;
background-image: image-url('button-spinner.gif');
}
}
.button--small {
font-size:12px !important;
padding:3px 10px;
border-width:1px;
font-size: 12px !important;
padding: 3px 10px;
border-width: 1px;
}
.button--positive {
background-color:$green;
border-bottom-color:darken($green, 15%);
background-color: $green;
border-bottom-color: darken($green, 15%);
&:active {
background-color:darken($green, 15%);
background-color: darken($green, 15%);
}
&:focus {
border-color:darken($green, 15%);
background-color:lighten($green, 5%);
border-color: darken($green, 15%);
background-color: lighten($green, 5%);
}
&.is-spinning {
background-image:image-url('button-spinner-positive.gif');
background-image: image-url('button-spinner-positive.gif');
}
}
.button--neutral {
background-color:#ccc;
border-bottom-color:darken(#ccc, 15%);
background-color: #ccc;
border-bottom-color: darken(#ccc, 15%);
&:active {
background-color:darken(#ccc, 15%);
background-color: darken(#ccc, 15%);
}
&:focus {
border-color:darken(#ccc, 15%);
background-color:lighten(#ccc, 5%);
border-color: darken(#ccc, 15%);
background-color: lighten(#ccc, 5%);
}
&.is-spinning {
background-image:image-url('button-spinner-neutral.gif');
background-image: image-url('button-spinner-neutral.gif');
}
}
.button--danger {
background-color:$red;
border-bottom-color:darken($red, 15%);
background-color: $red;
border-bottom-color: darken($red, 15%);
&:active {
background-color:darken($red, 15%);
background-color: darken($red, 15%);
}
&:focus {
border-color:darken($red, 15%);
background-color:lighten($red, 5%);
border-color: darken($red, 15%);
background-color: lighten($red, 5%);
}
&.is-spinning {
background-image:image-url('button-spinner-danger.gif');
background-image: image-url('button-spinner-danger.gif');
}
}
.button--dark {
background-color:$darkBlue;
border-bottom-color:darken($darkBlue, 15%);
background-color: $darkBlue;
border-bottom-color: darken($darkBlue, 15%);
&:active {
background-color:darken($darkBlue, 15%);
background-color: darken($darkBlue, 15%);
}
&:focus {
border-color:darken($darkBlue, 15%);
background-color:lighten($darkBlue, 5%);
border-color: darken($darkBlue, 15%);
background-color: lighten($darkBlue, 5%);
}
&.is-spinning {
background-image:image-url('button-spinner-dark.gif');
background-image: image-url('button-spinner-dark.gif');
}
}
.button--full {
width: 100%;
text-align: center;
}

عرض الملف

@@ -1,136 +1,137 @@
.label {
display:inline-block;
background:#000;
color:#fff;
font-size:9px;
display: inline-block;
background: #000;
color: #fff;
font-size: 9px;
text-transform: uppercase;
border-radius:40px;
padding:2px 6px;
line-height:0.9;
border-radius: 40px;
padding: 2px 6px;
line-height: 0.9;
}
.label--green {
background-color:$green;
background-color: $green;
}
.label--red {
background-color:$red;
background-color: $red;
}
.label--orange {
background-color:$orange;
background-color: $orange;
}
.label--blue {
background-color:$blue;
background-color: $blue;
}
.label--grey {
background-color:#999;
background-color: #999;
}
.label--turquoise {
background-color:$blue;
background-color: $blue;
}
.label--purple {
background-color:$purple;
background-color: $purple;
}
.label--large {
font-size:11px;
padding:4px 10px;
font-size: 11px;
padding: 4px 10px;
}
.label--serverStatus-live {
background-color:$green;
background-color: $green;
}
.label--serverStatus-development {
background-color:#636363;
background-color: #636363;
}
.label--serverStatus-suspended {
background-color:$red;
background-color: $red;
}
.label--messageStatus-pending {
background-color:$subBlue;
background-color: $subBlue;
}
.label--messageStatus-held {
background-color:#aaa;
background-color: #aaa;
}
.label--messageStatus-processed {
background-color:$green;
background-color: $green;
}
.label--messageStatus-sent {
background-color:$green;
background-color: $green;
}
.label--messageStatus-hard_fail {
background-color:$red;
background-color: $red;
}
.label--messageStatus-soft_fail {
background-color:$orange;
background-color: $orange;
}
.label--messageStatus-bounced {
background-color:$red;
background-color: $red;
}
.label--messageStatus-hold_cancelled {
background-color:#ccc;
background-color: #ccc;
}
.label--credentialType-api {
background-color:$blue;
background-color: $blue;
}
.label--credentialType-smtp {
background-color:$turquoise;
background-color: $turquoise;
}
.label--credentialType-smtp_ip {
background-color:$orange;
background-color: $orange;
}
.label--spamStatus-not_checked {
background:#aaa;
background: #aaa;
}
.label--spamStatus-spam {
background:$orange;
background: $orange;
}
.label--spamStatus-not_spam {
background:$turquoise;
background: $turquoise;
}
.label--http-status-2 {
background-color:$green;
background-color: $green;
}
.label--http-status-3 {
background-color:$orange;
background-color: $orange;
}
.label--http-status-4,
.label--http-status-5 {
background-color:$red;
background-color: $red;
}
.domainList__ssl {
color:$green;
color: $green;
&:hover {
text-decoration:underline;
text-decoration: underline;
}
}
.domainList__ssl--disabled {
color:#999;
color: #999;
}

عرض الملف

@@ -4,7 +4,8 @@ class SessionsController < ApplicationController
layout "sub"
skip_before_action :login_required, only: [:new, :create, :begin_password_reset, :finish_password_reset, :ip, :raise_error]
before_action :require_local_authentication, only: [:create, :begin_password_reset, :finish_password_reset]
skip_before_action :login_required, only: [:new, :create, :begin_password_reset, :finish_password_reset, :ip, :raise_error, :create_from_oidc, :oauth_failure]
def create
login(User.authenticate(params[:email_address], params[:password]))
@@ -29,12 +30,16 @@ class SessionsController < ApplicationController
def begin_password_reset
return unless request.post?
if user = User.where(email_address: params[:email_address]).first
user.begin_password_reset(params[:return_to])
redirect_to login_path(return_to: params[:return_to]), notice: "Please check your e-mail and click the link in the e-mail we've sent you."
else
redirect_to login_reset_path(return_to: params[:return_to]), alert: "No user exists with that e-mail address. Please check and try again."
user_scope = Postal::Config.oidc.enabled? ? User.with_password : User
user = user_scope.find_by(email_address: params[:email_address])
if user.nil?
redirect_to login_reset_path(return_to: params[:return_to]), alert: "No local user exists with that e-mail address. Please check and try again."
return
end
user.begin_password_reset(params[:return_to])
redirect_to login_path(return_to: params[:return_to]), notice: "Please check your e-mail and click the link in the e-mail we've sent you."
end
def finish_password_reset
@@ -49,6 +54,7 @@ class SessionsController < ApplicationController
flash.now[:alert] = "You must enter a new password"
return
end
@user.password = params[:password]
@user.password_confirmation = params[:password_confirmation]
return unless @user.save
@@ -61,4 +67,33 @@ class SessionsController < ApplicationController
render plain: "ip: #{request.ip} remote ip: #{request.remote_ip}"
end
def create_from_oidc
unless Postal::Config.oidc.enabled?
raise Postal::Error, "OIDC cannot be used unless enabled in the configuration"
end
auth = request.env["omniauth.auth"]
user = User.find_from_oidc(auth.extra.raw_info, logger: Postal.logger)
if user.nil?
redirect_to login_path, alert: "No user was found matching your identity. Please contact your administrator."
return
end
login(user)
flash[:remember_login] = true
redirect_to_with_return_to root_path
end
def oauth_failure
redirect_to login_path, alert: "An issue occurred while logging you in with OpenID. Please try again later or contact your administrator."
end
private
def require_local_authentication
return if Postal::Config.oidc.local_authentication_enabled?
redirect_to login_path, alert: "Local authentication is not enabled"
end
end

عرض الملف

@@ -30,23 +30,28 @@ class UserController < ApplicationController
def update
@user = User.find(current_user.id)
@user.attributes = params.require(:user).permit(:first_name, :last_name, :time_zone, :email_address, :password, :password_confirmation)
safe_params = [:first_name, :last_name, :time_zone, :email_address]
if @user.authenticate_with_previous_password_first(params[:password])
@password_correct = true
else
respond_to do |wants|
wants.html do
flash.now[:alert] = "The current password you have entered is incorrect. Please check and try again."
render "edit"
end
wants.json do
render json: { alert: "The current password you've entered is incorrect. Please check and try again" }
if @user.password? && Postal::Config.oidc.local_authentication_enabled?
safe_params += [:password, :password_confirmation]
if @user.authenticate_with_previous_password_first(params[:password])
@password_correct = true
else
respond_to do |wants|
wants.html do
flash.now[:alert] = "The current password you have entered is incorrect. Please check and try again."
render "edit"
end
wants.json do
render json: { alert: "The current password you've entered is incorrect. Please check and try again" }
end
end
return
end
return
end
@user.attributes = params.require(:user).permit(safe_params)
if @user.save
redirect_to_with_json settings_path, notice: "Your settings have been updated successfully."
else

عرض الملف

@@ -5,15 +5,20 @@ module HasAuthentication
extend ActiveSupport::Concern
included do
has_secure_password
has_secure_password validations: false
validates :password, length: { minimum: 8, allow_blank: true }
validates :password, confirmation: { allow_blank: true }
validate :validate_password_presence
before_save :clear_password_reset_token_on_password_change
scope :with_password, -> { where.not(password_digest: nil) }
end
class_methods do
def authenticate(email_address, password)
user = where(email_address: email_address).first
user = find_by(email_address: email_address)
raise Postal::Errors::AuthenticationError, "InvalidEmailAddress" if user.nil?
raise Postal::Errors::AuthenticationError, "InvalidPassword" unless user.authenticate(password)
@@ -30,6 +35,10 @@ module HasAuthentication
end
def begin_password_reset(return_to = nil)
if Postal::Config.oidc.enabled? && (oidc_uid.present? || password_digest.blank?)
raise Postal::Error, "User has OIDC enabled, password resets are not supported"
end
self.password_reset_token = SecureRandom.alphanumeric(24)
self.password_reset_token_valid_until = 1.day.from_now
save!
@@ -45,6 +54,12 @@ module HasAuthentication
self.password_reset_token_valid_until = nil
end
def validate_password_presence
return if password_digest.present? || Postal::Config.oidc.enabled?
errors.add :password, :blank
end
end
# -*- SkipSchemaAnnotations

عرض الملف

@@ -5,19 +5,21 @@
# Table name: users
#
# id :integer not null, primary key
# uuid :string(255)
# first_name :string(255)
# last_name :string(255)
# admin :boolean default(FALSE)
# email_address :string(255)
# password_digest :string(255)
# time_zone :string(255)
# email_verification_token :string(255)
# email_verified_at :datetime
# created_at :datetime
# updated_at :datetime
# first_name :string(255)
# last_name :string(255)
# oidc_issuer :string(255)
# oidc_uid :string(255)
# password_digest :string(255)
# password_reset_token :string(255)
# password_reset_token_valid_until :datetime
# admin :boolean default(FALSE)
# time_zone :string(255)
# uuid :string(255)
# created_at :datetime
# updated_at :datetime
#
# Indexes
#
@@ -28,13 +30,11 @@
class User < ApplicationRecord
include HasUUID
include HasAuthentication
validates :first_name, presence: true
validates :last_name, presence: true
validates :email_address, presence: true, uniqueness: { case_sensitive: false }, format: { with: /@/, allow_blank: true }
validates :time_zone, presence: true
default_value :time_zone, -> { "UTC" }
@@ -53,24 +53,85 @@ class User < ApplicationRecord
"#{first_name} #{last_name}"
end
def password?
password_digest.present?
end
def oidc?
oidc_uid.present?
end
def to_param
uuid
end
def md5_for_gravatar
@md5_for_gravatar ||= Digest::MD5.hexdigest(email_address.to_s.downcase)
end
def avatar_url
@avatar_url ||= email_address ? "https://secure.gravatar.com/avatar/#{md5_for_gravatar}?rating=PG&size=120&d=mm" : nil
end
def email_tag
"#{name} <#{email_address}>"
end
def self.[](email)
where(email_address: email).first
class << self
# Lookup a user by email address
#
# @param email [String] the email address
#
# @return [User, nil] the user
def [](email)
find_by(email_address: email)
end
# Find a user based on an OIDC authentication hash
#
# @param auth [Hash] the authentication hash
# @param logger [Logger] a logger to log debug information to
#
# @return [User, nil] the user
def find_from_oidc(auth, logger: nil)
config = Postal::Config.oidc
uid = auth[config.uid_field]
oidc_name = auth[config.name_field]
oidc_email_address = auth[config.email_address_field]
logger&.debug "got auth details from issuer: #{auth.inspect}"
# look for an existing user with the same UID and OIDC issuer. If we find one,
# this is the user we'll want to use.
user = where(oidc_uid: uid, oidc_issuer: config.issuer).first
if user
logger&.debug "found user with UID #{uid} for issuer #{config.issuer} (user ID: #{user.id})"
else
logger&.debug "no user with UID #{uid} for issuer #{config.issuer}"
end
# if we don't have an existing user, we will look for users which have no OIDC
# credentials but with a matching e-mail address.
if user.nil? && oidc_email_address.present?
user = where(oidc_uid: nil, email_address: oidc_email_address).first
if user
logger&.debug "found user with e-mail address #{oidc_email_address} (user ID: #{user.id})"
else
logger&.debug "no user with e-mail address #{oidc_email_address}"
end
end
# now, if we still don't have a user, we're not going to create one so we'll just
# return nil (we might auto create users in the future but not right now)
return if user.nil?
# otherwise, let's update our user as appropriate
user.oidc_uid = uid
user.oidc_issuer = config.issuer
user.email_address = oidc_email_address if oidc_email_address.present?
user.first_name, user.last_name = oidc_name.split(/\s+/, 2) if oidc_name.present?
user.password = nil
user.save!
# return the user
user
end
end
end

عرض الملف

@@ -6,13 +6,19 @@
.subPageBox__content
= form_tag login_path, :class => 'loginForm' do
= hidden_field_tag 'return_to', params[:return_to]
- if params[:return_to] && params[:return_to] =~ /\/join\//
%p.loginForm__invite.warningBox.u-margin Login to your existing account to accept your invitation.
%p.loginForm__input= text_field_tag 'email_address', '', :type => 'email', :autocomplete => 'off', :spellcheck => 'false', :class => 'input input--text input--onWhite', :placeholder => "Your e-mail address", :autofocus => true, :tabindex => 1
%p.loginForm__input= password_field_tag 'password', '', :class => 'input input--text input--onWhite', :placeholder => "Your password", :tabindex => 2
.loginForm__submit
%ul.loginForm__links
%li= link_to "Forgotten your password?", login_reset_path(:return_to => params[:return_to])
%p= submit_tag "Login", :class => 'button button--positive', :tabindex => 3
- if Postal::Config.oidc.enabled?
.loginForm__oidcButton
= link_to "Login with #{Postal::Config.oidc.name}", "/auth/oidc", method: :post, class: 'button button--full'
- if Postal::Config.oidc.enabled? && Postal::Config.oidc.local_authentication_enabled?
.loginForm__divider
%p.loginForm__localTitle or login with a local user
- if Postal::Config.oidc.local_authentication_enabled?
%p.loginForm__input= text_field_tag 'email_address', '', :type => 'email', :spellcheck => 'false', :class => 'input input--text input--onWhite', :placeholder => "Your e-mail address", :autofocus => !Postal::Config.oidc.enabled?, :tabindex => 1
%p.loginForm__input= password_field_tag 'password', '', :class => 'input input--text input--onWhite', :placeholder => "Your password", :tabindex => 2
.loginForm__submit
%ul.loginForm__links
%li= link_to "Forgotten your password?", login_reset_path(:return_to => params[:return_to])
%p= submit_tag "Login", :class => 'button button--positive', :tabindex => 3

عرض الملف

@@ -6,15 +6,16 @@
= form_for @user, :url => settings_path, :remote => true do |f|
= f.error_messages
%fieldset.fieldSet
.fieldSet__field
= label_tag :password, 'Your Password', :class => 'fieldSet__label'
.fieldSet__input
= password_field_tag :password, params[:password], :autofocus => @password_correct.nil?, :disabled => @password_correct, :class => 'input input--text', :placeholder => "Enter your current password to change your details"
- if @password_correct
= hidden_field_tag :password, params[:password]
%p.fieldSet__text
In order to protect your account, you need to enter your current password in the field above
to authenticate the change of your details.
- if @user.password? && Postal::Config.oidc.local_authentication_enabled?
.fieldSet__field
= label_tag :password, 'Your Password', :class => 'fieldSet__label'
.fieldSet__input
= password_field_tag :password, params[:password], :autofocus => @password_correct.nil?, :disabled => @password_correct, :class => 'input input--text', :placeholder => "Enter your current password to change your details"
- if @password_correct
= hidden_field_tag :password, params[:password]
%p.fieldSet__text
In order to protect your account, you need to enter your current password in the field above
to authenticate the change of your details.
.fieldSet__title
Your details
@@ -41,14 +42,15 @@
Choose the time zone that you'd like times to be displayed to you when you use our
web interface. By default, times are displayed in UTC.
.fieldSet__title
Change your password?
.fieldSet__field
= f.label :password, "New Password", :class => 'fieldSet__label'
.fieldSet__input
.inputPair
= f.password_field :password, :class => 'input input--text', :placeholder => "•••••••••••", :value => @user.password
= f.password_field :password_confirmation, :class => 'input input--text', :placeholder => "and confirm it", :value => @user.password_confirmation
- if @user.password? && Postal::Config.oidc.local_authentication_enabled?
.fieldSet__title
Change your password?
.fieldSet__field
= f.label :password, "New Password", :class => 'fieldSet__label'
.fieldSet__input
.inputPair
= f.password_field :password, :class => 'input input--text', :placeholder => "•••••••••••", :value => @user.password
= f.password_field :password_confirmation, :class => 'input input--text', :placeholder => "and confirm it", :value => @user.password_confirmation
%p.fieldSetSubmit.buttonSet

عرض الملف

@@ -11,11 +11,25 @@
.fieldSet__input= f.text_field :last_name, :class => 'input input--text'
.fieldSet__field
= f.label :email_address, :class => 'fieldSet__label'
.fieldSet__input= f.text_field :email_address, :class => 'input input--text', autocomplete: 'one-time-code'
- unless @user.persisted?
.fieldSet__input
= f.text_field :email_address, :class => 'input input--text', autocomplete: 'one-time-code'
- if Postal::Config.oidc.enabled?
%p.fieldSet__text
This e-mail address should match the address provided by your OpenID Connect identity provider.
- if Postal::Config.oidc.local_authentication_enabled? && !@user.persisted?
.fieldSet__field
= f.label :password, :class => 'fieldSet__label'
.fieldSet__input= f.password_field :password, :class => 'input input--text', :placeholder => '•••••••••••', autocomplete: 'one-time-code'
.fieldSet__input
= f.password_field :password, :class => 'input input--text', :placeholder => '•••••••••••', autocomplete: 'one-time-code'
- if Postal::Config.oidc.enabled?
%p.fieldSet__text
You have enabled OIDC which means a password is not required. If you do not provide
a password this user will be matched to an OIDC identity based on the e-mail address
provided above. You may, however, enter a password and this user will be permitted to
use that password until they have successfully logged in with OIDC.
.fieldSet__field
= f.label :password_confirmation, "Confirm".html_safe, :class => 'fieldSet__label'
.fieldSet__input= f.password_field :password_confirmation, :class => 'input input--text', :placeholder => '•••••••••••', autocomplete: 'one-time-code'

عرض الملف

@@ -7,15 +7,20 @@
%ul.userList.u-margin
- for user in @users
%li.userList__item
= image_tag user.avatar_url, :class => 'userList__avatar'
.userList__details
%p.userList__name
= user.name
- if user.admin?
%span.userList__admin.label Admin
%span.userList__tag.label.label--blue Admin
- if Postal::Config.oidc.enabled?
- if user.oidc?
%span.userList__tag.label.label--green OIDC
- elsif !Postal::Config.oidc.local_authentication_enabled?
%span.userList__tag.label.label--orange Pending
%p.userList__email= user.email_address
%ul.userList__actions
%li= link_to "Edit permissions", [:edit, user]
%li= link_to "Edit user", [:edit, user]
%li= link_to "Delete user", user, :method => :delete, :data => {:confirm => "Are you sure you wish to revoke #{user.name}'s access?", :disable_with => "Deleting..."}, :remote => true, :class => 'userList__revoke'
%p.u-center= link_to "Add a new user", :new_user, :class => 'button button--positive'