Merge pull request 'Sign up for new account via invitation' (#9) from feature/signup_from_invite into master
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing

Reviewed-on: #9
This commit is contained in:
Râu Cao 2020-12-04 12:30:28 +00:00
commit efe168b205
37 changed files with 784 additions and 30 deletions

View File

@ -12,6 +12,9 @@ steps:
restore: true
mount:
- vendor
when:
branch:
- master
- name: rspec
image: guildeducation/rails:2.7.1-12.19.0
commands:
@ -30,6 +33,9 @@ steps:
rebuild: true
mount:
- vendor
when:
branch:
- master
volumes:
- name: cache

View File

@ -10,9 +10,9 @@ credentials, invites, donations, etc..
* [x] Reset account password when logged in, via reset email
* [x] Log in with admin permissions
* [x] View LDAP users as admin
* [x] Sign up for a new account via invitation
* [ ] List my donations
* [ ] 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 via proving contributions (via cryptographic signature)
* [ ] ...

View 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;
}
}

View File

@ -9,6 +9,12 @@ class ApplicationController < ActionController::Base
end
end
def require_user_signed_out
if user_signed_in?
redirect_to root_path and return
end
end
def authorize_admin
http_status :forbidden unless current_user.is_admin?
end

View 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

View 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

View File

@ -0,0 +1,2 @@
module InvitationsHelper
end

View File

@ -0,0 +1,2 @@
module SignupHelper
end

View 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
View 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

View File

@ -1,4 +1,14 @@
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:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :ldap_authenticatable,
@ -33,4 +43,13 @@ class User < ApplicationRecord
false
end
end
def address
"#{self.cn}@#{self.ou}"
end
def valid_attribute?(attribute_name)
self.valid?
self.errors[attribute_name].blank?
end
end

View 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

View 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

View File

@ -11,18 +11,6 @@
Chat rooms and instant messaging (XMPP/Jabber)
</p>
</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">
<h3><%= link_to "Wiki", "https://wiki.kosmos.org" %></h3>
<p>
@ -35,6 +23,18 @@
Kosmos community forums and user support/help site
</p>
</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>
</section>

View 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 %>

View 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>

View File

@ -1,2 +1,2 @@
<h2>Access forbidden</h2>
<p>Not with those shoes, buddy.</p>
<p>Sorry, you're not allowed to access this page.</p>

View File

@ -0,0 +1,2 @@
<h2>Not found</h2>
<p>Sorry, this page does not exist.</p>

View 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>

View 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 %>

View File

@ -33,10 +33,11 @@ module Akkounts
config.generators.system_tests = nil
config.generators do |g|
g.orm :active_record
g.template_engine :erb
g.test_framework :rspec, fixture: true
g.stylesheets false
g.orm :active_record
g.template_engine :erb
g.test_framework :rspec, fixture: true
g.fixture_replacement :factory_bot, suffix_factory: 'factory', dir: 'spec/factories'
g.stylesheets false
end
end
end

View File

@ -43,4 +43,10 @@ Rails.application.configure do
# Raises error for missing translations.
# 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

View File

@ -3,7 +3,7 @@
en:
devise:
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_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:
@ -15,7 +15,7 @@ en:
not_found_in_database: "Invalid %{authentication_keys} or password."
timeout: "Your session expired. Please sign in again to continue."
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:
confirmation_instructions:
subject: "Confirmation instructions"

View File

@ -1,11 +1,17 @@
Rails.application.routes.draw do
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'
post 'settings_reset_password', to: 'settings#reset_password'
get 'welcome', to: 'welcome#index'
get 'check_your_email', to: 'welcome#check_your_email'
resources :invitations, only: ['index', 'show', 'create', 'destroy']
namespace :admin do
root to: 'dashboard#index'

View 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

View File

@ -10,7 +10,16 @@
#
# 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|
t.string "cn"

View 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

View File

@ -0,0 +1,6 @@
FactoryBot.define do
factory :invitation do
token { "abcdef123456" }
user
end
end

View 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

View File

@ -1,7 +0,0 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
uid: MyString
two:
uid: MyString

View 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

View 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

View 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

View File

@ -20,4 +20,12 @@ RSpec.describe User, type: :model do
expect(user.is_admin?).to be false
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

View 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

View 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