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 %> +
<%= message %>
+<% end %> diff --git a/web/templates/layout/header.html.eex b/web/templates/layout/header.html.eex index f34b301..20005d7 100644 --- a/web/templates/layout/header.html.eex +++ b/web/templates/layout/header.html.eex @@ -32,7 +32,7 @@ <% else %> -
  • Log in
  • +
  • Log in / Sign up
  • <% end %> diff --git a/web/templates/login/new.html.eex b/web/templates/login/new.html.eex new file mode 100644 index 0000000..e633c10 --- /dev/null +++ b/web/templates/login/new.html.eex @@ -0,0 +1,43 @@ +
    +
    +
    +
    +
    + +
    +
    +

    Log in

    +
    + + <%= form_for @conn, login_path(@conn, :create), [as: :login, class: "form-inline login-form"], fn f -> %> +
    + <%= text_input f, :email, class: "form-control email", "data-behavior": "focus", placeholder: "Email address or username" %> +
    + + + <% 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 @@ +
    +
    +
    +

    Verifying link...

    +
    +
    +
    + +<%= form_for @conn, session_path(@conn, :create), [id: "login", as: :login], fn _f -> %> +<% end %> + + diff --git a/web/templates/user/new.html.eex b/web/templates/user/new.html.eex new file mode 100644 index 0000000..1d78f80 --- /dev/null +++ b/web/templates/user/new.html.eex @@ -0,0 +1,14 @@ +
    +
    +
    +

    Verifying link...

    +
    +
    +
    + +<%= form_for @conn, users_path(@conn, :create), [id: "login", as: :login], fn _f -> %> +<% end %> + + diff --git a/web/views/email_view.ex b/web/views/email_view.ex new file mode 100644 index 0000000..588cd84 --- /dev/null +++ b/web/views/email_view.ex @@ -0,0 +1,3 @@ +defmodule Asciinema.EmailView do + use Asciinema.Web, :view +end diff --git a/web/views/layout_view.ex b/web/views/layout_view.ex index b77e10c..39aa40c 100644 --- a/web/views/layout_view.ex +++ b/web/views/layout_view.ex @@ -1,5 +1,6 @@ defmodule Asciinema.LayoutView do use Asciinema.Web, :view + import Asciinema.UserView, only: [avatar_url: 1, profile_path: 1] def page_title(conn) do case conn.assigns[:page_title] do diff --git a/web/views/login_view.ex b/web/views/login_view.ex new file mode 100644 index 0000000..74c46e6 --- /dev/null +++ b/web/views/login_view.ex @@ -0,0 +1,3 @@ +defmodule Asciinema.LoginView do + use Asciinema.Web, :view +end diff --git a/web/views/session_view.ex b/web/views/session_view.ex new file mode 100644 index 0000000..5d39e54 --- /dev/null +++ b/web/views/session_view.ex @@ -0,0 +1,3 @@ +defmodule Asciinema.SessionView do + use Asciinema.Web, :view +end diff --git a/web/views/user_view.ex b/web/views/user_view.ex index eea2cc5..c9e4eba 100644 --- a/web/views/user_view.ex +++ b/web/views/user_view.ex @@ -1,4 +1,5 @@ defmodule Asciinema.UserView do + use Asciinema.Web, :view alias Asciinema.{Gravatar, User} def avatar_url(user) do diff --git a/web/web.ex b/web/web.ex index 88ae5c5..a63e8c6 100644 --- a/web/web.ex +++ b/web/web.ex @@ -38,6 +38,7 @@ defmodule Asciinema.Web do import Asciinema.Router.Helpers import Asciinema.Router.Helpers.Extra import Asciinema.Gettext + import Asciinema.Rails.Flash end end @@ -55,7 +56,6 @@ defmodule Asciinema.Web do import Asciinema.Router.Helpers.Extra import Asciinema.ErrorHelpers import Asciinema.Gettext - import Asciinema.UserView end end