Merge pull request 'Sign up for new account via invitation' (#9) from feature/signup_from_invite into master
Reviewed-on: #9
This commit is contained in:
commit
efe168b205
@ -12,6 +12,9 @@ steps:
|
|||||||
restore: true
|
restore: true
|
||||||
mount:
|
mount:
|
||||||
- vendor
|
- vendor
|
||||||
|
when:
|
||||||
|
branch:
|
||||||
|
- master
|
||||||
- name: rspec
|
- name: rspec
|
||||||
image: guildeducation/rails:2.7.1-12.19.0
|
image: guildeducation/rails:2.7.1-12.19.0
|
||||||
commands:
|
commands:
|
||||||
@ -30,6 +33,9 @@ steps:
|
|||||||
rebuild: true
|
rebuild: true
|
||||||
mount:
|
mount:
|
||||||
- vendor
|
- vendor
|
||||||
|
when:
|
||||||
|
branch:
|
||||||
|
- master
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- name: cache
|
- name: cache
|
||||||
|
@ -10,9 +10,9 @@ credentials, invites, donations, etc..
|
|||||||
* [x] Reset account password when logged in, via reset email
|
* [x] Reset account password when logged in, via reset email
|
||||||
* [x] Log in with admin permissions
|
* [x] Log in with admin permissions
|
||||||
* [x] View LDAP users as admin
|
* [x] View LDAP users as admin
|
||||||
|
* [x] Sign up for a new account via invitation
|
||||||
* [ ] List my donations
|
* [ ] List my donations
|
||||||
* [ ] Invite new users from your account
|
* [ ] Invite new users from your account
|
||||||
* [ ] Sign up for a new account via invite
|
|
||||||
* [ ] Sign up for a new account by donating upfront
|
* [ ] Sign up for a new account by donating upfront
|
||||||
* [ ] Sign up for a new account via proving contributions (via cryptographic signature)
|
* [ ] Sign up for a new account via proving contributions (via cryptographic signature)
|
||||||
* [ ] ...
|
* [ ] ...
|
||||||
|
35
app/assets/stylesheets/forms.scss
Normal file
35
app/assets/stylesheets/forms.scss
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
form {
|
||||||
|
.field_with_errors {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-signup {
|
||||||
|
label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=text], input[type=email], input[type=password] {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.at-sign, span.domain {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
color: #bc0101;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accept-terms {
|
||||||
|
margin-top: 2rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5em;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
}
|
@ -9,6 +9,12 @@ class ApplicationController < ActionController::Base
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def require_user_signed_out
|
||||||
|
if user_signed_in?
|
||||||
|
redirect_to root_path and return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def authorize_admin
|
def authorize_admin
|
||||||
http_status :forbidden unless current_user.is_admin?
|
http_status :forbidden unless current_user.is_admin?
|
||||||
end
|
end
|
||||||
|
49
app/controllers/invitations_controller.rb
Normal file
49
app/controllers/invitations_controller.rb
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
class InvitationsController < ApplicationController
|
||||||
|
before_action :require_user_signed_in, except: ["show"]
|
||||||
|
before_action :require_user_signed_out, only: ["show"]
|
||||||
|
|
||||||
|
layout "signup", only: ["show"]
|
||||||
|
|
||||||
|
# GET /invitations
|
||||||
|
def index
|
||||||
|
@invitations_unused = current_user.invitations.unused
|
||||||
|
@invitations_used = current_user.invitations.used
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /invitations/a-random-invitation-token
|
||||||
|
def show
|
||||||
|
token = session[:invitation_token] = params[:id]
|
||||||
|
|
||||||
|
if Invitation.where(token: token, used_at: nil).exists?
|
||||||
|
redirect_to signup_path and return
|
||||||
|
else
|
||||||
|
flash.now[:alert] = "This invitation either doesn't exist or has already been used."
|
||||||
|
http_status :unauthorized
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /invitations
|
||||||
|
def create
|
||||||
|
@invitation = Invitation.new(user: current_user)
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
if @invitation.save
|
||||||
|
format.html { redirect_to @invitation, notice: 'Invitation was successfully created.' }
|
||||||
|
format.json { render :show, status: :created, location: @invitation }
|
||||||
|
else
|
||||||
|
format.html { render :new }
|
||||||
|
format.json { render json: @invitation.errors, status: :unprocessable_entity }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /invitations/1
|
||||||
|
def destroy
|
||||||
|
@invitation = current_user.invitations.find(params[:id])
|
||||||
|
@invitation.destroy
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_to invitations_url }
|
||||||
|
format.json { head :no_content }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
109
app/controllers/signup_controller.rb
Normal file
109
app/controllers/signup_controller.rb
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
class SignupController < ApplicationController
|
||||||
|
before_action :require_user_signed_out
|
||||||
|
before_action :require_invitation
|
||||||
|
before_action :set_invitation
|
||||||
|
before_action :set_new_user, only: ["steps", "validate"]
|
||||||
|
|
||||||
|
layout "signup"
|
||||||
|
|
||||||
|
def index
|
||||||
|
@invited_by_name = @invitation.user.address
|
||||||
|
end
|
||||||
|
|
||||||
|
def steps
|
||||||
|
@step = params[:step].to_i
|
||||||
|
http_status :not_found unless [1,2,3].include?(@step)
|
||||||
|
@validation_error = session[:validation_error]
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate
|
||||||
|
session[:validation_error] = nil
|
||||||
|
|
||||||
|
case user_params.keys.first
|
||||||
|
when "cn"
|
||||||
|
@user.cn = user_params[:cn]
|
||||||
|
@user.valid?
|
||||||
|
session[:new_user] = @user
|
||||||
|
|
||||||
|
if @user.errors[:cn].present?
|
||||||
|
session[:validation_error] = @user.errors[:cn].first # Store user including validation errors
|
||||||
|
redirect_to signup_steps_path(1) and return
|
||||||
|
else
|
||||||
|
redirect_to signup_steps_path(2) and return
|
||||||
|
end
|
||||||
|
when "email"
|
||||||
|
@user.email = user_params[:email]
|
||||||
|
@user.valid?
|
||||||
|
session[:new_user] = @user
|
||||||
|
|
||||||
|
if @user.errors[:email].present?
|
||||||
|
session[:validation_error] = @user.errors[:email].first # Store user including validation errors
|
||||||
|
redirect_to signup_steps_path(2) and return
|
||||||
|
else
|
||||||
|
redirect_to signup_steps_path(3) and return
|
||||||
|
end
|
||||||
|
when "password"
|
||||||
|
@user.password = user_params[:password]
|
||||||
|
@user.password_confirmation = user_params[:password]
|
||||||
|
@user.valid?
|
||||||
|
session[:new_user] = @user
|
||||||
|
|
||||||
|
if @user.errors[:password].present?
|
||||||
|
session[:validation_error] = @user.errors[:password].first # Store user including validation errors
|
||||||
|
redirect_to signup_steps_path(3) and return
|
||||||
|
else
|
||||||
|
complete_signup
|
||||||
|
msg = "Almost done! We have sent you an email to confirm your address."
|
||||||
|
redirect_to(check_your_email_path, notice: msg) and return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def user_params
|
||||||
|
params.require(:user).permit(:cn, :email, :password)
|
||||||
|
end
|
||||||
|
|
||||||
|
def require_invitation
|
||||||
|
if session[:invitation_token].blank?
|
||||||
|
flash.now[:alert] = "You need an invitation to sign up for an account."
|
||||||
|
http_status :unauthorized
|
||||||
|
elsif !valid_invitation?(session[:invitation_token])
|
||||||
|
flash.now[:alert] = "This invitation either doesn't exist or has already been used."
|
||||||
|
http_status :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
@invitation = Invitation.find_by(token: session[:invitation_token])
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_invitation?(token)
|
||||||
|
Invitation.where(token: session[:invitation_token], used_at: nil).exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_invitation
|
||||||
|
@invitation = Invitation.find_by(token: session[:invitation_token])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_new_user
|
||||||
|
if session[:new_user].present?
|
||||||
|
@user = User.new(session[:new_user])
|
||||||
|
else
|
||||||
|
@user = User.new(ou: "kosmos.org")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def complete_signup
|
||||||
|
@user.save!
|
||||||
|
session[:new_user] = nil
|
||||||
|
session[:validation_error] = nil
|
||||||
|
|
||||||
|
CreateAccount.call(
|
||||||
|
username: @user.cn,
|
||||||
|
email: @user.email,
|
||||||
|
password: @user.password
|
||||||
|
)
|
||||||
|
|
||||||
|
@invitation.update! invited_user_id: @user.id, used_at: DateTime.now
|
||||||
|
end
|
||||||
|
end
|
2
app/helpers/invitations_helper.rb
Normal file
2
app/helpers/invitations_helper.rb
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
module InvitationsHelper
|
||||||
|
end
|
2
app/helpers/signup_helper.rb
Normal file
2
app/helpers/signup_helper.rb
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
module SignupHelper
|
||||||
|
end
|
15
app/models/concerns/email_validatable.rb
Normal file
15
app/models/concerns/email_validatable.rb
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
require 'mail'
|
||||||
|
|
||||||
|
module EmailValidatable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
class EmailValidator < ActiveModel::EachValidator
|
||||||
|
def validate_each(record, attribute, value)
|
||||||
|
begin
|
||||||
|
a = Mail::Address.new(value)
|
||||||
|
rescue Mail::Field::ParseError
|
||||||
|
record.errors[attribute] << (options[:message] || "is not a valid address")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
20
app/models/invitation.rb
Normal file
20
app/models/invitation.rb
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
class Invitation < ApplicationRecord
|
||||||
|
# Relations
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
# Validations
|
||||||
|
validates_presence_of :user
|
||||||
|
|
||||||
|
# Hooks
|
||||||
|
before_create :generate_token
|
||||||
|
|
||||||
|
# Scopes
|
||||||
|
scope :unused, -> { where(used_at: nil) }
|
||||||
|
scope :used, -> { where.not(used_at: nil) }
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def generate_token
|
||||||
|
self.token = SecureRandom.hex(8)
|
||||||
|
end
|
||||||
|
end
|
@ -1,4 +1,14 @@
|
|||||||
class User < ApplicationRecord
|
class User < ApplicationRecord
|
||||||
|
include EmailValidatable
|
||||||
|
|
||||||
|
# Relations
|
||||||
|
has_many :invitations, dependent: :destroy
|
||||||
|
|
||||||
|
validates_uniqueness_of :cn
|
||||||
|
validates_length_of :cn, :minimum => 3
|
||||||
|
validates_uniqueness_of :email
|
||||||
|
validates :email, email: true
|
||||||
|
|
||||||
# Include default devise modules. Others available are:
|
# Include default devise modules. Others available are:
|
||||||
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
|
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
|
||||||
devise :ldap_authenticatable,
|
devise :ldap_authenticatable,
|
||||||
@ -33,4 +43,13 @@ class User < ApplicationRecord
|
|||||||
false
|
false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def address
|
||||||
|
"#{self.cn}@#{self.ou}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_attribute?(attribute_name)
|
||||||
|
self.valid?
|
||||||
|
self.errors[attribute_name].blank?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
7
app/services/application_service.rb
Normal file
7
app/services/application_service.rb
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
class ApplicationService
|
||||||
|
# This enables executing a service's `#call` method directly via
|
||||||
|
# `MyService.call(args)`, without creating a class instance it first.
|
||||||
|
def self.call(*args, &block)
|
||||||
|
new(*args, &block).call
|
||||||
|
end
|
||||||
|
end
|
42
app/services/create_account.rb
Normal file
42
app/services/create_account.rb
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
class CreateAccount < ApplicationService
|
||||||
|
def initialize(args)
|
||||||
|
@username = args[:username]
|
||||||
|
@email = args[:email]
|
||||||
|
@password = args[:password]
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
add_ldap_document
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def add_ldap_document
|
||||||
|
dn = "cn=#{@username},ou=kosmos.org,cn=users,dc=kosmos,dc=org"
|
||||||
|
attr = {
|
||||||
|
objectclass: ["top", "account", "person", "extensibleObject"],
|
||||||
|
cn: @username,
|
||||||
|
sn: @username,
|
||||||
|
uid: @username,
|
||||||
|
mail: @email,
|
||||||
|
userPassword: Devise.ldap_auth_password_builder.call(@password)
|
||||||
|
}
|
||||||
|
|
||||||
|
ldap_client.add(dn: dn, attributes: attr)
|
||||||
|
end
|
||||||
|
|
||||||
|
def ldap_client
|
||||||
|
ldap_client ||= Net::LDAP.new host: ldap_config['host'],
|
||||||
|
port: ldap_config['port'],
|
||||||
|
encryption: ldap_config['ssl'],
|
||||||
|
auth: {
|
||||||
|
method: :simple,
|
||||||
|
username: ldap_config['admin_user'],
|
||||||
|
password: ldap_config['admin_password']
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def ldap_config
|
||||||
|
ldap_config ||= YAML.load(ERB.new(File.read("#{Rails.root}/config/ldap.yml")).result)[Rails.env]
|
||||||
|
end
|
||||||
|
end
|
@ -11,18 +11,6 @@
|
|||||||
Chat rooms and instant messaging (XMPP/Jabber)
|
Chat rooms and instant messaging (XMPP/Jabber)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid-item gitea">
|
|
||||||
<h3><%= link_to "Gitea", "https://gitea.kosmos.org" %></h3>
|
|
||||||
<p>
|
|
||||||
Code hosting and collaboration for software projects
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid-item gitea">
|
|
||||||
<h3><%= link_to "Drone CI", "https://drone.kosmos.org" %></h3>
|
|
||||||
<p>
|
|
||||||
Continuous integration for software projects, tied to our Gitea
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid-item wiki">
|
<div class="grid-item wiki">
|
||||||
<h3><%= link_to "Wiki", "https://wiki.kosmos.org" %></h3>
|
<h3><%= link_to "Wiki", "https://wiki.kosmos.org" %></h3>
|
||||||
<p>
|
<p>
|
||||||
@ -35,6 +23,18 @@
|
|||||||
Kosmos community forums and user support/help site
|
Kosmos community forums and user support/help site
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid-item gitea">
|
||||||
|
<h3><%= link_to "Gitea", "https://gitea.kosmos.org" %></h3>
|
||||||
|
<p>
|
||||||
|
Code hosting and collaboration for software projects
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid-item gitea">
|
||||||
|
<h3><%= link_to "Drone CI", "https://drone.kosmos.org" %></h3>
|
||||||
|
<p>
|
||||||
|
Continuous integration for software projects, tied to our Gitea
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
46
app/views/invitations/index.html.erb
Normal file
46
app/views/invitations/index.html.erb
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<section>
|
||||||
|
<h2>Invitations</h2>
|
||||||
|
<% if @invitations_unused.any? %>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>URL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% @invitations_unused.each do |invitation| %>
|
||||||
|
<tr>
|
||||||
|
<td><%= invitation_url(invitation.token) %></td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% else %>
|
||||||
|
<p>
|
||||||
|
You do not have any invitations to give away yet. All good
|
||||||
|
things come in time.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<% if @invitations_used.any? %>
|
||||||
|
<h3>Accepted Invitations</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>URL</th>
|
||||||
|
<th>Used at</th>
|
||||||
|
<th>Invited user</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% @invitations_used.each do |invitation| %>
|
||||||
|
<tr>
|
||||||
|
<td><%= invitation_url(invitation.token) %></td>
|
||||||
|
<td><%= invitation.used_at %></td>
|
||||||
|
<td><%= User.find(invitation.invited_user_id).address %></td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% end %>
|
41
app/views/layouts/signup.html.erb
Normal file
41
app/views/layouts/signup.html.erb
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Sign up | Kosmos Accounts</title>
|
||||||
|
<%= csrf_meta_tags %>
|
||||||
|
<%= csp_meta_tag %>
|
||||||
|
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
|
||||||
|
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="layout-signup">
|
||||||
|
<div id="wrapper">
|
||||||
|
<header>
|
||||||
|
<h1>
|
||||||
|
<span class ="project-name">Kosmos</span>
|
||||||
|
<span class ="site-name">Sign Up</span>
|
||||||
|
<!-- <span class="beta"><span class="bolt">⚡</span> beta</span> -->
|
||||||
|
</h1>
|
||||||
|
<% if user_signed_in? %>
|
||||||
|
<p class="current-user">
|
||||||
|
Signed in as <strong><%= current_user.cn %>@kosmos.org</strong>.
|
||||||
|
<%= link_to "Log out", destroy_user_session_path, method: :delete %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<% flash.each do |type, msg| %>
|
||||||
|
<div class="flash-msg <%= type %>">
|
||||||
|
<p><%= msg %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<%= yield %>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -1,2 +1,2 @@
|
|||||||
<h2>Access forbidden</h2>
|
<h2>Access forbidden</h2>
|
||||||
<p>Not with those shoes, buddy.</p>
|
<p>Sorry, you're not allowed to access this page.</p>
|
||||||
|
2
app/views/shared/status_not_found.html.erb
Normal file
2
app/views/shared/status_not_found.html.erb
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<h2>Not found</h2>
|
||||||
|
<p>Sorry, this page does not exist.</p>
|
0
app/views/shared/status_unauthorized.html.erb
Normal file
0
app/views/shared/status_unauthorized.html.erb
Normal file
12
app/views/signup/index.html.erb
Normal file
12
app/views/signup/index.html.erb
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<h2>Welcome</h2>
|
||||||
|
<p>
|
||||||
|
Hey there! You were invited to sign up for a Kosmos account by
|
||||||
|
<strong><%= @invited_by_name %></strong>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This invitation can only be used once, and sign-up is currently only possible
|
||||||
|
by invitation. Seems like you have good friends!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<%= link_to "Get started", signup_steps_path(1), class: "next-step" %>
|
||||||
|
</p>
|
61
app/views/signup/steps.html.erb
Normal file
61
app/views/signup/steps.html.erb
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<% case @step %>
|
||||||
|
<% when 1 %>
|
||||||
|
<h2>Choose a username</h2>
|
||||||
|
<%= form_for @user, :url => signup_validate_url do |f| %>
|
||||||
|
<div class="field">
|
||||||
|
<p>
|
||||||
|
<%= f.label :cn, 'Username' %><br />
|
||||||
|
<%= f.text_field :cn, autofocus: true, autocomplete: "username" %>
|
||||||
|
<span class="at-sign">@</span>
|
||||||
|
<span class="domain">kosmos.org</span>
|
||||||
|
</p>
|
||||||
|
<% if @validation_error.present? %>
|
||||||
|
<p class="error-msg">Username <%= @validation_error %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<p><%= f.submit "Continue" %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% when 2 %>
|
||||||
|
<h2>What's your email?</h2>
|
||||||
|
<%= form_for @user, :url => signup_validate_url do |f| %>
|
||||||
|
<div class="field">
|
||||||
|
<p>
|
||||||
|
<%= f.label :email, 'Email address' %><br />
|
||||||
|
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
|
||||||
|
</p>
|
||||||
|
<% if @validation_error.present? %>
|
||||||
|
<p class="error-msg">Email <%= @validation_error %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<p><%= f.submit "Continue" %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% when 3 %>
|
||||||
|
<h2>Choose a password</h2>
|
||||||
|
|
||||||
|
<%= form_for @user, :url => signup_validate_url do |f| %>
|
||||||
|
<div class="field">
|
||||||
|
<p>
|
||||||
|
<%= f.label :password, 'Password' %><br />
|
||||||
|
<%= f.password_field :password, autofocus: true %>
|
||||||
|
</p>
|
||||||
|
<% if @validation_error.present? %>
|
||||||
|
<p class="error-msg">Password <%= @validation_error %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<p class="accept-terms">
|
||||||
|
<small>
|
||||||
|
By clicking the button below, you accept our future Terms of Service
|
||||||
|
and Privacy Policy. Don't worry, they will be excellent!
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<p><%= f.submit "Create account" %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
@ -33,10 +33,11 @@ module Akkounts
|
|||||||
config.generators.system_tests = nil
|
config.generators.system_tests = nil
|
||||||
|
|
||||||
config.generators do |g|
|
config.generators do |g|
|
||||||
g.orm :active_record
|
g.orm :active_record
|
||||||
g.template_engine :erb
|
g.template_engine :erb
|
||||||
g.test_framework :rspec, fixture: true
|
g.test_framework :rspec, fixture: true
|
||||||
g.stylesheets false
|
g.fixture_replacement :factory_bot, suffix_factory: 'factory', dir: 'spec/factories'
|
||||||
|
g.stylesheets false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -43,4 +43,10 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Raises error for missing translations.
|
# Raises error for missing translations.
|
||||||
# config.action_view.raise_on_missing_translations = true
|
# config.action_view.raise_on_missing_translations = true
|
||||||
|
|
||||||
|
config.action_mailer.default_url_options = {
|
||||||
|
host: "accounts.kosmos.org",
|
||||||
|
protocol: "https",
|
||||||
|
from: "accounts@kosmos.org"
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
en:
|
en:
|
||||||
devise:
|
devise:
|
||||||
confirmations:
|
confirmations:
|
||||||
confirmed: "Your email address has been confirmed. You can now log in below."
|
confirmed: "Thanks for confirming your email address! Your account has been activated."
|
||||||
send_instructions: "You will receive an email with instructions for how to confirm your email address in a moment."
|
send_instructions: "You will receive an email with instructions for how to confirm your email address in a moment."
|
||||||
send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes."
|
send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes."
|
||||||
failure:
|
failure:
|
||||||
@ -15,7 +15,7 @@ en:
|
|||||||
not_found_in_database: "Invalid %{authentication_keys} or password."
|
not_found_in_database: "Invalid %{authentication_keys} or password."
|
||||||
timeout: "Your session expired. Please sign in again to continue."
|
timeout: "Your session expired. Please sign in again to continue."
|
||||||
unauthenticated: "You need to sign in or sign up before continuing."
|
unauthenticated: "You need to sign in or sign up before continuing."
|
||||||
unconfirmed: "You have to confirm your email address before continuing."
|
unconfirmed: "Please confirm your email address before continuing."
|
||||||
mailer:
|
mailer:
|
||||||
confirmation_instructions:
|
confirmation_instructions:
|
||||||
subject: "Confirmation instructions"
|
subject: "Confirmation instructions"
|
||||||
|
@ -1,11 +1,17 @@
|
|||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
devise_for :users
|
devise_for :users
|
||||||
|
|
||||||
|
get 'welcome', to: 'welcome#index'
|
||||||
|
get 'check_your_email', to: 'welcome#check_your_email'
|
||||||
|
|
||||||
|
get 'signup', to: 'signup#index'
|
||||||
|
match 'signup/:step', to: 'signup#steps', as: :signup_steps, via: [:get, :post]
|
||||||
|
post 'signup_validate', to: 'signup#validate'
|
||||||
|
|
||||||
get 'settings', to: 'settings#index'
|
get 'settings', to: 'settings#index'
|
||||||
post 'settings_reset_password', to: 'settings#reset_password'
|
post 'settings_reset_password', to: 'settings#reset_password'
|
||||||
|
|
||||||
get 'welcome', to: 'welcome#index'
|
resources :invitations, only: ['index', 'show', 'create', 'destroy']
|
||||||
get 'check_your_email', to: 'welcome#check_your_email'
|
|
||||||
|
|
||||||
namespace :admin do
|
namespace :admin do
|
||||||
root to: 'dashboard#index'
|
root to: 'dashboard#index'
|
||||||
|
14
db/migrate/20201130132533_create_invitations.rb
Normal file
14
db/migrate/20201130132533_create_invitations.rb
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
class CreateInvitations < ActiveRecord::Migration[6.0]
|
||||||
|
def change
|
||||||
|
create_table :invitations do |t|
|
||||||
|
t.string :token
|
||||||
|
t.integer :user_id
|
||||||
|
t.integer :invited_user_id
|
||||||
|
t.datetime :used_at
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
add_index :invitations, :user_id
|
||||||
|
add_index :invitations, :invited_user_id
|
||||||
|
end
|
||||||
|
end
|
11
db/schema.rb
11
db/schema.rb
@ -10,7 +10,16 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema.define(version: 2020_11_09_090739) do
|
ActiveRecord::Schema.define(version: 2020_11_30_132533) do
|
||||||
|
|
||||||
|
create_table "invitations", force: :cascade do |t|
|
||||||
|
t.string "token"
|
||||||
|
t.integer "user_id"
|
||||||
|
t.integer "invited_user_id"
|
||||||
|
t.datetime "used_at"
|
||||||
|
t.datetime "created_at", precision: 6, null: false
|
||||||
|
t.datetime "updated_at", precision: 6, null: false
|
||||||
|
end
|
||||||
|
|
||||||
create_table "users", force: :cascade do |t|
|
create_table "users", force: :cascade do |t|
|
||||||
t.string "cn"
|
t.string "cn"
|
||||||
|
13
lib/tasks/invitations.rake
Normal file
13
lib/tasks/invitations.rake
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
namespace :invitations do
|
||||||
|
desc "Generate invitations for all users"
|
||||||
|
task :generate_for_all_users, [:amount_per_user] => :environment do |t, args|
|
||||||
|
count = 0
|
||||||
|
User.all.each do |user|
|
||||||
|
args[:amount_per_user].to_i.times do
|
||||||
|
user.invitations << Invitation.create(user: user)
|
||||||
|
count += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
puts "Created #{count} new invitations"
|
||||||
|
end
|
||||||
|
end
|
6
spec/factories/invitations.rb
Normal file
6
spec/factories/invitations.rb
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
FactoryBot.define do
|
||||||
|
factory :invitation do
|
||||||
|
token { "abcdef123456" }
|
||||||
|
user
|
||||||
|
end
|
||||||
|
end
|
103
spec/features/signup_spec.rb
Normal file
103
spec/features/signup_spec.rb
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Signup", type: :feature do
|
||||||
|
let(:user) { create :user }
|
||||||
|
|
||||||
|
describe "Invitation handling" do
|
||||||
|
before do
|
||||||
|
@unused_invitation = Invitation.create(user: user)
|
||||||
|
@used_invitation = Invitation.create(user: user)
|
||||||
|
@used_invitation.update_attribute :used_at, DateTime.now - 1.day
|
||||||
|
end
|
||||||
|
|
||||||
|
scenario "Follow link for non-existing invitation" do
|
||||||
|
visit invitation_url(id: "123")
|
||||||
|
|
||||||
|
within ".flash-msg.alert" do
|
||||||
|
expect(page).to have_content("doesn't exist")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
scenario "Follow link for used invitation" do
|
||||||
|
visit invitation_url(id: @used_invitation.token)
|
||||||
|
|
||||||
|
within ".flash-msg.alert" do
|
||||||
|
expect(page).to have_content("has already been used")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
scenario "Follow link for unused invitation" do
|
||||||
|
visit invitation_url(id: @unused_invitation.token)
|
||||||
|
|
||||||
|
expect(current_url).to eq(signup_url)
|
||||||
|
expect(page).to have_content("Welcome")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Signup steps" do
|
||||||
|
before do
|
||||||
|
@invitation = Invitation.create(user: user)
|
||||||
|
visit invitation_url(id: @invitation.token)
|
||||||
|
click_link "Get started"
|
||||||
|
end
|
||||||
|
|
||||||
|
scenario "Successful signup (happy path galore)" do
|
||||||
|
expect(page).to have_content("Choose a username")
|
||||||
|
|
||||||
|
fill_in "user_cn", with: "tony"
|
||||||
|
click_button "Continue"
|
||||||
|
expect(page).to have_content("What's your email?")
|
||||||
|
|
||||||
|
fill_in "user_email", with: "tony@example.com"
|
||||||
|
click_button "Continue"
|
||||||
|
expect(page).to have_content("Choose a password")
|
||||||
|
|
||||||
|
expect(CreateAccount).to receive(:call)
|
||||||
|
.with(username: "tony", email: "tony@example.com", password: "a-valid-password")
|
||||||
|
.and_return(true)
|
||||||
|
|
||||||
|
fill_in "user_password", with: "a-valid-password"
|
||||||
|
click_button "Create account"
|
||||||
|
within ".flash-msg.notice" do
|
||||||
|
expect(page).to have_content("confirm your address")
|
||||||
|
end
|
||||||
|
expect(page).to have_content("close this window or tab now")
|
||||||
|
expect(User.last.confirmed_at).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
scenario "Validation errors" do
|
||||||
|
fill_in "user_cn", with: "t"
|
||||||
|
click_button "Continue"
|
||||||
|
expect(page).to have_content("Username is too short")
|
||||||
|
fill_in "user_cn", with: "jimmy"
|
||||||
|
click_button "Continue"
|
||||||
|
expect(page).to have_content("Username has already been taken")
|
||||||
|
fill_in "user_cn", with: "tony"
|
||||||
|
click_button "Continue"
|
||||||
|
|
||||||
|
fill_in "user_email", with: "tony@"
|
||||||
|
click_button "Continue"
|
||||||
|
expect(page).to have_content("Email is not a valid address")
|
||||||
|
fill_in "user_email", with: ""
|
||||||
|
click_button "Continue"
|
||||||
|
expect(page).to have_content("Email can't be blank")
|
||||||
|
fill_in "user_email", with: "tony@example.com"
|
||||||
|
click_button "Continue"
|
||||||
|
|
||||||
|
fill_in "user_password", with: "123456"
|
||||||
|
click_button "Create account"
|
||||||
|
expect(page).to have_content("Password is too short")
|
||||||
|
|
||||||
|
expect(CreateAccount).to receive(:call)
|
||||||
|
.with(username: "tony", email: "tony@example.com", password: "a-valid-password")
|
||||||
|
.and_return(true)
|
||||||
|
|
||||||
|
fill_in "user_password", with: "a-valid-password"
|
||||||
|
click_button "Create account"
|
||||||
|
within ".flash-msg.notice" do
|
||||||
|
expect(page).to have_content("confirm your address")
|
||||||
|
end
|
||||||
|
expect(User.last.cn).to eq("tony")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
7
spec/fixtures/users.yml
vendored
7
spec/fixtures/users.yml
vendored
@ -1,7 +0,0 @@
|
|||||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
|
||||||
|
|
||||||
one:
|
|
||||||
uid: MyString
|
|
||||||
|
|
||||||
two:
|
|
||||||
uid: MyString
|
|
15
spec/helpers/invitations_helper_spec.rb
Normal file
15
spec/helpers/invitations_helper_spec.rb
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
# Specs in this file have access to a helper object that includes
|
||||||
|
# the InvitationsHelper. For example:
|
||||||
|
#
|
||||||
|
# describe InvitationsHelper do
|
||||||
|
# describe "string concat" do
|
||||||
|
# it "concats two strings with spaces" do
|
||||||
|
# expect(helper.concat_strings("this","that")).to eq("this that")
|
||||||
|
# end
|
||||||
|
# end
|
||||||
|
# end
|
||||||
|
RSpec.describe InvitationsHelper, type: :helper do
|
||||||
|
pending "add some examples to (or delete) #{__FILE__}"
|
||||||
|
end
|
15
spec/helpers/signup_helper_spec.rb
Normal file
15
spec/helpers/signup_helper_spec.rb
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
# Specs in this file have access to a helper object that includes
|
||||||
|
# the SignupHelper. For example:
|
||||||
|
#
|
||||||
|
# describe SignupHelper do
|
||||||
|
# describe "string concat" do
|
||||||
|
# it "concats two strings with spaces" do
|
||||||
|
# expect(helper.concat_strings("this","that")).to eq("this that")
|
||||||
|
# end
|
||||||
|
# end
|
||||||
|
# end
|
||||||
|
RSpec.describe SignupHelper, type: :helper do
|
||||||
|
pending "add some examples to (or delete) #{__FILE__}"
|
||||||
|
end
|
44
spec/models/invitation_spec.rb
Normal file
44
spec/models/invitation_spec.rb
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Invitation, type: :model do
|
||||||
|
let(:user) { build :user }
|
||||||
|
|
||||||
|
describe "tokens" do
|
||||||
|
it "requires a user" do
|
||||||
|
expect { Invitation.create! }.to raise_error(ActiveRecord::RecordInvalid)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "generates a random token when created" do
|
||||||
|
invitation = Invitation.new(user: user)
|
||||||
|
invitation.save!
|
||||||
|
token = invitation.token
|
||||||
|
expect(token).to be_a(String)
|
||||||
|
expect(token.length).to eq(16)
|
||||||
|
|
||||||
|
invitation_2 = Invitation.create(user: user)
|
||||||
|
expect(token).not_to eq(invitation_2.token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "scopes" do
|
||||||
|
before do
|
||||||
|
@unused_invitation = create :invitation, user: user
|
||||||
|
@used_invitation = create :invitation, user: user, used_at: DateTime.now
|
||||||
|
@used_invitation_2 = create :invitation, user: user, used_at: DateTime.now
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#unused" do
|
||||||
|
it "returns unused invitations" do
|
||||||
|
expect(Invitation.unused.count).to eq(1)
|
||||||
|
expect(Invitation.unused.first).to eq(@unused_invitation)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#used" do
|
||||||
|
it "returns used invitations" do
|
||||||
|
expect(Invitation.used.count).to eq(2)
|
||||||
|
expect(Invitation.used.first).to eq(@used_invitation)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -20,4 +20,12 @@ RSpec.describe User, type: :model do
|
|||||||
expect(user.is_admin?).to be false
|
expect(user.is_admin?).to be false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#address" do
|
||||||
|
let(:user) { build :user, cn: "jimmy", ou: "kosmos.org" }
|
||||||
|
|
||||||
|
it "returns the user address" do
|
||||||
|
expect(user.address).to eq("jimmy@kosmos.org")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
14
spec/requests/signup_request_spec.rb
Normal file
14
spec/requests/signup_request_spec.rb
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe "Signups", type: :request do
|
||||||
|
|
||||||
|
describe "GET /index" do
|
||||||
|
context "without invitation" do
|
||||||
|
it "returns http unauthorized" do
|
||||||
|
get "/signup"
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
33
spec/services/create_account_spec.rb
Normal file
33
spec/services/create_account_spec.rb
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe CreateAccount, type: :model do
|
||||||
|
let(:ldap_client_mock) { instance_double(Net::LDAP) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(service).to receive(:ldap_client).and_return(ldap_client_mock)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#add_ldap_document" do
|
||||||
|
let(:service) { CreateAccount.new(
|
||||||
|
username: 'halfinney',
|
||||||
|
email: 'halfinney@example.com',
|
||||||
|
password: 'remember-remember-the-5th-of-november'
|
||||||
|
)}
|
||||||
|
|
||||||
|
it "creates a new document with the correct attributes" do
|
||||||
|
expect(ldap_client_mock).to receive(:add).with(
|
||||||
|
dn: "cn=halfinney,ou=kosmos.org,cn=users,dc=kosmos,dc=org",
|
||||||
|
attributes: {
|
||||||
|
objectclass: ["top", "account", "person", "extensibleObject"],
|
||||||
|
cn: "halfinney",
|
||||||
|
sn: "halfinney",
|
||||||
|
uid: "halfinney",
|
||||||
|
mail: "halfinney@example.com",
|
||||||
|
userPassword: /^{SSHA512}.{171}=/
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
service.send(:add_ldap_document)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
x
Reference in New Issue
Block a user