diff --git a/config/config.exs b/config/config.exs index 10f2c5c..a6fa89d 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 1c14b99..7038cf3 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -43,3 +43,6 @@ config :asciinema, Asciinema.Repo, database: "asciinema_development", hostname: "localhost", pool_size: 10 + +config :asciinema, Asciinema.Mailer, + adapter: Bamboo.LocalAdapter diff --git a/config/prod.exs b/config/prod.exs index 059810f..d3526ce 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -63,3 +63,9 @@ config :asciinema, Asciinema.Repo, url: System.get_env("DATABASE_URL"), 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 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..6c8dd14 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/?) { 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/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/lib/asciinema/users/mailer.ex b/lib/asciinema/users/mailer.ex new file mode 100644 index 0000000..d9dfb9a --- /dev/null +++ b/lib/asciinema/users/mailer.ex @@ -0,0 +1,19 @@ +defmodule Asciinema.Users.Mailer do + @doc "Sends registration email to given address" + @callback send_register_email(email_address :: String.t, register_url :: String.t) :: :ok | {:error, term} + + @doc "Sends login email to given address" + @callback send_login_email(email_address :: String.t, login_url :: String.t) :: :ok | {:error, term} + + def send_register_email(email_address, register_url) do + instance().send_register_email(email_address, register_url) + end + + def send_login_email(email_address, login_url) do + instance().send_login_email(email_address, login_url) + end + + defp instance do + Application.get_env(:asciinema, :mailer) + end +end 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/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 deb4e1b..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}} -> @@ -65,7 +99,12 @@ defmodule Asciinema.SessionController do 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/web/templates/email/login.text.eex b/web/templates/email/login.text.eex new file mode 100644 index 0000000..91ba6d3 --- /dev/null +++ b/web/templates/email/login.text.eex @@ -0,0 +1,8 @@ +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. 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/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/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