Log in by email without Persona

footer-fixes
Marcin Kulik 10 years ago
parent 53c2598c48
commit 0cd4559f5a

@ -23,4 +23,6 @@ $(function() {
e.preventDefault();
$('.embed-box').slideDown('fast');
});
$('.login-form input[type=email]').focus();
});

@ -1,22 +1,7 @@
.new-user
.new_user
margin-top: 30px
#user_username
width: 200px
.returning-user
border-left: 1px solid #bbb
padding-left: 30px
ul.login
list-style: none
padding: 0
margin: 20px 0
li
display: inline-block
margin-right: 15px
.login-form
input.email
width: 250px
margin-right: 10px
.profile-page
.cinema

@ -61,7 +61,7 @@ class ApplicationController < ActionController::Base
render json: "Unauthenticated", status: 401
else
store_location
redirect_to login_path, notice: "Please sign in to proceed"
redirect_to new_login_path, notice: "Please log in to proceed"
end
end

@ -0,0 +1,27 @@
class LoginsController < ApplicationController
def new; end
def create
email = params[:email].strip
if login_service.login(email)
redirect_to sent_login_path, flash: { email_recipient: email }
else
@invalid_email = true
render :new
end
end
def sent
@email_recipient = flash[:email_recipient]
redirect_to new_login_path unless @email_recipient
end
private
def login_service
EmailLoginService.new
end
end

@ -1,36 +1,33 @@
class SessionsController < ApplicationController
def new; end
def create
user = find_user
user = login_service.validate(params[:token].to_s.strip)
if user
self.current_user = user
redirect_back_or_to root_url, :notice => "Welcome back!"
redirect_back_or_to profile_path(user), notice: login_notice(user)
else
store[:new_user_email] = omniauth_credentials.email
redirect_to new_user_path
render :error
end
end
def destroy
self.current_user = nil
redirect_to root_path, :notice => "See you later!"
end
def failure
redirect_to root_path, :alert => "Authentication failed. Maybe try again?"
redirect_to root_path, notice: "See you later!"
end
private
def store
session
def login_service
EmailLoginService.new
end
def find_user
User.for_email(omniauth_credentials.email)
def login_notice(user)
if user.first_login?
"Welcome to Asciinema!"
else
"Welcome back!"
end
end
end

@ -2,7 +2,7 @@ class UserDecorator < ApplicationDecorator
include AvatarHelper
def link
wrap_with_link(username || temporary_username || "user:#{id}")
wrap_with_link(display_name)
end
def img_link
@ -29,11 +29,14 @@ class UserDecorator < ApplicationDecorator
def wrap_with_link(html)
if id
title = username || temporary_username || 'anonymous user'
h.link_to html, h.profile_path(model), title: title
h.link_to html, h.profile_path(model), title: display_name
else
html
end
end
def display_name
username || temporary_username || "user:#{id}"
end
end

@ -0,0 +1,14 @@
class Notifications < ActionMailer::Base
default from: CFG.from_email
def self.delay_login_request(user_id, token)
delay.login_request(user_id, token)
end
def login_request(user_id, token)
user = User.find(user_id)
@login_url = login_token_url(token)
mail to: user.email
end
end

@ -2,6 +2,8 @@ class User < ActiveRecord::Base
USERNAME_FORMAT = /\A[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]\z/
InvalidEmailError = Class.new(StandardError)
has_many :api_tokens, :dependent => :destroy
has_many :asciicasts, :dependent => :destroy
has_many :comments, :dependent => :destroy
@ -22,13 +24,16 @@ class User < ActiveRecord::Base
before_create :generate_auth_token
def self.for_credentials(credentials)
where(provider: credentials.provider, uid: credentials.uid).first
end
def self.for_email!(email)
raise InvalidEmailError if email.blank?
self.where(email: email).first_or_create!
def self.for_email(email)
if email
where(email: email).first
rescue ActiveRecord::RecordInvalid => e
if e.record.errors[:email].present?
raise InvalidEmailError
else
raise e
end
end
@ -120,6 +125,10 @@ class User < ActiveRecord::Base
CFG.admin_ids.include?(id)
end
def first_login?
expiring_tokens.count == 1
end
private
def generate_auth_token

@ -0,0 +1,21 @@
class EmailLoginService
def login(email)
user = User.for_email!(email)
expiring_token = ExpiringToken.create_for_user(user)
Notifications.delay_login_request(expiring_token.user_id, expiring_token.token)
true
rescue User::InvalidEmailError
false
end
def validate(token)
expiring_token = ExpiringToken.active_for_token(token)
if expiring_token
expiring_token.use!
expiring_token.user
end
end
end

@ -33,4 +33,4 @@ header.navbar.navbar-default[role="navigation"]
a.logout href=logout_path Log out
- else
li
a#log-in href=login_path Sign in
a#log-in href=new_login_path Log in

@ -0,0 +1,17 @@
.container
.row
.col-md-12
h1 Log in
br
p Log in to Asciinema using your email address:
= form_tag login_path, class: "form-inline login-form" do
.form-group
input.form-control.email name="email" type="email" placeholder="Enter email"
button.btn.btn-primary type="submit" Log in
- if @invalid_email
br
p.text-danger This email address doesn't seem to be correct.

@ -0,0 +1,14 @@
.container
.row
.col-md-12
h1 You're almost in!
br
p
| We sent a login link to
strong = @email_recipient
' . Please click on the link to login to your account.
' The link is valid for next 15 minutes.
p If it doesn't arrive, check your spam folder, or enter your email again to send another login link.

@ -0,0 +1,7 @@
Hello,
Click the following link to log in to asciinema.org:
<%= @login_url %>
If you did not initiate this request, just ignore this email. The request will expire shortly.

@ -0,0 +1,15 @@
.container
.row
.col-md-12
h1 Invalid login link
br
p Your login link is either invalid or has expired.
p
' Login links are valid for 15 minutes. If you think this may be the case then
a href=new_login_path request a new login link
| .
p If you're copy-pasting the link check if the link hasn't been corrupted by your email client's message formatting.

@ -1,8 +0,0 @@
.container
.row
.col-md-12
h1 Sign in
p Sign in using your email address by clicking on the button below:
= link_to image_tag('email_sign_in_black.png', :title => "Sign in with your email"), '#', :id => 'persona-button'

@ -22,6 +22,7 @@ module Asciinema
attribute :admin_ids, Array[Integer]
attribute :host, String, default: 'localhost:3000'
attribute :smtp_settings, Hash
attribute :from_email, String, default: "Asciinema <hello@asciinema.org>"
alias_method :local_persona_js?, :local_persona_js

@ -20,4 +20,6 @@
# available at http://guides.rubyonrails.org/i18n.html.
en:
hello: "Hello world"
notifications:
login_request:
subject: "Login request"

@ -22,11 +22,13 @@ Rails.application.routes.draw do
get "/docs" => "docs#show", :page => 'getting-started', :as => :docs_index
get "/docs/:page" => "docs#show", :as => :docs
get "/auth/browser_id/callback" => "sessions#create"
get "/auth/:provider/callback" => "account_merges#create"
get "/auth/failure" => "sessions#failure"
resource :login, only: [:new, :create] do
get :sent
end
get "/login" => redirect("/login/new")
get "/login" => "sessions#new"
get "/login/:token" => "sessions#create", as: :login_token
get "/logout" => "sessions#destroy"
get "/connect/:api_token" => "api_tokens#create"

@ -5,7 +5,7 @@ describe ApiTokensController do
describe '#create' do
subject { get :create, api_token: 'a-toh-can' }
let(:user) { double('user', assign_api_token: nil) }
let(:user) { double('user', assign_api_token: nil, username: 'foobar') }
before do
login_as(user)
@ -18,9 +18,9 @@ describe ApiTokensController do
subject
end
it { should redirect_to(login_path) }
it { should redirect_to(new_login_path) }
specify { expect(flash[:notice]).to match(/sign in to proceed/) }
specify { expect(flash[:notice]).to match(/log in to proceed/) }
end
context "when assigning succeeds" do
@ -29,7 +29,7 @@ describe ApiTokensController do
subject
end
it { should redirect_to(profile_path(user)) }
it { should redirect_to(public_profile_path(username: 'foobar')) }
specify { expect(flash[:notice]).to_not be_blank }
end

@ -52,13 +52,13 @@ describe FakesController do
end
context "when normal request" do
it "redirects to login_path" do
it "redirects to login page" do
expect(@controller).to receive(:store_location)
get :foo
expect(flash[:notice]).to eq("Please sign in to proceed")
should redirect_to(login_path)
expect(flash[:notice]).to eq("Please log in to proceed")
should redirect_to(new_login_path)
end
end
end

@ -1,8 +1,8 @@
require 'rails_helper'
shared_examples_for 'guest user trying to modify' do
it { should redirect_to(login_path) }
specify { expect(flash[:notice]).to match(/sign in to proceed/) }
it { should redirect_to(new_login_path) }
specify { expect(flash[:notice]).to match(/log in to proceed/) }
end
shared_examples_for 'non-owner user trying to modify' do
@ -177,7 +177,7 @@ describe AsciicastsController do
make_request
end
it { should redirect_to(profile_path(user)) }
it { should redirect_to(public_profile_path(username: 'nick')) }
specify { expect(flash[:notice]).to match(/was deleted/) }
end

@ -0,0 +1,65 @@
require 'rails_helper'
describe LoginsController do
describe "#new" do
subject { get :new }
it "renders 'new' template" do
should render_template('new')
end
end
describe "#create" do
subject { post :create, email: "foo@example.com" }
let(:login_service) { double(:login_service) }
before do
allow(controller).to receive(:login_service) { login_service }
allow(login_service).to receive(:login).with("foo@example.com") { login_success }
end
context "when login succeeds" do
let(:login_success) { true }
it "sets email_recipient in flash" do
subject
expect(flash[:email_recipient]).to eq("foo@example.com")
end
it "redirects to 'sent' page" do
should redirect_to(sent_login_path)
end
end
context "when login fails" do
let(:login_success) { false }
it "renders 'new' template" do
should render_template('new')
end
end
end
describe "#sent" do
subject { get :sent, {}, {}, { email_recipient: email_recipient } }
context "when email_recipient is set in flash" do
let(:email_recipient) { "foo@example.com" }
it "renders 'sent' template" do
should render_template('sent')
end
end
context "when email_recipient is not set in flash" do
let(:email_recipient) { nil }
it "redirects to login page" do
should redirect_to(new_login_path)
end
end
end
end

@ -2,31 +2,17 @@ require 'rails_helper'
describe SessionsController do
let(:store) { {} }
describe "#create" do
subject { get :create, token: 'the-to-ken' }
before do
allow(controller).to receive(:store) { store }
end
describe '#new' do
subject { get :new }
it 'renders "new" template' do
should render_template('new')
end
end
describe '#create' do
subject { get :create, provider: 'twitter' }
let(:credentials) { double('credentials', email: 'foo@bar.com') }
let(:login_service) { double(:login_service) }
before do
allow(controller).to receive(:omniauth_credentials) { credentials }
allow(User).to receive(:for_email).with('foo@bar.com') { user }
allow(controller).to receive(:login_service) { login_service }
allow(login_service).to receive(:validate).with('the-to-ken') { user }
end
context "when user can be found for given credentials" do
context "when token is successfully validated" do
let(:user) { stub_model(User) }
before do
@ -35,29 +21,25 @@ describe SessionsController do
subject
end
it 'sets the current_user' do
it "sets the current_user" do
expect(controller).to have_received(:current_user=).with(user)
end
it 'redirects to the root_path with a notice' do
it "redirects to the user's profile with a notice" do
expect(flash[:notice]).to_not be_blank
should redirect_to(root_path)
should redirect_to(unnamed_user_path(user))
end
end
context "when user can't be found for given credentials" do
context "when token is not validated" do
let(:user) { nil }
before do
subject
end
it 'stores the email' do
expect(store[:new_user_email]).to eq('foo@bar.com')
end
it 'redirects to the new user page' do
should redirect_to(new_user_path)
it "displays error" do
should render_template('error')
end
end
end
@ -69,7 +51,7 @@ describe SessionsController do
get :destroy
end
it 'sets current_user to nil' do
it "sets current_user to nil" do
expect(controller).to have_received(:current_user=).with(nil)
end
@ -79,15 +61,4 @@ describe SessionsController do
end
end
describe "#failure" do
before do
get :failure
end
it "redirects to root_url with an alert" do
expect(flash[:alert]).to_not be_blank
should redirect_to(root_path)
end
end
end

@ -145,7 +145,7 @@ describe UsersController do
it "redirects to login page" do
subject
expect(response).to redirect_to(login_path)
expect(response).to redirect_to(new_login_path)
end
end
end
@ -162,7 +162,7 @@ describe UsersController do
it "redirects to profile" do
subject
expect(response).to redirect_to(profile_path('batman'))
expect(response).to redirect_to(public_profile_path(username: 'batman'))
end
context "when update fails" do
@ -186,7 +186,7 @@ describe UsersController do
it "redirects to login page" do
subject
expect(response).to redirect_to(login_path)
expect(response).to redirect_to(new_login_path)
end
end
end

@ -7,8 +7,6 @@ describe UserDecorator do
describe '#link' do
subject { decorator.link }
let(:user) { User.new }
before do
RSpec::Mocks.configuration.verify_partial_doubles = false # for stubbing "h"
end
@ -22,9 +20,7 @@ describe UserDecorator do
end
context "when user has username" do
before do
user.username = "satyr"
end
let(:user) { create(:user, username: "satyr") }
it "is a username link to user's profile" do
expect(subject).to eq('<a href="/path" title="satyr">satyr</a>')
@ -32,22 +28,18 @@ describe UserDecorator do
end
context "when user has temporary username" do
before do
user.temporary_username = "temp"
end
let(:user) { create(:unconfirmed_user, temporary_username: "frost") }
it "is user's username" do
expect(subject).to eq('temp')
it "is a temporary username link to user's profile" do
expect(subject).to eq('<a href="/path" title="frost">frost</a>')
end
end
context "when user has neither username nor temporary username" do
before do
user.username = user.temporary_username = nil
end
context "when user has not username nor temporary username" do
let(:user) { create(:unconfirmed_user, temporary_username: nil) }
it 'is "anonymous"' do
expect(subject).to eq('anonymous')
it "is id-based link to user's profile" do
expect(subject).to eq(%(<a href="/path" title="user:#{user.id}">user:#{user.id}</a>))
end
end
end
@ -55,8 +47,6 @@ describe UserDecorator do
describe '#img_link' do
subject { decorator.img_link }
let(:user) { User.new }
before do
RSpec::Mocks.configuration.verify_partial_doubles = false # for stubbing "h"
end
@ -70,20 +60,24 @@ describe UserDecorator do
RSpec::Mocks.configuration.verify_partial_doubles = true
end
context "when user has username" do
before do
user.username = "satyr"
end
context "when user is persisted and has username" do
let(:user) { create(:user, username: "satyr") }
it "is an avatar link to user's profile" do
expect(subject).to eq('<a href="/path" title="satyr"><img ...></a>')
end
end
context "when user has no username" do
before do
user.username = nil
context "when user is persisted and has temporary username" do
let(:user) { create(:unconfirmed_user, temporary_username: "frost") }
it "is an avatar link to user's profile" do
expect(subject).to eq('<a href="/path" title="frost"><img ...></a>')
end
end
context "when user is not persisted" do
let(:user) { User.new }
it "is user's avatar image" do
expect(subject).to eq('<img ...>')

@ -6,7 +6,7 @@ feature "User's profile" do
let!(:asciicast) { create(:asciicast, :user => user, :title => 'Tricks!') }
scenario 'Visiting' do
visit profile_path(user)
visit public_profile_path(username: user.username)
expect(page).to have_content(/1 asciicast by #{user.username}/i)
expect(page).to have_link('Tricks!')

@ -0,0 +1,3 @@
Notifications#login_request
Hi, find me in app/views/notifications/login_request

@ -0,0 +1,20 @@
require "rails_helper"
RSpec.describe Notifications, :type => :mailer do
describe "login_request" do
let(:mail) { Notifications.login_request(user.id, "the-to-ken") }
let(:user) { create(:user, email: "foo@example.com") }
it "renders the headers" do
expect(mail.subject).to eq("Login request")
expect(mail.to).to eq(["foo@example.com"])
expect(mail.from).to eq(["hello@asciinema.org"])
end
it "renders the body" do
expect(mail.body.encoded).to match("Click")
expect(mail.body.encoded).to match(login_token_path("the-to-ken"))
end
end
end

@ -43,52 +43,6 @@ describe User do
end
end
describe '.for_credentials' do
subject { described_class.for_credentials(credentials) }
let!(:user) { create(:user, provider: 'twitter', uid: '1') }
context "when there is matching record" do
let(:credentials) { double('credentials', provider: 'twitter', uid: '1') }
it { should eq(user) }
end
context "when there isn't matching record" do
let(:credentials) { double('credentials', provider: 'twitter', uid: '2') }
it { should be(nil) }
end
end
describe '.for_email' do
subject { described_class.for_email(email) }
let!(:user) { create(:user, email: 'foo@bar.com') }
context "when there is matching record" do
let(:email) { 'foo@bar.com' }
it { should eq(user) }
end
context "when there isn't matching record" do
let(:email) { 'qux@bar.com' }
it { should be(nil) }
end
context "when given email is nil" do
let(:email) { nil }
before do
create(:unconfirmed_user, email: nil)
end
it { should be(nil) }
end
end
describe '.for_api_token' do
subject { described_class.for_api_token(token) }

@ -0,0 +1,99 @@
require 'rails_helper'
describe EmailLoginService do
let(:creator) { described_class.new }
describe "#login" do
subject { creator.login(email) }
let(:email) { "me@example.com" }
context "when given email is blank" do
let(:email) { nil }
it "returns false" do
expect(subject).to be(false)
end
end
context "when given email is invalid" do
let(:email) { "oops" }
it "returns false" do
expect(subject).to be(false)
end
end
context "when there's no user with given email" do
it "creates a user with given email" do
expect { subject }.to change(User, :count).by(1)
expect(User.last.email).to eq("me@example.com")
end
it "creates an expiring token for new user" do
expect { subject }.to change(ExpiringToken, :count).by(1)
expect(ExpiringToken.last.user).to eq(User.last)
end
it "sends login email" do
expect(Notifications).to receive(:delay_login_request)
subject
end
it "returns true" do
expect(subject).to be(true)
end
end
context "when there's a user with given email" do
let!(:user) { create(:user, email: "me@example.com") }
it "creates an expiring token this user" do
expect { subject }.to change(ExpiringToken, :count).by(1)
expect(ExpiringToken.last.user).to eq(user)
end
it "sends login email" do
expect(Notifications).to receive(:delay_login_request)
subject
end
it "returns true" do
expect(subject).to be(true)
end
end
end
describe "#validate" do
subject { creator.validate(token) }
let(:token) { "the-to-ken" }
context "when given token is valid" do
let!(:expiring_token) { create(:expiring_token, user: user, token: token) }
let(:user) { create(:user) }
it "marks token as used" do
now = Time.now
Timecop.freeze(now) do
subject
end
expect(expiring_token.reload.used_at.to_i).to eq(now.to_i)
end
it "returns user associated with the token" do
expect(subject).to eq(user)
end
end
context "when given token is invalid" do
it "returns nil" do
expect(subject).to be(nil)
end
end
end
end
Loading…
Cancel
Save