diff --git a/app/controllers/logins_controller.rb b/app/controllers/logins_controller.rb
deleted file mode 100644
index e7a5daf..0000000
--- a/app/controllers/logins_controller.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-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
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index c05728f..78d4a5d 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -1,44 +1,8 @@
class SessionsController < ApplicationController
- def new
- end
-
- def create
- user = login_service.validate(params[:token].to_s.strip)
-
- if user
- self.current_user = user
- redirect_to_profile(user)
- else
- render :error
- end
- end
-
def destroy
self.current_user = nil
redirect_to root_path, notice: "See you later!"
end
- private
-
- def login_service
- EmailLoginService.new
- end
-
- def redirect_to_profile(user)
- if user.username
- redirect_back_or_to profile_path(user), notice: login_notice(user)
- else
- redirect_to new_username_path, notice: login_notice(user)
- end
- end
-
- def login_notice(user)
- if user.first_login?
- "Welcome to asciinema!"
- else
- "Welcome back!"
- end
- end
-
end
diff --git a/app/mailers/.gitkeep b/app/mailers/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/app/mailers/notifications.rb b/app/mailers/notifications.rb
deleted file mode 100644
index c0edcb6..0000000
--- a/app/mailers/notifications.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-class Notifications < ActionMailer::Base
- default from: CFG.smtp_from_address
-
- 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
diff --git a/app/services/email_login_service.rb b/app/services/email_login_service.rb
deleted file mode 100644
index 3ce3995..0000000
--- a/app/services/email_login_service.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-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
diff --git a/app/views/layouts/_header.html.slim b/app/views/layouts/_header.html.slim
index 8a308f8..42499f5 100644
--- a/app/views/layouts/_header.html.slim
+++ b/app/views/layouts/_header.html.slim
@@ -33,4 +33,4 @@ header.navbar.navbar-default[role="navigation"]
a.logout href=logout_path Log out
- else
li
- a#log-in href=new_login_path Log in
+ a#log-in href=new_login_path Log in / Sign up
diff --git a/app/views/logins/new.html.slim b/app/views/logins/new.html.slim
deleted file mode 100644
index 5192666..0000000
--- a/app/views/logins/new.html.slim
+++ /dev/null
@@ -1,33 +0,0 @@
-.container
- .row
- .col-md-6
- h1
- span class="glyphicon glyphicon-user"
- | Log in
-
- br
-
- p Log in to asciinema.org 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" data-behavior="focus"
- button.btn.btn-primary type="submit" Log in
-
- - if @invalid_email
- br
- p.text-danger This email address doesn't seem to be correct.
-
- .col-md-6
- h1
- span class="glyphicon glyphicon-info-sign"
- | First time here?
-
- br
-
- p
- | asciinema.org uses email-based, passwordless login process.
- Whether you're here for the first time or you're coming back,
- just enter your email address and you'll get sent a login link.
- If you're doing this for the first time then your user account will
- automatically get created.
diff --git a/app/views/logins/sent.html.slim b/app/views/logins/sent.html.slim
deleted file mode 100644
index 486f978..0000000
--- a/app/views/logins/sent.html.slim
+++ /dev/null
@@ -1,14 +0,0 @@
-.container
- .row
- .col-md-12
- h1 You're almost there!
-
- 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 #{link_to 'enter your email again', new_login_path} to get another login link.
diff --git a/app/views/sessions/error.html.slim b/app/views/sessions/error.html.slim
deleted file mode 100644
index f31a785..0000000
--- a/app/views/sessions/error.html.slim
+++ /dev/null
@@ -1,15 +0,0 @@
-.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 yours might have expired 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.
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb
deleted file mode 100644
index b495ee8..0000000
--- a/app/views/sessions/new.html.erb
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
Welcome.
-
-
Click the button and you're in!
-
- <%= form_tag method: :post do %>
-
- <% end %>
-
-
-
diff --git a/config/application.rb b/config/application.rb
index 78162c7..f347db9 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -60,15 +60,5 @@ module Asciinema
action: "unauthenticated_user"
)
end
-
- config.action_mailer.default_url_options = {
- protocol: CFG.url_scheme,
- host: CFG.url_host,
- port: CFG.url_port
- }
-
- if CFG.smtp_settings
- config.action_mailer.smtp_settings = CFG.smtp_settings
- end
end
end
diff --git a/config/cfg.rb b/config/cfg.rb
index ac3118d..d4e6f7a 100644
--- a/config/cfg.rb
+++ b/config/cfg.rb
@@ -19,8 +19,6 @@ module Asciinema
attribute :session_encryption_salt, String, default: 'encrypted cookie'
attribute :session_signing_salt, String, default: 'signed encrypted cookie'
attribute :admin_ids, Array[Integer]
- attribute :smtp_settings, Hash
- attribute :smtp_from_address, String
def home_asciicast
if home_asciicast_id
@@ -33,10 +31,6 @@ module Asciinema
def ssl?
url_scheme == 'https'
end
-
- def smtp_from_address
- super || "asciinema "
- end
end
end
diff --git a/config/config.exs b/config/config.exs
index 69edff9..50014f8 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -66,7 +66,7 @@ config :exq,
url: System.get_env("REDIS_URL") || "redis://redis:6379",
namespace: "exq",
concurrency: 10,
- queues: ["default"],
+ queues: ["default", "emails"],
scheduler_enable: true,
max_retries: 25,
shutdown_timeout: 5000
diff --git a/config/dev.exs b/config/dev.exs
index a767b91..52b31d9 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -44,5 +44,8 @@ config :asciinema, Asciinema.Repo,
hostname: "localhost",
pool_size: 10
+config :asciinema, Asciinema.Mailer,
+ adapter: Bamboo.LocalAdapter
+
# Import custom config.
import_config "custom*.exs"
diff --git a/config/prod.exs b/config/prod.exs
index 8949dec..558a7e4 100644
--- a/config/prod.exs
+++ b/config/prod.exs
@@ -64,5 +64,11 @@ config :asciinema, Asciinema.Repo,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "20"),
ssl: false
+config :asciinema, Asciinema.Mailer,
+ deliver_later_strategy: Asciinema.BambooExqStrategy,
+ adapter: Bamboo.SMTPAdapter,
+ server: "smtp",
+ port: 25
+
# Import custom config.
import_config "custom*.exs"
diff --git a/config/routes.rb b/config/routes.rb
index d2197f5..c4f23d0 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -18,14 +18,7 @@ Rails.application.routes.draw do
get "/oembed" => "oembed#show", as: :oembed
- resource :login, only: [:new, :create] do
- get :sent
- end
-
- get "/login" => redirect("/login/new")
-
- get "/login/:token" => "sessions#new", as: :login_token
- post "/login/:token" => "sessions#create"
+ get "/login/new" => redirect("/not-gonna-happen"), as: :new_login # define new_login_path
get "/logout" => "sessions#destroy"
resources :api_tokens, only: [:create, :destroy]
diff --git a/config/test.exs b/config/test.exs
index 236e69c..59a7ff1 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -27,3 +27,6 @@ config :asciinema, :snapshot_updater, Asciinema.Asciicasts.SnapshotUpdater.Sync
config :asciinema, :frames_generator, Asciinema.Asciicasts.FramesGenerator.Noop
config :exq_ui, server: false
+
+config :asciinema, Asciinema.Mailer,
+ adapter: Bamboo.TestAdapter
diff --git a/db/migrate/20170721130539_add_last_login_at_to_users.rb b/db/migrate/20170721130539_add_last_login_at_to_users.rb
new file mode 100644
index 0000000..4dbbff5
--- /dev/null
+++ b/db/migrate/20170721130539_add_last_login_at_to_users.rb
@@ -0,0 +1,5 @@
+class AddLastLoginAtToUsers < ActiveRecord::Migration
+ def change
+ add_column :users, :last_login_at, :datetime
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index efb8f1b..42b26a1 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20170611171826) do
+ActiveRecord::Schema.define(version: 20170721130539) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -88,6 +88,7 @@ ActiveRecord::Schema.define(version: 20170611171826) do
t.string "theme_name"
t.string "temporary_username"
t.boolean "asciicasts_private_by_default", default: true, null: false
+ t.datetime "last_login_at"
end
add_index "users", ["auth_token"], name: "index_users_on_auth_token", using: :btree
diff --git a/docker/nginx/asciinema.conf b/docker/nginx/asciinema.conf
index 3c32417..33709a3 100644
--- a/docker/nginx/asciinema.conf
+++ b/docker/nginx/asciinema.conf
@@ -24,7 +24,7 @@ server {
client_max_body_size 16m;
- location ~ ^/(phoenix/|css/|js/|images/|fonts/|docs/?|api/asciicasts|connect/) {
+ location ~ ^/(phoenix/|css/|js/|images/|fonts/|docs/?|api/asciicasts|connect/|login/?|users/?|session/?) {
try_files /maintenance.html $uri/index.html $uri.html $uri @phoenix;
}
diff --git a/lib/asciinema/bamboo_exq_strategy.ex b/lib/asciinema/bamboo_exq_strategy.ex
new file mode 100644
index 0000000..50f50d8
--- /dev/null
+++ b/lib/asciinema/bamboo_exq_strategy.ex
@@ -0,0 +1,13 @@
+defmodule Asciinema.BambooExqStrategy do
+ @behaviour Bamboo.DeliverLaterStrategy
+
+ def deliver_later(adapter, email, config) do
+ binary = [adapter, email, config] |> :erlang.term_to_binary |> Base.encode64
+ {:ok, _jid} = Exq.enqueue(Exq, "emails", __MODULE__, [binary])
+ end
+
+ def perform(binary) do
+ [adapter, email, config] = binary |> Base.decode64! |> :erlang.binary_to_term
+ adapter.deliver(email, config)
+ end
+end
diff --git a/lib/asciinema/email.ex b/lib/asciinema/email.ex
new file mode 100644
index 0000000..79d784e
--- /dev/null
+++ b/lib/asciinema/email.ex
@@ -0,0 +1,36 @@
+defmodule Asciinema.Email do
+ use Bamboo.Phoenix, view: Asciinema.EmailView
+ import Bamboo.Email
+
+ def signup_email(email_address, signup_url) do
+ base_email()
+ |> to(email_address)
+ |> subject("Welcome to #{instance_hostname()}")
+ |> render("signup.text", signup_url: signup_url)
+ end
+
+ def login_email(email_address, login_url) do
+ base_email()
+ |> to(email_address)
+ |> subject("Login request")
+ |> render("login.text", login_url: login_url)
+ end
+
+ defp base_email do
+ new_email()
+ |> from({"asciinema", from_address()})
+ |> put_header("Reply-To", reply_to_address())
+ end
+
+ defp from_address do
+ System.get_env("SMTP_FROM_ADDRESS") || "hello@#{instance_hostname()}"
+ end
+
+ defp reply_to_address do
+ System.get_env("SMTP_REPLY_TO_ADDRESS") || "support@asciinema.org"
+ end
+
+ defp instance_hostname do
+ System.get_env("URL_HOST") || "asciinema.org"
+ end
+end
diff --git a/lib/asciinema/mailer.ex b/lib/asciinema/mailer.ex
new file mode 100644
index 0000000..94b3246
--- /dev/null
+++ b/lib/asciinema/mailer.ex
@@ -0,0 +1,3 @@
+defmodule Asciinema.Mailer do
+ use Bamboo.Mailer, otp_app: :asciinema
+end
diff --git a/lib/asciinema/rails/flash.ex b/lib/asciinema/rails/flash.ex
new file mode 100644
index 0000000..1cd13d9
--- /dev/null
+++ b/lib/asciinema/rails/flash.ex
@@ -0,0 +1,13 @@
+defmodule Asciinema.Rails.Flash do
+ import Plug.Conn
+
+ def put_rails_flash(conn, key, value) do
+ key = case key do
+ :info -> :notice
+ :error -> :alert
+ key -> key
+ end
+
+ put_session(conn, :flash, %{discard: [], flashes: %{key => value}})
+ end
+end
diff --git a/lib/asciinema/users.ex b/lib/asciinema/users.ex
index 92e4ddb..bcd3951 100644
--- a/lib/asciinema/users.ex
+++ b/lib/asciinema/users.ex
@@ -1,7 +1,7 @@
defmodule Asciinema.Users do
import Ecto.Query, warn: false
import Ecto, only: [assoc: 2]
- alias Asciinema.{Repo, User, ApiToken, Asciicasts}
+ alias Asciinema.{Repo, User, ApiToken, Asciicasts, Email, Mailer, Auth}
def create_asciinema_user!() do
attrs = %{username: "asciinema",
@@ -28,6 +28,107 @@ defmodule Asciinema.Users do
:ok
end
+ def send_login_email(email_or_username) do
+ with {:ok, %User{} = user} <- lookup_user(email_or_username) do
+ do_send_login_email(user)
+ end
+ end
+
+ defp lookup_user(email_or_username) do
+ if String.contains?(email_or_username, "@") do
+ lookup_user_by_email(email_or_username)
+ else
+ lookup_user_by_username(email_or_username)
+ end
+ end
+
+ defp lookup_user_by_email(email) do
+ case Repo.get_by(User, email: email) do
+ %User{} = user ->
+ {:ok, user}
+ nil ->
+ case User.signup_changeset(%{email: email}) do
+ %{errors: [{:email, _}]} ->
+ {:error, :email_invalid}
+ %{errors: []} ->
+ {:ok, %User{email: email}}
+ end
+ end
+ end
+
+ defp lookup_user_by_username(username) do
+ case Repo.get_by(User, username: username) do
+ %User{} = user ->
+ {:ok, user}
+ nil ->
+ {:error, :user_not_found}
+ end
+ end
+
+ defp do_send_login_email(%User{email: nil}) do
+ {:error, :email_missing}
+ end
+ defp do_send_login_email(%User{id: nil, email: email}) do
+ url = signup_url(email)
+ Email.signup_email(email, url) |> Mailer.deliver_later
+ {:ok, url}
+ end
+ defp do_send_login_email(%User{} = user) do
+ url = login_url(user)
+ Email.login_email(user.email, url) |> Mailer.deliver_later
+ {:ok, url}
+ end
+
+ defp signup_url(email) do
+ token = Phoenix.Token.sign(Asciinema.Endpoint, "signup", email)
+ Asciinema.Router.Helpers.users_url(Asciinema.Endpoint, :new, t: token)
+ end
+
+ defp login_url(%User{id: id, last_login_at: last_login_at}) do
+ last_login_at = last_login_at && Timex.to_unix(last_login_at)
+ token = Phoenix.Token.sign(Asciinema.Endpoint, "login", {id, last_login_at})
+ Asciinema.Router.Helpers.session_url(Asciinema.Endpoint, :new, t: token)
+ end
+
+ @login_token_max_age 15 * 60 # 15 minutes
+
+ alias Phoenix.Token
+ alias Asciinema.Endpoint
+
+ def verify_signup_token(token) do
+ with {:ok, email} <- Token.verify(Endpoint, "signup", token, max_age: @login_token_max_age),
+ {:ok, %User{} = user} <- User.signup_changeset(%{email: email}) |> Repo.insert do
+ {:ok, user}
+ else
+ {:error, :invalid} ->
+ {:error, :token_invalid}
+ {:error, %Ecto.Changeset{}} ->
+ {:error, :email_taken}
+ {:error, _} ->
+ {:error, :token_expired}
+ end
+ end
+
+ def verify_login_token(token) do
+ with {:ok, {user_id, last_login_at}} <- Token.verify(Endpoint, "login", token, max_age: @login_token_max_age),
+ %User{} = user <- Repo.get(User, user_id),
+ ^last_login_at <- user.last_login_at && Timex.to_unix(user.last_login_at) do
+ {:ok, user}
+ else
+ {:error, :invalid} ->
+ {:error, :token_invalid}
+ nil ->
+ {:error, :user_not_found}
+ _ ->
+ {:error, :token_expired}
+ end
+ end
+
+ def log_in(conn, %User{} = user) do
+ user = user |> User.login_changeset |> Repo.update!
+ Auth.login(conn, user)
+ end
+
def authenticate(api_token) do
q = from u in User,
join: at in ApiToken,
diff --git a/mix.exs b/mix.exs
index dd656e9..11977b0 100644
--- a/mix.exs
+++ b/mix.exs
@@ -19,6 +19,8 @@ defmodule Asciinema.Mixfile do
def application do
[mod: {Asciinema, []},
applications: [
+ :bamboo,
+ :bamboo_smtp,
:briefly,
:bugsnag,
:cowboy,
@@ -50,6 +52,8 @@ defmodule Asciinema.Mixfile do
# Type `mix help deps` for examples and options.
defp deps do
[
+ {:bamboo, "~> 0.8"},
+ {:bamboo_smtp, "~> 1.4.0"},
{:briefly, "~> 0.3"},
{:bugsnag, "~> 1.5.0"},
{:cowboy, "~> 1.0"},
diff --git a/mix.lock b/mix.lock
index 274f400..c174592 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,4 +1,6 @@
-%{"briefly": {:hex, :briefly, "0.3.0", "16e6b76d2070ebc9cbd025fa85cf5dbaf52368c4bd896fb482b5a6b95a540c2f", [:mix], [], "hexpm"},
+%{"bamboo": {:hex, :bamboo, "0.8.0", "573889a3efcb906bb9d25a1c4caa4ca22f479235e1b8cc3260d8b88dabeb4b14", [:mix], [{:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
+ "bamboo_smtp": {:hex, :bamboo_smtp, "1.4.0", "a01d91406f3a46b3452c84d345d50f75d6facca5e06337358287a97da0426240", [:mix], [{:bamboo, "~> 0.8.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 0.12.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}], "hexpm"},
+ "briefly": {:hex, :briefly, "0.3.0", "16e6b76d2070ebc9cbd025fa85cf5dbaf52368c4bd896fb482b5a6b95a540c2f", [:mix], [], "hexpm"},
"bugsnag": {:hex, :bugsnag, "1.5.0", "e761b3c4c198d01d4b04b85298ea7756632c70610ed0b1a57f04c2f528a3e3ab", [:mix], [{:httpoison, "~> 0.9", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
"certifi": {:hex, :certifi, "1.0.0", "1c787a85b1855ba354f0b8920392c19aa1d06b0ee1362f9141279620a5be2039", [:rebar3], [], "hexpm"},
"combine": {:hex, :combine, "0.9.6", "8d1034a127d4cbf6924c8a5010d3534d958085575fa4d9b878f200d79ac78335", [:mix], [], "hexpm"},
@@ -13,6 +15,7 @@
"exq": {:hex, :exq, "0.9.0", "3feeb085fcd94a687033211e10c78cf9dca1de062aac3fa9a4b1f808cdcea522", [:mix], [{:poison, ">= 1.2.0 or ~> 2.0", [hex: :poison, repo: "hexpm", optional: false]}, {:redix, ">= 0.5.0", [hex: :redix, repo: "hexpm", optional: false]}, {:uuid, ">= 1.0.0", [hex: :uuid, repo: "hexpm", optional: false]}], "hexpm"},
"exq_ui": {:hex, :exq_ui, "0.9.0", "e97e9fa9009f30d2926b51a166e40a3a521e83f61f3f333fede8335b2aa57f09", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:exq, "~> 0.9", [hex: :exq, repo: "hexpm", optional: false]}, {:plug, ">= 0.8.1 and < 2.0.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"fs": {:hex, :fs, "0.9.2", "ed17036c26c3f70ac49781ed9220a50c36775c6ca2cf8182d123b6566e49ec59", [:rebar], [], "hexpm"},
+ "gen_smtp": {:hex, :gen_smtp, "0.12.0", "97d44903f5ca18ca85cb39aee7d9c77e98d79804bbdef56078adcf905cb2ef00", [:rebar3], [], "hexpm"},
"gettext": {:hex, :gettext, "0.11.0", "80c1dd42d270482418fa158ec5ba073d2980e3718bacad86f3d4ad71d5667679", [:mix], [], "hexpm"},
"hackney": {:hex, :hackney, "1.7.1", "e238c52c5df3c3b16ce613d3a51c7220a784d734879b1e231c9babd433ac1cb4", [:rebar3], [{:certifi, "1.0.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "4.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
"httpoison": {:hex, :httpoison, "0.11.1", "d06c571274c0e77b6cc50e548db3fd7779f611fbed6681fd60a331f66c143a0b", [:mix], [{:hackney, "~> 1.7.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
diff --git a/spec/controllers/logins_controller_spec.rb b/spec/controllers/logins_controller_spec.rb
deleted file mode 100644
index a8589e2..0000000
--- a/spec/controllers/logins_controller_spec.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-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
diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb
index 3d9d988..3217425 100644
--- a/spec/controllers/sessions_controller_spec.rb
+++ b/spec/controllers/sessions_controller_spec.rb
@@ -2,75 +2,6 @@ require 'rails_helper'
describe SessionsController do
- describe "#new" do
- subject { get :new, token: 'the-to-ken' }
-
- before do
- subject
- end
-
- it "displays button" do
- should render_template('new')
- end
- end
-
- describe "#create" do
- subject { post :create, token: 'the-to-ken' }
-
- let(:login_service) { double(:login_service) }
-
- before do
- allow(controller).to receive(:login_service) { login_service }
- allow(login_service).to receive(:validate).with('the-to-ken') { user }
- end
-
- context "when token is successfully validated" do
- let(:user) { stub_model(User) }
-
- before do
- allow(controller).to receive(:current_user=)
-
- subject
- end
-
- it "sets the current_user" do
- expect(controller).to have_received(:current_user=).with(user)
- end
-
- it "sets a notice" do
- expect(flash[:notice]).to_not be_blank
- end
-
- context "when user has username" do
- let(:user) { User.new(username: "foobar") }
-
- it "redirects to user's profile" do
- should redirect_to(public_profile_path(username: "foobar"))
- end
- end
-
- context "when user has no username" do
- let(:user) { User.new }
-
- it "redirects to new username page" do
- should redirect_to(new_username_path)
- end
- end
- end
-
- context "when token is not validated" do
- let(:user) { nil }
-
- before do
- subject
- end
-
- it "displays error" do
- should render_template('error')
- end
- end
- end
-
describe "#destroy" do
before do
allow(controller).to receive(:current_user=)
diff --git a/spec/features/tokens_spec.rb b/spec/features/tokens_spec.rb
deleted file mode 100644
index ef27f07..0000000
--- a/spec/features/tokens_spec.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-require 'rails_helper'
-
-feature "Recorder tokens management" do
-
- let!(:user) { create(:user) }
-
- scenario 'Listing tokens when user has none' do
- login_as user
- visit edit_user_path
-
- expect(page).to have_content('asciinema auth')
- end
-
- scenario 'Listing tokens when user has some' do
- api_token = create(:api_token, user: user)
-
- login_as user
- visit edit_user_path
-
- expect(page).to have_content(api_token.token)
- expect(page).to have_link('Revoke')
- expect(page).to have_no_content('asciinema auth')
- end
-
- scenario 'Revoking a token' do
- api_token = create(:api_token, user: user)
-
- login_as user
- visit edit_user_path
-
- click_on "Revoke"
-
- expect(page).to have_content(api_token.token)
- expect(page).to have_no_link('Revoke')
- end
-
-end
-
diff --git a/spec/features/user_spec.rb b/spec/features/user_spec.rb
index 36f9025..8096ed9 100644
--- a/spec/features/user_spec.rb
+++ b/spec/features/user_spec.rb
@@ -13,20 +13,4 @@ feature "User's profile" do
expect(page).to have_selector('.asciicast-list .play-button')
end
- scenario 'Updating profile', js: true, unstable: true do
- login_as user
-
- within 'header' do
- click_on user.username
- click_on 'Settings'
- end
-
- fill_in 'Username', with: 'batman'
- click_on 'Save'
-
- within 'header' do
- expect(page).to have_content('batman')
- end
- end
-
end
diff --git a/spec/mailers/notifications_spec.rb b/spec/mailers/notifications_spec.rb
deleted file mode 100644
index 3e47092..0000000
--- a/spec/mailers/notifications_spec.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-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@localhost"])
- 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
diff --git a/spec/services/email_login_service_spec.rb b/spec/services/email_login_service_spec.rb
deleted file mode 100644
index aa2e658..0000000
--- a/spec/services/email_login_service_spec.rb
+++ /dev/null
@@ -1,99 +0,0 @@
-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
diff --git a/spec/support/authentication.rb b/spec/support/authentication.rb
index f112d42..afd5d6d 100644
--- a/spec/support/authentication.rb
+++ b/spec/support/authentication.rb
@@ -17,16 +17,6 @@ module Asciinema
controller.current_user = nil
end
end
-
- module FeatureHelpers
- def login_as(user)
- visit new_login_path
- fill_in :email, with: user.email
- click_button 'Log in'
- visit "/login/#{user.expiring_tokens.last.token}"
- click_button "Log in"
- end
- end
end
end
@@ -36,5 +26,4 @@ RSpec.configure do |config|
end
config.include Asciinema::Test::ControllerHelpers, type: :controller
- config.include Asciinema::Test::FeatureHelpers, type: :feature
end
diff --git a/test/asciinema/users_test.exs b/test/asciinema/users_test.exs
new file mode 100644
index 0000000..380dcec
--- /dev/null
+++ b/test/asciinema/users_test.exs
@@ -0,0 +1,37 @@
+defmodule Asciinema.UsersTest do
+ import Asciinema.Fixtures
+ use Asciinema.DataCase
+ use Bamboo.Test
+ alias Asciinema.Email
+
+ describe "send_login_email/1" do
+ import Asciinema.Users, only: [send_login_email: 1]
+
+ test "existing user, by email" do
+ user = fixture(:user)
+ assert {:ok, url} = send_login_email(user.email)
+ assert_delivered_email Email.login_email(user.email, url)
+ end
+
+ test "existing user, by username" do
+ user = fixture(:user)
+ assert {:ok, url} = send_login_email(user.username)
+ assert_delivered_email Email.login_email(user.email, url)
+ end
+
+ test "non-existing user, by email" do
+ assert {:ok, url} = send_login_email("new@example.com")
+ assert_delivered_email Email.signup_email("new@example.com", url)
+ end
+
+ test "non-existing user, by email, when email is invalid" do
+ assert send_login_email("new@") == {:error, :email_invalid}
+ assert_no_emails_delivered()
+ end
+
+ test "non-existing user, by username" do
+ assert send_login_email("idontexist") == {:error, :user_not_found}
+ assert_no_emails_delivered()
+ end
+ end
+end
diff --git a/test/controllers/login_controller_test.exs b/test/controllers/login_controller_test.exs
new file mode 100644
index 0000000..2b76d01
--- /dev/null
+++ b/test/controllers/login_controller_test.exs
@@ -0,0 +1,13 @@
+defmodule Asciinema.LoginControllerTest do
+ use Asciinema.ConnCase
+
+ test "with valid email", %{conn: conn} do
+ conn = post conn, "/login", %{login: %{email: "new@example.com"}}
+ assert redirected_to(conn, 302) == "/login/sent"
+ end
+
+ test "with invalid email", %{conn: conn} do
+ conn = post conn, "/login", %{login: %{email: "new@"}}
+ assert html_response(conn, 200) =~ "correct email"
+ end
+end
diff --git a/test/models/user_test.exs b/test/models/user_test.exs
index 8f567e7..498d5a7 100644
--- a/test/models/user_test.exs
+++ b/test/models/user_test.exs
@@ -3,16 +3,16 @@ defmodule Asciinema.UserTest do
alias Asciinema.User
- @valid_attrs %{email: "test@example.com", username: "test"}
+ @valid_attrs %{email: "test@example.com"}
@invalid_attrs %{}
- test "changeset with valid attributes" do
- changeset = User.create_changeset(%User{}, @valid_attrs)
+ test "signup changeset with valid attributes" do
+ changeset = User.signup_changeset(@valid_attrs)
assert changeset.valid?
end
- test "changeset with invalid attributes" do
- changeset = User.create_changeset(%User{}, @invalid_attrs)
+ test "signup changeset with invalid attributes" do
+ changeset = User.signup_changeset(@invalid_attrs)
refute changeset.valid?
end
end
diff --git a/web/controllers/login_controller.ex b/web/controllers/login_controller.ex
new file mode 100644
index 0000000..86a153f
--- /dev/null
+++ b/web/controllers/login_controller.ex
@@ -0,0 +1,27 @@
+defmodule Asciinema.LoginController do
+ use Asciinema.Web, :controller
+ alias Asciinema.Users
+
+ def new(conn, _params) do
+ render(conn, "new.html")
+ end
+
+ def create(conn, %{"login" => %{"email" => email_or_username}}) do
+ email_or_username = String.trim(email_or_username)
+
+ case Users.send_login_email(email_or_username) do
+ {:ok, _url} ->
+ redirect(conn, to: login_path(conn, :sent))
+ {:error, :user_not_found} ->
+ render(conn, "new.html", error: "No user found for given username.")
+ {:error, :email_invalid} ->
+ render(conn, "new.html", error: "This doesn't look like a correct email address.")
+ {:error, :email_missing} ->
+ redirect(conn, to: login_path(conn, :sent))
+ end
+ end
+
+ def sent(conn, _params) do
+ render(conn, "sent.html")
+ end
+end
diff --git a/web/controllers/session_controller.ex b/web/controllers/session_controller.ex
index 9ee3987..1dafc22 100644
--- a/web/controllers/session_controller.ex
+++ b/web/controllers/session_controller.ex
@@ -1,12 +1,21 @@
defmodule Asciinema.SessionController do
use Asciinema.Web, :controller
import Asciinema.UserView, only: [profile_path: 1]
- alias Asciinema.{Auth, Users, User}
+ alias Asciinema.{Users, User}
+
+ def new(conn, %{"t" => login_token}) do
+ conn
+ |> put_session(:login_token, login_token)
+ |> redirect(to: session_path(conn, :new))
+ end
+ def new(conn, _params) do
+ render(conn, "new.html")
+ end
def create(conn, %{"api_token" => api_token}) do
case Users.get_user_with_api_token(api_token) do
{:ok, user} ->
- login(conn, user)
+ login_via_api_token(conn, user)
{:error, :token_invalid} ->
conn
|> put_rails_flash(:alert, "Invalid token. Make sure you pasted the URL correctly.")
@@ -18,18 +27,43 @@ defmodule Asciinema.SessionController do
end
end
- defp login(conn, logging_user) do
+ def create(conn, _params) do
+ login_token = get_session(conn, :login_token)
+ conn = delete_session(conn, :login_token)
+
+ case Users.verify_login_token(login_token) do
+ {:ok, user} ->
+ conn
+ |> Users.log_in(user)
+ |> put_rails_flash(:notice, "Welcome back!")
+ |> redirect_to_profile
+ {:error, :token_invalid} ->
+ conn
+ |> put_flash(:error, "Invalid login link.")
+ |> redirect(to: login_path(conn, :new))
+ {:error, :token_expired} ->
+ conn
+ |> put_flash(:error, "This login link has expired, sorry.")
+ |> redirect(to: login_path(conn, :new))
+ {:error, :user_not_found} ->
+ conn
+ |> put_flash(:error, "This account has been removed.")
+ |> redirect(to: login_path(conn, :new))
+ end
+ end
+
+ defp login_via_api_token(conn, logging_user) do
current_user = conn.assigns.current_user
case {current_user, logging_user} do
{nil, %User{email: nil}} ->
conn
- |> Auth.login(logging_user)
+ |> Users.log_in(logging_user)
|> put_rails_flash(:notice, "Welcome! Setting username and email will help you with logging in later.")
|> redirect_to_edit_profile
{nil, %User{}} ->
conn
- |> Auth.login(logging_user)
+ |> Users.log_in(logging_user)
|> put_rails_flash(:notice, "Welcome back!")
|> redirect_to_profile
{%User{id: id, email: nil}, %User{id: id}} ->
@@ -44,7 +78,7 @@ defmodule Asciinema.SessionController do
{%User{email: nil}, %User{}} ->
Users.merge!(logging_user, current_user)
conn
- |> Auth.login(logging_user)
+ |> Users.log_in(logging_user)
|> put_rails_flash(:notice, "Recorder token has been added to your account.")
|> redirect_to_profile
{%User{}, %User{email: nil}} ->
@@ -64,12 +98,13 @@ defmodule Asciinema.SessionController do
end
end
- defp put_rails_flash(conn, key, value) do
- put_session(conn, :flash, %{discard: [], flashes: %{key => value}})
- end
-
defp redirect_to_profile(conn) do
- redirect(conn, to: profile_path(conn.assigns.current_user))
+ path = case conn.assigns.current_user do
+ %User{username: nil} -> "/username/new"
+ %User{} = user -> profile_path(user)
+ end
+
+ redirect(conn, to: path)
end
defp redirect_to_edit_profile(conn) do
diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex
new file mode 100644
index 0000000..f03064d
--- /dev/null
+++ b/web/controllers/user_controller.ex
@@ -0,0 +1,38 @@
+defmodule Asciinema.UserController do
+ use Asciinema.Web, :controller
+ alias Asciinema.Users
+
+ def new(conn, %{"t" => signup_token}) do
+ conn
+ |> put_session(:signup_token, signup_token)
+ |> redirect(to: users_path(conn, :new))
+ end
+ def new(conn, _params) do
+ render(conn, "new.html")
+ end
+
+ def create(conn, _params) do
+ signup_token = get_session(conn, :signup_token)
+ conn = delete_session(conn, :signup_token)
+
+ case Users.verify_signup_token(signup_token) do
+ {:ok, user} ->
+ conn
+ |> Users.log_in(user)
+ |> put_rails_flash(:info, "Welcome to asciinema!")
+ |> redirect(to: "/username/new")
+ {:error, :token_invalid} ->
+ conn
+ |> put_flash(:error, "Invalid sign-up link.")
+ |> redirect(to: login_path(conn, :new))
+ {:error, :token_expired} ->
+ conn
+ |> put_flash(:error, "This sign-up link has expired, sorry.")
+ |> redirect(to: login_path(conn, :new))
+ {:error, :email_taken} ->
+ conn
+ |> put_flash(:error, "You already signed up with this email.")
+ |> redirect(to: login_path(conn, :new))
+ end
+ end
+end
diff --git a/web/models/user.ex b/web/models/user.ex
index 8efd82c..bc448c9 100644
--- a/web/models/user.ex
+++ b/web/models/user.ex
@@ -12,6 +12,7 @@ defmodule Asciinema.User do
field :auth_token, :string
field :theme_name, :string
field :asciicasts_private_by_default, :boolean, default: true
+ field :last_login_at, Timex.Ecto.DateTime
timestamps(inserted_at: :created_at)
@@ -29,10 +30,20 @@ defmodule Asciinema.User do
def create_changeset(struct, attrs) do
struct
|> changeset(attrs)
- |> validate_required(~w(username email)a)
|> generate_auth_token
end
+ def signup_changeset(attrs) do
+ %User{}
+ |> create_changeset(attrs)
+ |> cast(attrs, [:email])
+ |> validate_required([:email])
+ end
+
+ def login_changeset(user) do
+ change(user, %{last_login_at: Timex.now()})
+ end
+
def temporary_changeset(temporary_username) do
%User{}
|> change(%{temporary_username: temporary_username})
diff --git a/web/router.ex b/web/router.ex
index 40ce858..6bbdef4 100644
--- a/web/router.ex
+++ b/web/router.ex
@@ -51,7 +51,13 @@ defmodule Asciinema.Router do
get "/docs", DocController, :index
get "/docs/:topic", DocController, :show
- get "/connect/:api_token", SessionController, :create
+ resources "/login", LoginController, only: [:new, :create], singleton: true
+ get "/login/sent", LoginController, :sent, as: :login
+
+ resources "/users", UserController, as: :users, only: [:new, :create]
+
+ resources "/session", SessionController, only: [:new, :create], singleton: true
+ get "/connect/:api_token", SessionController, :create, as: :connect
end
scope "/api", Asciinema.Api, as: :api do
diff --git a/web/static/assets/fonts/glyphicons-halflings-regular.eot b/web/static/assets/fonts/glyphicons-halflings-regular.eot
new file mode 100644
index 0000000..87eaa43
Binary files /dev/null and b/web/static/assets/fonts/glyphicons-halflings-regular.eot differ
diff --git a/web/static/assets/fonts/glyphicons-halflings-regular.svg b/web/static/assets/fonts/glyphicons-halflings-regular.svg
new file mode 100644
index 0000000..5fee068
--- /dev/null
+++ b/web/static/assets/fonts/glyphicons-halflings-regular.svg
@@ -0,0 +1,228 @@
+
+
+
\ No newline at end of file
diff --git a/web/static/assets/fonts/glyphicons-halflings-regular.ttf b/web/static/assets/fonts/glyphicons-halflings-regular.ttf
new file mode 100644
index 0000000..be784dc
Binary files /dev/null and b/web/static/assets/fonts/glyphicons-halflings-regular.ttf differ
diff --git a/web/static/assets/fonts/glyphicons-halflings-regular.woff b/web/static/assets/fonts/glyphicons-halflings-regular.woff
new file mode 100644
index 0000000..2cc3e48
Binary files /dev/null and b/web/static/assets/fonts/glyphicons-halflings-regular.woff differ
diff --git a/web/static/css/base.sass b/web/static/css/base.sass
index 16896b0..75a02db 100644
--- a/web/static/css/base.sass
+++ b/web/static/css/base.sass
@@ -45,6 +45,9 @@ h2
font-size: 24px
margin-top: 30px
+ span.glyphicon
+ top: 4px
+
h3
font-size: 18px
diff --git a/web/static/js/app.js b/web/static/js/app.js
index e7549b9..dada5bc 100644
--- a/web/static/js/app.js
+++ b/web/static/js/app.js
@@ -19,3 +19,7 @@ import "phoenix_html"
// paths "./socket" or full ones "web/static/js/socket".
// import socket from "./socket"
+
+$(function() {
+ $('input[data-behavior=focus]:first').focus().select();
+});
diff --git a/app/views/notifications/login_request.text.erb b/web/templates/email/login.text.eex
similarity index 88%
rename from app/views/notifications/login_request.text.erb
rename to web/templates/email/login.text.eex
index ff42e61..91ba6d3 100644
--- a/app/views/notifications/login_request.text.erb
+++ b/web/templates/email/login.text.eex
@@ -2,6 +2,7 @@ Hello,
Click the following link to log in to asciinema.org:
- <%= @login_url %>
+<%= @login_url %>
+
If you did not initiate this request, just ignore this email. The request will expire shortly.
diff --git a/web/templates/email/signup.text.eex b/web/templates/email/signup.text.eex
new file mode 100644
index 0000000..3a3cd6b
--- /dev/null
+++ b/web/templates/email/signup.text.eex
@@ -0,0 +1,8 @@
+Welcome,
+
+Click the following link to create your account at asciinema.org:
+
+<%= @signup_url %>
+
+
+If you did not initiate this request, just ignore this email. The request will expire shortly.
diff --git a/web/templates/layout/flash.html.eex b/web/templates/layout/flash.html.eex
index 8c39410..5785013 100644
--- a/web/templates/layout/flash.html.eex
+++ b/web/templates/layout/flash.html.eex
@@ -1,4 +1,7 @@
-
+<%= if message = get_flash(@conn, :info) do %>
+
<%= message %>
+<% end %>
+
+<%= if message = get_flash(@conn, :error) do %>
+
+
+
+ <% end %>
+
+ <%= if error = assigns[:error] do %>
+
+
<%= error %>
+ <% end %>
+
+
+
+
+
+
First time here?
+
+
We use email-based, passwordless login process. Enter your email
+ address and you'll receive a one-time login link. After you click it
+ you'll get in, and you'll be able to pick your username.
+
+
Coming back?
+
+
If you already have an account then enter either your username, or the
+ email address you used for the first time here. We'll send you an email
+ with a one-time login link.
+
+
+
+
diff --git a/web/templates/login/sent.html.eex b/web/templates/login/sent.html.eex
new file mode 100644
index 0000000..f10c052
--- /dev/null
+++ b/web/templates/login/sent.html.eex
@@ -0,0 +1,19 @@
+
+
+
+
Check your inbox
+
+
+
+ We've sent an email with one-time login link to your email address.
+ Click on the link to log in to your account.
+ The link is valid for next 15 minutes.
+
+
+
+ If the email doesn't show up in your inbox, check your spam folder,
+ or try again.
+
+
+
+
diff --git a/web/templates/session/new.html.eex b/web/templates/session/new.html.eex
new file mode 100644
index 0000000..df0fbf6
--- /dev/null
+++ b/web/templates/session/new.html.eex
@@ -0,0 +1,14 @@
+