├── .gitignore ├── test ├── support │ ├── repo.exs │ ├── schema.exs │ ├── migrations.exs │ └── router.exs ├── interactors │ ├── inject_hash_test.exs │ ├── generate_password_reset_link_test.exs │ ├── register_test.exs │ ├── destroy_session_test.exs │ ├── create_session_test.exs │ ├── validate_password_test.exs │ ├── verify_password_test.exs │ └── validate_user_for_registration_test.exs ├── crypto_test.exs ├── presenter_test.exs ├── plugs │ └── authenticated_test.exs ├── test_helper.exs ├── acceptance │ └── controller_test.exs └── manager_interactor_test.exs ├── example_app ├── web │ ├── views │ │ ├── page_view.ex │ │ ├── user_view.ex │ │ ├── layout_view.ex │ │ ├── addict_view.ex │ │ ├── user_management_view.ex │ │ ├── error_view.ex │ │ └── error_helpers.ex │ ├── templates │ │ ├── user_management │ │ │ ├── new.html.eex │ │ │ ├── edit.html.eex │ │ │ ├── form.html.eex │ │ │ ├── show.html.eex │ │ │ ├── _send_reset_password_link.html.eex │ │ │ ├── _reset_password.html.eex │ │ │ ├── _login.html.eex │ │ │ ├── index.html.eex │ │ │ └── _register.html.eex │ │ ├── page │ │ │ ├── required_login.html.eex │ │ │ └── index.html.eex │ │ ├── addict │ │ │ ├── recover_password.html.eex │ │ │ ├── reset_password.html.eex │ │ │ ├── login.html.eex │ │ │ ├── register.html.eex │ │ │ └── addict.html.eex │ │ └── layout │ │ │ └── app.html.eex │ ├── controllers │ │ ├── page_controller.ex │ │ └── user_management_controller.ex │ ├── models │ │ └── user.ex │ ├── gettext.ex │ ├── channels │ │ └── user_socket.ex │ ├── router.ex │ └── web.ex ├── lib │ ├── example_app │ │ ├── repo.ex │ │ └── endpoint.ex │ └── example_app.ex ├── priv │ ├── static │ │ ├── favicon.ico │ │ ├── images │ │ │ └── phoenix.png │ │ ├── robots.txt │ │ └── js │ │ │ ├── app.js │ │ │ └── phoenix.js │ ├── repo │ │ ├── migrations │ │ │ └── 20160229201901_create_user.exs │ │ └── seeds.exs │ └── gettext │ │ ├── errors.pot │ │ └── en │ │ └── LC_MESSAGES │ │ └── errors.po ├── test │ ├── views │ │ ├── layout_view_test.exs │ │ ├── page_view_test.exs │ │ └── error_view_test.exs │ ├── test_helper.exs │ ├── controllers │ │ ├── page_controller_test.exs │ │ └── user_controller_test.exs │ ├── models │ │ └── user_test.exs │ └── support │ │ ├── channel_case.ex │ │ ├── conn_case.ex │ │ └── model_case.ex ├── README.md ├── .gitignore ├── config │ ├── test.exs │ ├── dev.exs │ ├── config.exs │ └── prod.exs ├── mix.lock └── mix.exs ├── lib └── addict │ ├── mailers │ ├── generic.ex │ ├── mailers.ex │ ├── mailgun.ex │ └── mail_sender.ex │ ├── interactors │ ├── generate_encrypted_password.ex │ ├── destroy_session.ex │ ├── insert_user.ex │ ├── create_session.ex │ ├── update_user_password.ex │ ├── login.ex │ ├── inject_hash.ex │ ├── get_user_by_id.ex │ ├── verify_password.ex │ ├── get_user_by_email.ex │ ├── generate_password_reset_link.ex │ ├── send_reset_password_link.ex │ ├── register.ex │ ├── validate_password.ex │ ├── validate_user_for_registration.ex │ └── reset_password.ex │ ├── helper.ex │ ├── presenter.ex │ ├── crypto.ex │ ├── configs.ex │ ├── plugs │ └── authenticated.ex │ ├── routes_helper.ex │ ├── mix │ ├── generate_boilerplate.ex │ └── generate_configs.ex │ └── controller.ex ├── config ├── test.exs └── config.exs ├── .travis.yml ├── boilerplate ├── recover_password.html.eex ├── reset_password.html.eex ├── login.html.eex ├── register.html.eex └── addict.html.eex ├── mix.lock ├── LICENSE ├── mix.exs ├── configs.md ├── README.md └── docs └── all.json /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | /doc 4 | erl_crash.dump 5 | *.ez 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /test/support/repo.exs: -------------------------------------------------------------------------------- 1 | defmodule TestAddictRepo do 2 | use Ecto.Repo, otp_app: :addict 3 | end 4 | -------------------------------------------------------------------------------- /example_app/web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.PageView do 2 | use ExampleApp.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /example_app/web/views/user_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.UserView do 2 | use ExampleApp.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /example_app/web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.LayoutView do 2 | use ExampleApp.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /example_app/lib/example_app/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.Repo do 2 | use Ecto.Repo, otp_app: :example_app 3 | end 4 | -------------------------------------------------------------------------------- /example_app/priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smpallen99/addict/master/example_app/priv/static/favicon.ico -------------------------------------------------------------------------------- /example_app/test/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.LayoutViewTest do 2 | use ExampleApp.ConnCase, async: true 3 | end -------------------------------------------------------------------------------- /example_app/test/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.PageViewTest do 2 | use ExampleApp.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /example_app/priv/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smpallen99/addict/master/example_app/priv/static/images/phoenix.png -------------------------------------------------------------------------------- /example_app/web/templates/user_management/new.html.eex: -------------------------------------------------------------------------------- 1 |

New user

2 | 3 | <%= render "register.html", action: "/register", csrf_token: @csrf_token %> 4 | 5 | <%= link "Back", to: user_management_path(@conn, :index) %> 6 | -------------------------------------------------------------------------------- /lib/addict/mailers/generic.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Mailers.Generic do 2 | @moduledoc """ 3 | Defines the required behaviour for e-mail providers 4 | """ 5 | @callback send_email(String.t, String.t, String.t, String.t) :: any 6 | end 7 | -------------------------------------------------------------------------------- /test/support/schema.exs: -------------------------------------------------------------------------------- 1 | defmodule TestAddictSchema do 2 | use Ecto.Schema 3 | 4 | schema "users" do 5 | field :name, :string 6 | field :email, :string 7 | field :encrypted_password, :string 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /example_app/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | Mix.Task.run "ecto.create", ~w(-r ExampleApp.Repo --quiet) 4 | Mix.Task.run "ecto.migrate", ~w(-r ExampleApp.Repo --quiet) 5 | Ecto.Adapters.SQL.begin_test_transaction(ExampleApp.Repo) 6 | 7 | -------------------------------------------------------------------------------- /example_app/web/views/addict_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.AddictView do 2 | use Phoenix.HTML 3 | use Phoenix.View, root: "web/templates/" 4 | import Phoenix.Controller, only: [view_module: 1] 5 | import ExampleApp.Router.Helpers 6 | end 7 | -------------------------------------------------------------------------------- /example_app/priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | config :addict, TestAddictRepo, 3 | adapter: Ecto.Adapters.Postgres, 4 | username: "postgres", 5 | password: "postgres", 6 | database: "addict_test", 7 | hostname: "localhost", 8 | pool: Ecto.Adapters.SQL.Sandbox 9 | -------------------------------------------------------------------------------- /example_app/priv/static/js/app.js: -------------------------------------------------------------------------------- 1 | // for phoenix_html support, including form and button helpers 2 | // copy the following scripts into your javascript bundle: 3 | // * https://raw.githubusercontent.com/phoenixframework/phoenix_html/v2.3.0/priv/static/phoenix_html.js 4 | 5 | -------------------------------------------------------------------------------- /example_app/test/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.PageControllerTest do 2 | use ExampleApp.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get conn, "/" 6 | assert html_response(conn, 200) =~ "Welcome to Phoenix!" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /example_app/web/templates/user_management/edit.html.eex: -------------------------------------------------------------------------------- 1 |

Edit user

2 | 3 | <%= render "form.html", changeset: @changeset, 4 | action: user_management_path(@conn, :update, @user) %> 5 | 6 | <%= link "Back", to: user_management_path(@conn, :index) %> 7 | -------------------------------------------------------------------------------- /test/support/migrations.exs: -------------------------------------------------------------------------------- 1 | defmodule TestAddictMigrations do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add :name, :string 7 | add :email, :string 8 | add :encrypted_password, :string 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /example_app/web/templates/page/required_login.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

If you're here, you are logged in!

3 |
4 | 5 |
6 | Here's your user's information: 7 | <%= Poison.encode!(Addict.Helper.current_user(@conn)) %> 8 |
9 | -------------------------------------------------------------------------------- /test/support/router.exs: -------------------------------------------------------------------------------- 1 | defmodule TestAddictRouter do 2 | use Phoenix.Router 3 | use Addict.RoutesHelper 4 | 5 | pipeline :addict_api_test do 6 | plug :accepts, ["json"] 7 | plug :fetch_session 8 | end 9 | 10 | scope "/" do 11 | addict :routes 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | services: 2 | - postgresql 3 | before_script: 4 | - psql -c 'create database addict_test;' -U postgres 5 | language: elixir 6 | elixir: 7 | - 1.2.0 8 | otp_release: 9 | - 18.2.1 10 | sudo: false 11 | notifications: 12 | recipients: 13 | - nizar.venturini@gmail.com 14 | -------------------------------------------------------------------------------- /lib/addict/interactors/generate_encrypted_password.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Interactors.GenerateEncryptedPassword do 2 | @doc """ 3 | Securely hashes `password` 4 | 5 | Returns the hash as a String 6 | """ 7 | def call(password) do 8 | Comeonin.Pbkdf2.hashpwsalt password 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /example_app/web/views/user_management_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.UserManagementView do 2 | use ExampleApp.Web, :view 3 | def current_user(conn) do 4 | Addict.Helper.current_user(conn) 5 | end 6 | 7 | def is_logged_in(conn) do 8 | current_user(conn) != nil 9 | end 10 | 11 | def user_info(conn) do 12 | conn |> current_user |> Poison.encode! 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /example_app/priv/repo/migrations/20160229201901_create_user.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.Repo.Migrations.CreateUser do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add :email, :string 7 | add :encrypted_password, :string 8 | add :name, :string 9 | 10 | timestamps 11 | end 12 | create unique_index(:users, [:email]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /example_app/priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # ExampleApp.Repo.insert!(%ExampleApp.SomeModel{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /example_app/web/templates/user_management/form.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 | Name 4 | 5 |
6 |
7 | E-mail 8 | 9 |
10 |
11 | Password 12 | 13 |
14 | 15 |
-------------------------------------------------------------------------------- /example_app/README.md: -------------------------------------------------------------------------------- 1 | # ExampleApp 2 | 3 | To start your Addicted Phoenix app: 4 | 5 | * Fine tune Addict mailgun configs on `config.exs` 6 | * Install dependencies with `mix deps.get` 7 | * Create and migrate your database with `mix ecto.create && mix ecto.migrate` 8 | * Start Phoenix endpoint with `mix phoenix.server` 9 | 10 | 11 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 12 | 13 | -------------------------------------------------------------------------------- /lib/addict/interactors/destroy_session.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Interactors.DestroySession do 2 | import Plug.Conn 3 | @doc """ 4 | Removes `:current_user` from the session in `conn` 5 | 6 | Returns `{:ok, conn}` 7 | """ 8 | 9 | def call(conn) do 10 | conn = conn 11 | |> fetch_session 12 | |> delete_session(:current_user) 13 | |> assign(:current_user, nil) 14 | {:ok, conn} 15 | end 16 | 17 | end 18 | -------------------------------------------------------------------------------- /lib/addict/interactors/insert_user.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Interactors.InsertUser do 2 | @doc """ 3 | Inserts the `schema` populated with `user_params` to the `repo`. 4 | 5 | Returns `{:ok, user}` or `{:error, error_message}` 6 | """ 7 | def call(schema, user_params, repo) do 8 | user_params = for {key, val} <- user_params, into: %{}, do: {String.to_atom(key), val} 9 | repo.insert struct(schema, user_params) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /example_app/.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | 7 | # Generate on crash by the VM 8 | erl_crash.dump 9 | 10 | # The config/prod.secret.exs file by default contains sensitive 11 | # data and you should not commit it into version control. 12 | # 13 | # Alternatively, you may comment the line below and commit the 14 | # secrets file as long as you replace its contents by environment 15 | # variables. 16 | /config/prod.secret.exs -------------------------------------------------------------------------------- /lib/addict/interactors/create_session.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Interactors.CreateSession do 2 | import Plug.Conn 3 | 4 | @doc """ 5 | Adds `user` as `:current_user` to the session in `conn` 6 | 7 | Returns `{:ok, conn}` 8 | """ 9 | def call(conn, user, schema \\ Addict.Configs.user_schema) do 10 | conn = conn 11 | |> fetch_session 12 | |> put_session(:current_user, Addict.Presenter.strip_all(user, schema)) 13 | {:ok, conn} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /example_app/web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.ErrorView do 2 | use ExampleApp.Web, :view 3 | 4 | def render("404.html", _assigns) do 5 | "Page not found" 6 | end 7 | 8 | def render("500.html", _assigns) do 9 | "Server internal error" 10 | end 11 | 12 | # In case no render clause matches or no 13 | # template is found, let's render it as 500 14 | def template_not_found(_template, assigns) do 15 | render "500.html", assigns 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/addict/helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Helper do 2 | @moduledoc """ 3 | Addict Helper functions 4 | """ 5 | 6 | @doc """ 7 | Returns the current user in session in a Hash 8 | """ 9 | def current_user(conn) do 10 | conn |> Plug.Conn.fetch_session |> Plug.Conn.get_session(:current_user) 11 | end 12 | 13 | @doc """ 14 | Verifies if user is logged in 15 | 16 | Returns a boolean 17 | """ 18 | def is_logged_in(conn) do 19 | current_user(conn) != nil 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /example_app/web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.PageController do 2 | use ExampleApp.Web, :controller 3 | plug Addict.Plugs.Authenticated when action in [:required_login] 4 | 5 | def index(%{method: "GET"} = conn, _params) do 6 | render conn, "index.html" 7 | end 8 | 9 | def index(%{method: "POST"} = conn, _params) do 10 | json conn, %{} 11 | end 12 | 13 | def required_login(conn, _params) do 14 | render conn, "required_login.html" 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/addict/interactors/update_user_password.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Interactors.UpdateUserPassword do 2 | alias Addict.Interactors.GenerateEncryptedPassword 3 | @doc """ 4 | Updates the user `encrypted_password` 5 | 6 | Returns `{:ok, user}` or `{:error, [errors]}` 7 | """ 8 | 9 | def call(user, password, repo \\ Addict.Configs.repo) do 10 | user 11 | |> Ecto.Changeset.change(encrypted_password: GenerateEncryptedPassword.call(password)) 12 | |> repo.update 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /lib/addict/interactors/login.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Interactors.Login do 2 | alias Addict.Interactors.{GetUserByEmail, VerifyPassword} 3 | 4 | @doc """ 5 | Verifies if the `password` is correct for the provided `email` 6 | 7 | Returns `{:ok, user}` or `{:error, [errors]}` 8 | """ 9 | def call(%{"email" => email, "password" => password}) do 10 | with {:ok, user} <- GetUserByEmail.call(email), 11 | {:ok} <- VerifyPassword.call(user, password), 12 | do: {:ok, user} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/addict/interactors/inject_hash.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Interactors.InjectHash do 2 | alias Addict.Interactors.GenerateEncryptedPassword 3 | @doc """ 4 | Adds `"encrypted_password"` and drops `"password"` from provided hash. 5 | 6 | Returns the new hash with `"encrypted_password"` and without `"password"`. 7 | """ 8 | def call(user_params) do 9 | user_params 10 | |> Map.put("encrypted_password", GenerateEncryptedPassword.call(user_params["password"])) 11 | |> Map.drop(["password"]) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /example_app/web/templates/user_management/show.html.eex: -------------------------------------------------------------------------------- 1 |

Show user

2 | 3 | 21 | 22 | <%= link "Edit", to: user_management_path(@conn, :edit, @user) %> 23 | <%= link "Back", to: user_management_path(@conn, :index) %> 24 | -------------------------------------------------------------------------------- /lib/addict/interactors/get_user_by_id.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Interactors.GetUserById do 2 | @doc """ 3 | Gets user by e-mail. 4 | Returns `{:ok, user}` or `{:error, [user_id: "Unable to find user"]}` 5 | """ 6 | def call(id, schema \\ Addict.Configs.user_schema, repo \\ Addict.Configs.repo) do 7 | repo.get_by(schema, id: id) |> process_response 8 | end 9 | 10 | defp process_response(nil) do 11 | {:error, [user_id: "Unable to find user"]} 12 | end 13 | 14 | defp process_response(user) do 15 | {:ok, user} 16 | end 17 | 18 | end 19 | -------------------------------------------------------------------------------- /example_app/test/models/user_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.UserTest do 2 | use ExampleApp.ModelCase 3 | 4 | alias ExampleApp.User 5 | 6 | @valid_attrs %{email: "some content", encrypted_password: "some content", name: "some content"} 7 | @invalid_attrs %{} 8 | 9 | test "changeset with valid attributes" do 10 | changeset = User.changeset(%User{}, @valid_attrs) 11 | assert changeset.valid? 12 | end 13 | 14 | test "changeset with invalid attributes" do 15 | changeset = User.changeset(%User{}, @invalid_attrs) 16 | refute changeset.valid? 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/addict/interactors/verify_password.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Interactors.VerifyPassword do 2 | @doc """ 3 | Verifies if the password for the user is valid 4 | 5 | Returns `{:ok}` or `{:error, [authentication: "Incorrect e-mail/password"]}` 6 | """ 7 | def call(user, password) do 8 | Comeonin.Pbkdf2.checkpw(password, user.encrypted_password) |> process_response 9 | end 10 | 11 | defp process_response(false) do 12 | {:error, [authentication: "Incorrect e-mail/password"]} 13 | end 14 | 15 | defp process_response(true) do 16 | {:ok} 17 | end 18 | 19 | 20 | end 21 | -------------------------------------------------------------------------------- /lib/addict/interactors/get_user_by_email.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Interactors.GetUserByEmail do 2 | @doc """ 3 | Gets user by e-mail. 4 | Returns `{:ok, user}` or `{:error, [authentication: "Incorrect e-mail/password"]}` 5 | """ 6 | def call(email, schema \\ Addict.Configs.user_schema, repo \\ Addict.Configs.repo) do 7 | repo.get_by(schema, email: email) |> process_response 8 | end 9 | 10 | defp process_response(nil) do 11 | {:error, [authentication: "Incorrect e-mail/password"]} 12 | end 13 | 14 | defp process_response(user) do 15 | {:ok, user} 16 | end 17 | 18 | end 19 | -------------------------------------------------------------------------------- /example_app/config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :example_app, ExampleApp.Endpoint, 6 | http: [port: 4001], 7 | server: false 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | 12 | # Configure your database 13 | config :example_app, ExampleApp.Repo, 14 | adapter: Ecto.Adapters.Postgres, 15 | username: "postgres", 16 | password: "postgres", 17 | database: "example_app_test", 18 | hostname: "localhost", 19 | pool: Ecto.Adapters.SQL.Sandbox 20 | -------------------------------------------------------------------------------- /lib/addict/presenter.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Presenter do 2 | @moduledoc """ 3 | Normalized structure presentation 4 | """ 5 | 6 | @doc """ 7 | Strips all associations, `:__struct__`, `:__meta__` and `:encrypted_password` from the structure 8 | 9 | Returns the stripped structure 10 | """ 11 | def strip_all(model, schema \\ Addict.Configs.user_schema) do 12 | model |> drop_keys(schema) 13 | end 14 | 15 | defp drop_keys(model, schema) do 16 | associations = schema.__schema__(:associations) 17 | Map.drop model, associations ++ [:__struct__, :__meta__, :encrypted_password] 18 | end 19 | 20 | end -------------------------------------------------------------------------------- /example_app/test/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.ErrorViewTest do 2 | use ExampleApp.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(ExampleApp.ErrorView, "404.html", []) == 9 | "Page not found" 10 | end 11 | 12 | test "render 500.html" do 13 | assert render_to_string(ExampleApp.ErrorView, "500.html", []) == 14 | "Server internal error" 15 | end 16 | 17 | test "render any other" do 18 | assert render_to_string(ExampleApp.ErrorView, "505.html", []) == 19 | "Server internal error" 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/interactors/inject_hash_test.exs: -------------------------------------------------------------------------------- 1 | defmodule InjectHashTest do 2 | alias Addict.Interactors.InjectHash 3 | use ExUnit.Case, async: true 4 | 5 | test "it injects the encrypted_password attribute" do 6 | params = %{"email" => "john.doe@example.com", "password" => "ma pass phrase"} 7 | encrypted_password = InjectHash.call(params)["encrypted_password"] 8 | assert Comeonin.Pbkdf2.checkpw(params["password"], encrypted_password) == true 9 | end 10 | 11 | test "it removes the password attribute" do 12 | params = %{"email" => "john.doe@example.com", "password" => "ma pass phrase"} 13 | new_params = InjectHash.call(params) 14 | assert new_params["password"] == nil 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /boilerplate/recover_password.html.eex: -------------------------------------------------------------------------------- 1 |

Recover Password

2 |
3 | 17 |
18 | -------------------------------------------------------------------------------- /test/interactors/generate_password_reset_link_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GeneratePasswordResetLinkTest do 2 | alias Addict.Interactors.GeneratePasswordResetLink 3 | use ExUnit.Case, async: true 4 | 5 | test "it generates a reset password path" do 6 | user_id = 123 7 | {:ok, result} = GeneratePasswordResetLink.call(user_id, "T01HLTEzMzctczNjcjM3NQ==") 8 | assert Regex.match?(~r/\/reset_password\?token=.+&signature=./, result) 9 | end 10 | 11 | test "it generates a custom reset password path" do 12 | user_id = 123 13 | {:ok, result} = GeneratePasswordResetLink.call(user_id, "T01HLTEzMzctczNjcjM3NQ==", "/woooh") 14 | assert Regex.match?(~r/\/woooh\?token=.+&signature=./, result) 15 | end 16 | 17 | end 18 | -------------------------------------------------------------------------------- /example_app/web/templates/addict/recover_password.html.eex: -------------------------------------------------------------------------------- 1 |

Recover Password

2 |
3 | 17 |
18 | -------------------------------------------------------------------------------- /lib/addict/crypto.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Crypto do 2 | @moduledoc """ 3 | Signs and verifies text 4 | """ 5 | 6 | @doc """ 7 | Sign `plaintext` with a `key` 8 | """ 9 | def sign(plaintext, key \\ Addict.Configs.secret_key) do 10 | :crypto.hmac(:sha512, key, plaintext) |> Base.encode16 11 | end 12 | 13 | @doc """ 14 | Verify `plaintext` is signed with a `key` 15 | """ 16 | def verify(plaintext, signature, key \\ Addict.Configs.secret_key) do 17 | base_signature = sign(plaintext, key) 18 | do_verify(base_signature == signature) 19 | end 20 | 21 | defp do_verify(true) do 22 | {:ok, true} 23 | end 24 | 25 | defp do_verify(false) do 26 | {:error, [{:token, "Password reset token not valid."}]} 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /example_app/web/templates/user_management/_send_reset_password_link.html.eex: -------------------------------------------------------------------------------- 1 |

Recover Password

2 |
3 | 17 |
18 | -------------------------------------------------------------------------------- /lib/addict/interactors/generate_password_reset_link.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Interactors.GeneratePasswordResetLink do 2 | @doc """ 3 | Generates a ready to use password reset path. The generated token is timestamped. 4 | 5 | Returns the password reset path with a token and it's respective signature. 6 | """ 7 | def call(user_id, secret \\ Addict.Configs.secret_key, reset_path \\ Addict.Configs.reset_password_path) do 8 | current_time = to_string(:erlang.system_time(:seconds)) 9 | reset_string = Base.encode16 "#{current_time},#{user_id}" 10 | reset_path = reset_path || "/reset_password" 11 | signature = Addict.Crypto.sign(reset_string, secret) 12 | {:ok, "#{reset_path}?token=#{reset_string}&signature=#{signature}"} 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /lib/addict/configs.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Configs do 2 | [ 3 | :secret_key, 4 | :generate_csrf_token, 5 | :password_strategies, 6 | :not_logged_in_url, 7 | :user_schema, 8 | :post_register, 9 | :post_login, 10 | :post_logout, 11 | :post_reset_password, 12 | :post_recover_password, 13 | :extra_validation, 14 | :mail_service, 15 | :from_email, 16 | :host, 17 | :email_register_subject, 18 | :email_register_template, 19 | :email_reset_password_subject, 20 | :email_reset_password_template, 21 | :reset_password_path, 22 | :repo 23 | ] |> Enum.each(fn key -> 24 | def unquote(key)() do 25 | Application.get_env(:addict, unquote(key)) 26 | end 27 | end) 28 | 29 | end 30 | -------------------------------------------------------------------------------- /example_app/web/models/user.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.User do 2 | use ExampleApp.Web, :model 3 | 4 | schema "users" do 5 | field :name, :string 6 | field :email, :string 7 | field :encrypted_password, :string 8 | 9 | timestamps 10 | end 11 | 12 | @required_fields ~w(name email encrypted_password) 13 | @optional_fields ~w() 14 | 15 | @doc """ 16 | Creates a changeset based on the `model` and `params`. 17 | 18 | If no params are provided, an invalid changeset is returned 19 | with no validation performed. 20 | """ 21 | def changeset(model, params \\ :empty) do 22 | model 23 | |> cast(params, @required_fields, @optional_fields) 24 | end 25 | 26 | def validate({valid, errors}, user_params) do 27 | {valid, errors} 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/addict/mailers/mailers.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Mailers do 2 | @moduledoc """ 3 | Sends e-mail using the configured mail service on `Addict.Configs.mail_service` 4 | """ 5 | require Logger 6 | 7 | def send_email(to, from, subject, html_body, mail_service \\ Addict.Configs.mail_service) do 8 | do_send_email(to, from, subject, html_body, mail_service) 9 | end 10 | 11 | defp do_send_email(_, _, _, _, nil) do 12 | Logger.debug "Not sending e-mail: No registered mail service." 13 | {:ok, nil} 14 | end 15 | 16 | defp do_send_email(to, from, subject, html_body, mail_service) do 17 | mail_service = to_string(mail_service) |> Mix.Utils.camelize 18 | mailer = Module.concat Addict.Mailers, mail_service 19 | mailer.send_email(to, from, subject, html_body) 20 | end 21 | 22 | end 23 | -------------------------------------------------------------------------------- /test/interactors/register_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RegisterTest do 2 | # alias Addict.Interactors.ValidatePassword 3 | use ExUnit.Case, async: true 4 | 5 | test "it passes on happy path" do 6 | # changeset = %TestAddictUser{} |> Ecto.Changeset.cast(%{password: "one passphrase"}, ~w(password),[]) 7 | # %Ecto.Changeset{errors: errors, valid?: valid} = ValidatePassword.call(changeset, []) 8 | # assert errors == [] 9 | # assert valid == true 10 | end 11 | 12 | test "it validates the default use case" do 13 | # changeset = %TestAddictUser{} |> Ecto.Changeset.cast(%{password: "123"}, ~w(password),[]) 14 | # %Ecto.Changeset{errors: errors, valid?: valid} = ValidatePassword.call(changeset, []) 15 | # assert errors == [password: "is too short"] 16 | # assert valid == false 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /example_app/web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](http://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import ExampleApp.Gettext 9 | 10 | # Simple translation 11 | gettext "Here is the string to translate" 12 | 13 | # Plural translation 14 | ngettext "Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3 17 | 18 | # Domain-based translation 19 | dgettext "errors", "Here is the error message to translate" 20 | 21 | See the [Gettext Docs](http://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :example_app 24 | end 25 | -------------------------------------------------------------------------------- /test/crypto_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CryptoTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "it ciphers with a given key" do 5 | key = "SQkFekcLjcPAHRIKc3t4z9hn0Mehimticphd2WUpXSo=" 6 | signature = Addict.Crypto.sign("plain text", key) 7 | assert signature == "C6DA7D55E213A8D48B6077FCC6D9E6B98CED22A91D42AC9DDA68E15044BC52328D92A0AF051EB457416E2B922565E3FF75AEF0222291AAB3C7E43FD315DF8E72" 8 | end 9 | 10 | test "it is able to verify a signature" do 11 | key = "SQkFekcLjcPAHRIKc3t4z9hn0Mehimticphd2WUpXSo=" 12 | signature = "C6DA7D55E213A8D48B6077FCC6D9E6B98CED22A91D42AC9DDA68E15044BC52328D92A0AF051EB457416E2B922565E3FF75AEF0222291AAB3C7E43FD315DF8E72" 13 | {_status, result} = Addict.Crypto.verify("plain text", key, signature) 14 | assert result == [token: "Password reset token not valid."] 15 | end 16 | 17 | end 18 | -------------------------------------------------------------------------------- /test/interactors/destroy_session_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DestroySessionTest do 2 | alias Addict.Interactors.{CreateSession, DestroySession} 3 | use ExUnit.Case, async: true 4 | use Plug.Test 5 | 6 | @session_opts Plug.Session.init [ 7 | store: :cookie, 8 | key: "_test", 9 | encryption_salt: "abcdefgh", 10 | signing_salt: "abcdefgh" 11 | ] 12 | 13 | test "it removes the :current_user key from the session" do 14 | fake_user = %{id: 123, email: "john.doe@example.com"} 15 | 16 | conn = conn(:get, "/") 17 | |> Plug.Session.call(@session_opts) 18 | |> fetch_session 19 | {:ok, conn} = CreateSession.call(conn, fake_user, TestAddictSchema) 20 | 21 | {:ok, conn} = DestroySession.call(conn) 22 | 23 | assert Plug.Conn.get_session(conn, :current_user) == nil 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /test/interactors/create_session_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CreateSessionTest do 2 | alias Addict.Interactors.CreateSession 3 | use ExUnit.Case, async: true 4 | use Plug.Test 5 | 6 | @session_opts Plug.Session.init [ 7 | store: :cookie, 8 | key: "_test", 9 | encryption_salt: "abcdefgh", 10 | signing_salt: "abcdefgh" 11 | ] 12 | 13 | test "it adds the :current_user key to the session" do 14 | fake_user = %{id: 123, email: "john.doe@example.com"} 15 | 16 | conn = conn(:get, "/") 17 | |> Plug.Session.call(@session_opts) 18 | |> fetch_session 19 | 20 | assert Plug.Conn.get_session(conn, :current_user) == nil 21 | 22 | {:ok, conn} = CreateSession.call(conn, fake_user, TestAddictSchema) 23 | 24 | assert Plug.Conn.get_session(conn, :current_user) == fake_user 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"comeonin": {:hex, :comeonin, "2.1.1"}, 2 | "connection": {:hex, :connection, "1.0.2"}, 3 | "cowboy": {:hex, :cowboy, "1.0.4"}, 4 | "cowlib": {:hex, :cowlib, "1.0.2"}, 5 | "db_connection": {:hex, :db_connection, "0.2.4"}, 6 | "decimal": {:hex, :decimal, "1.1.1"}, 7 | "earmark": {:hex, :earmark, "0.2.1"}, 8 | "ecto": {:hex, :ecto, "1.1.4"}, 9 | "ecto_fixtures": {:hex, :ecto_fixtures, "0.0.2"}, 10 | "ex_doc": {:hex, :ex_doc, "0.11.4"}, 11 | "mailgun": {:hex, :mailgun, "0.1.2"}, 12 | "meck": {:hex, :meck, "0.8.4"}, 13 | "mock": {:hex, :mock, "0.1.3"}, 14 | "phoenix": {:hex, :phoenix, "1.1.4"}, 15 | "plug": {:hex, :plug, "1.1.2"}, 16 | "poison": {:hex, :poison, "1.5.2"}, 17 | "poolboy": {:hex, :poolboy, "1.5.1"}, 18 | "postgrex": {:hex, :postgrex, "0.11.1"}, 19 | "ranch": {:hex, :ranch, "1.2.1"}, 20 | "uuid": {:hex, :uuid, "1.1.3"}} 21 | -------------------------------------------------------------------------------- /example_app/mix.lock: -------------------------------------------------------------------------------- 1 | %{"comeonin": {:hex, :comeonin, "2.1.1"}, 2 | "connection": {:hex, :connection, "1.0.2"}, 3 | "cowboy": {:hex, :cowboy, "1.0.4"}, 4 | "cowlib": {:hex, :cowlib, "1.0.2"}, 5 | "db_connection": {:hex, :db_connection, "0.2.4"}, 6 | "decimal": {:hex, :decimal, "1.1.1"}, 7 | "ecto": {:hex, :ecto, "1.1.4"}, 8 | "fs": {:hex, :fs, "0.9.2"}, 9 | "gettext": {:hex, :gettext, "0.10.0"}, 10 | "mailgun": {:hex, :mailgun, "0.1.2"}, 11 | "phoenix": {:hex, :phoenix, "1.1.4"}, 12 | "phoenix_ecto": {:hex, :phoenix_ecto, "2.0.1"}, 13 | "phoenix_html": {:hex, :phoenix_html, "2.5.0"}, 14 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.0.3"}, 15 | "plug": {:hex, :plug, "1.1.2"}, 16 | "poison": {:hex, :poison, "1.5.2"}, 17 | "poolboy": {:hex, :poolboy, "1.5.1"}, 18 | "postgrex": {:hex, :postgrex, "0.11.1"}, 19 | "ranch": {:hex, :ranch, "1.2.1"}} 20 | -------------------------------------------------------------------------------- /test/interactors/validate_password_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ValidatePasswordTest do 2 | alias Addict.Interactors.ValidatePassword 3 | use ExUnit.Case, async: true 4 | 5 | defmodule Addict.PasswordUser do 6 | use Ecto.Schema 7 | 8 | schema "users" do 9 | field :password, :string 10 | field :email, :string 11 | end 12 | end 13 | 14 | test "it passes on happy path" do 15 | changeset = %Addict.PasswordUser{} |> Ecto.Changeset.cast(%{password: "one passphrase"}, ~w(password),[]) 16 | {:ok, errors} = ValidatePassword.call(changeset, []) 17 | assert errors == [] 18 | end 19 | 20 | test "it validates the default use case" do 21 | changeset = %Addict.PasswordUser{} |> Ecto.Changeset.cast(%{password: "123"}, ~w(password),[]) 22 | {:error, errors} = ValidatePassword.call(changeset, []) 23 | assert errors == [password: "is too short"] 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /boilerplate/reset_password.html.eex: -------------------------------------------------------------------------------- 1 |

Reset Password

2 |
3 | 17 |
18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /test/interactors/verify_password_test.exs: -------------------------------------------------------------------------------- 1 | defmodule VerifyPasswordTest do 2 | alias Addict.Interactors.VerifyPassword 3 | use ExUnit.Case, async: true 4 | 5 | test "it validates correct passwords" do 6 | user = %TestAddictSchema{encrypted_password: "$pbkdf2-sha512$100000$od8q.7wWyoUzThxnm7mnHQ$kW4PEzo9l/f.emd5khI3EhpjQaLMKecCTl3YrPNglhYNzCtyfmaggCtHWDpM7MS/Kv4eewwl12HbcHiMn3nnPg"} 7 | {status} = VerifyPassword.call(user, "ma password") 8 | assert status == :ok 9 | end 10 | 11 | test "it validates incorrect passwords" do 12 | user = %TestAddictSchema{encrypted_password: "$pbkdf2-sha512$100000$od8q.7wWyoUzThxnm7mnHQ$kW4PEzo9l/f.emd5khI3EhpjQaLMKecCTl3YrPNglhYNzCtyfmaggCtHWDpM7MS/Kv4eewwl12HbcHiMn3nnPg"} 13 | {status, errors} = VerifyPassword.call(user, "incorrect password") 14 | assert status == :error 15 | assert errors == [authentication: "Incorrect e-mail/password"] 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /example_app/web/templates/addict/reset_password.html.eex: -------------------------------------------------------------------------------- 1 |

Reset Password

2 |
3 | 17 |
18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /example_app/web/templates/user_management/_reset_password.html.eex: -------------------------------------------------------------------------------- 1 |

Reset Password

2 |
3 | 17 |
18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /boilerplate/login.html.eex: -------------------------------------------------------------------------------- 1 |

Login

2 |
3 | 22 |
23 | -------------------------------------------------------------------------------- /example_app/web/templates/addict/login.html.eex: -------------------------------------------------------------------------------- 1 |

Login

2 |
3 | 22 |
23 | -------------------------------------------------------------------------------- /example_app/web/templates/user_management/_login.html.eex: -------------------------------------------------------------------------------- 1 |

Login

2 |
3 | 22 |
23 | -------------------------------------------------------------------------------- /test/presenter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PresenterTest do 2 | alias Addict.Presenter 3 | use ExUnit.Case, async: true 4 | 5 | test "it strips associations" do 6 | user = struct( 7 | TestAddictUserAssociationsSchema, 8 | %{ 9 | name: "Joe Doe", 10 | email: "joe.doe@example.com", 11 | encrypted_password: "what a hash!" 12 | }) 13 | 14 | model = Presenter.strip_all(user, TestAddictUserAssociationsSchema) 15 | 16 | assert Map.has_key?(model, :__struct__) == false 17 | assert Map.has_key?(model, :__meta__) == false 18 | assert Map.has_key?(model, :drugs) == false 19 | end 20 | end 21 | 22 | defmodule TestAddictDrugsSchema do 23 | use Ecto.Schema 24 | schema "drugs" do 25 | field :name, :string 26 | end 27 | end 28 | 29 | defmodule TestAddictUserAssociationsSchema do 30 | use Ecto.Schema 31 | 32 | schema "users" do 33 | field :name, :string 34 | field :email, :string 35 | field :encrypted_password, :string 36 | has_many :drugs, TestAddictDrugsSchema 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/addict/mailers/mailgun.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Mailers.Mailgun do 2 | @behaviour Addict.Mailers.Generic 3 | @moduledoc """ 4 | Wrapper for Mailgun client that handles eventual errors. 5 | """ 6 | require Logger 7 | use Mailgun.Client, domain: Application.get_env(:addict, :mailgun_domain), 8 | key: Application.get_env(:addict, :mailgun_key) 9 | 10 | def send_email(email, from, subject, html_body) do 11 | result = send_email to: email, 12 | from: from, 13 | subject: subject, 14 | html: html_body 15 | 16 | case result do 17 | {:error, status, json_body} -> handle_error(email, status, json_body) 18 | _ -> {:ok, result} 19 | end 20 | end 21 | 22 | defp handle_error(email, status, json_body) do 23 | Logger.debug "Unable to send e-mail to #{email}" 24 | Logger.debug "status: #{status}" 25 | Logger.debug "reason: " 26 | Logger.debug json_body 27 | 28 | {:error, [email: "Unable to send e-mail (#{to_string(status)})"]} 29 | end 30 | 31 | end 32 | -------------------------------------------------------------------------------- /lib/addict/interactors/send_reset_password_link.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Interactors.SendResetPasswordEmail do 2 | alias Addict.Interactors.{GetUserByEmail, GeneratePasswordResetLink} 3 | require Logger 4 | 5 | @doc """ 6 | Executes the password recovery flow: verifies if the user exists and sends the e-mail with the reset link 7 | 8 | Either returns `{:ok, user}` or `{:ok, nil}`. `{:ok, nil}` is returned when e-mail is not found to avoid user enumeration. 9 | """ 10 | def call(email, configs \\ Addict.Configs) do 11 | {result, user} = GetUserByEmail.call(email) 12 | 13 | case result do 14 | :error -> return_false_positive(email) 15 | :ok -> with {:ok, path} <- GeneratePasswordResetLink.call(user.id, configs.secret_key), 16 | {:ok, _} <- Addict.Mailers.MailSender.send_reset_token(email, path), 17 | do: {:ok, user} 18 | 19 | end 20 | end 21 | 22 | defp return_false_positive(email) do 23 | Logger.debug("Recover Password: E-mail not found: #{email}.") 24 | {:ok, nil} 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /example_app/web/templates/user_management/index.html.eex: -------------------------------------------------------------------------------- 1 | User Session info: <%= user_info(@conn) %> 2 |
3 | <%= if is_logged_in(@conn) do %> 4 | Logout 5 | <% else %> 6 | Login 7 | <% end %> 8 |
9 | Forgot password? 10 |
11 |

Listing users

12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | <%= for user <- @users do %> 24 | 25 | 26 | 27 | 28 | 29 | 32 | 33 | <% end %> 34 | 35 |
NameEmailEncrypted password
<%= user.name %><%= user.email %><%= user.encrypted_password %> 30 | <%= link "Delete", to: user_management_path(@conn, :delete, user), method: :delete, data: [confirm: "Are you sure?"], class: "btn btn-danger btn-xs" %> 31 |
36 | 37 | <%= link "Register user", to: "/register" %> 38 | -------------------------------------------------------------------------------- /boilerplate/register.html.eex: -------------------------------------------------------------------------------- 1 |

Register

2 |
3 | 25 |
26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Nizar Venturini 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /example_app/web/templates/addict/register.html.eex: -------------------------------------------------------------------------------- 1 |

Register

2 |
3 | 25 |
26 | -------------------------------------------------------------------------------- /example_app/lib/example_app.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp do 2 | use Application 3 | 4 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 5 | # for more information on OTP Applications 6 | def start(_type, _args) do 7 | import Supervisor.Spec, warn: false 8 | 9 | children = [ 10 | # Start the endpoint when the application starts 11 | supervisor(ExampleApp.Endpoint, []), 12 | # Start the Ecto repository 13 | supervisor(ExampleApp.Repo, []), 14 | # Here you could define other workers and supervisors as children 15 | # worker(ExampleApp.Worker, [arg1, arg2, arg3]), 16 | ] 17 | 18 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 19 | # for other strategies and supported options 20 | opts = [strategy: :one_for_one, name: ExampleApp.Supervisor] 21 | Supervisor.start_link(children, opts) 22 | end 23 | 24 | # Tell Phoenix to update the endpoint configuration 25 | # whenever the application is updated. 26 | def config_change(changed, _new, removed) do 27 | ExampleApp.Endpoint.config_change(changed, removed) 28 | :ok 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /example_app/web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Hello ExampleApp! 11 | "> 12 | 13 | 14 | 15 |
16 |
17 | 18 |
19 | 20 | 21 | 22 | 23 |
24 | <%= render @view_module, @view_template, assigns %> 25 |
26 | 27 |
28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /example_app/web/templates/user_management/_register.html.eex: -------------------------------------------------------------------------------- 1 |

Register

2 |
3 | 25 |
26 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for third- 9 | # party users, it should be done in your mix.exs file. 10 | 11 | # Sample configuration: 12 | # 13 | # config :logger, :console, 14 | # level: :info, 15 | # format: "$date $time [$level] $metadata$message\n", 16 | # metadata: [:user_id] 17 | 18 | # It is also possible to import configuration files, relative to this 19 | # directory. For example, you can emulate configuration per environment 20 | # by uncommenting the line below and defining dev.exs, test.exs and such. 21 | # Configuration from the imported file will override the ones defined 22 | # here (which is why it is important to import them last). 23 | # 24 | if Mix.env == :test do 25 | import_config "test.exs" 26 | end 27 | -------------------------------------------------------------------------------- /example_app/web/templates/page/index.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

<%= gettext "Welcome to %{name}", name: "Addict(ed) Phoenix!" %>

3 |

don't worry about user management

4 |
5 | 6 |
7 |
8 |

User management

9 | 29 |
30 | 31 |
32 |

Help

33 | 38 |
39 |
40 | -------------------------------------------------------------------------------- /example_app/test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | imports other functionality to make it easier 8 | to build and query models. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with channels 21 | use Phoenix.ChannelTest 22 | 23 | alias ExampleApp.Repo 24 | import Ecto 25 | import Ecto.Changeset 26 | import Ecto.Query, only: [from: 1, from: 2] 27 | 28 | 29 | # The default endpoint for testing 30 | @endpoint ExampleApp.Endpoint 31 | end 32 | end 33 | 34 | setup tags do 35 | unless tags[:async] do 36 | Ecto.Adapters.SQL.restart_test_transaction(ExampleApp.Repo, []) 37 | end 38 | 39 | :ok 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /example_app/web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | if error = form.errors[field] do 13 | content_tag :span, translate_error(error), class: "help-block" 14 | end 15 | end 16 | 17 | @doc """ 18 | Translates an error message using gettext. 19 | """ 20 | def translate_error({msg, opts}) do 21 | # Because error messages were defined within Ecto, we must 22 | # call the Gettext module passing our Gettext backend. We 23 | # also use the "errors" domain as translations are placed 24 | # in the errors.po file. On your own code and templates, 25 | # this could be written simply as: 26 | # 27 | # dngettext "errors", "1 file", "%{count} files", count 28 | # 29 | Gettext.dngettext(ExampleApp.Gettext, "errors", msg, msg, opts[:count], opts) 30 | end 31 | 32 | def translate_error(msg) do 33 | Gettext.dgettext(ExampleApp.Gettext, "errors", msg) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/addict/interactors/register.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Interactors.Register do 2 | alias Addict.Interactors.{ValidateUserForRegistration, InsertUser, InjectHash} 3 | 4 | @doc """ 5 | Executes the user registration flow: parameters validation, password hash generation, user insertion and e-mail sending. 6 | Also applies custom defined `Addict.Configs.extra_validation/2`. 7 | 8 | Returns `{:ok, user}` or `{:error, [errors]}` 9 | """ 10 | def call(user_params, configs \\ Addict.Configs) do 11 | extra_validation = configs.extra_validation || fn (a,_) -> a end 12 | 13 | {valid, errors} = ValidateUserForRegistration.call(user_params) 14 | user_params = InjectHash.call user_params 15 | {valid, errors} = extra_validation.({valid, errors}, user_params) 16 | 17 | case {valid, errors} do 18 | {:ok, _} -> do_register(user_params, configs) 19 | {:error, errors} -> {:error, errors} 20 | end 21 | 22 | end 23 | 24 | def do_register(user_params, configs) do 25 | with {:ok, user} <- InsertUser.call(configs.user_schema, user_params, configs.repo), 26 | {:ok, _} <- Addict.Mailers.MailSender.send_register(user_params), 27 | do: {:ok, user} 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/addict/interactors/validate_password.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Interactors.ValidatePassword do 2 | 3 | @doc """ 4 | Validates a password according to the defined strategies. 5 | For now, only the `:default` strategy exists: password must be at least 6 chars long. 6 | 7 | Returns `{:ok, []}` or `{:error, [errors]}` 8 | """ 9 | def call(changeset, nil) do 10 | call(changeset, []) 11 | end 12 | 13 | def call(changeset, strategies) do 14 | if Enum.count(strategies) == 0, do: strategies = [:default] 15 | 16 | strategies 17 | |> Enum.reduce(changeset, fn (strategy, acc) -> 18 | validate(strategy, acc) 19 | end) 20 | |> format_response 21 | end 22 | 23 | defp format_response([]) do 24 | {:ok, []} 25 | end 26 | 27 | defp format_response(messages) do 28 | {:error, messages} 29 | end 30 | 31 | defp validate(:default, password) when is_bitstring(password) do 32 | if String.length(password) > 5, do: [], else: [{:password, "is too short"}] 33 | end 34 | 35 | defp validate(:default, changeset) do 36 | Ecto.Changeset.validate_change(changeset, :password, fn (_field, value) -> 37 | validate(:default, value) 38 | end).errors 39 | end 40 | 41 | end 42 | -------------------------------------------------------------------------------- /example_app/lib/example_app/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :example_app 3 | 4 | socket "/socket", ExampleApp.UserSocket 5 | 6 | # Serve at "/" the static files from "priv/static" directory. 7 | # 8 | # You should set gzip to true if you are running phoenix.digest 9 | # when deploying your static files in production. 10 | plug Plug.Static, 11 | at: "/", from: :example_app, gzip: false, 12 | only: ~w(css fonts images js favicon.ico robots.txt) 13 | 14 | # Code reloading can be explicitly enabled under the 15 | # :code_reloader configuration of your endpoint. 16 | if code_reloading? do 17 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 18 | plug Phoenix.LiveReloader 19 | plug Phoenix.CodeReloader 20 | end 21 | 22 | plug Plug.RequestId 23 | plug Plug.Logger 24 | 25 | plug Plug.Parsers, 26 | parsers: [:urlencoded, :multipart, :json], 27 | pass: ["*/*"], 28 | json_decoder: Poison 29 | 30 | plug Plug.MethodOverride 31 | plug Plug.Head 32 | 33 | plug Plug.Session, 34 | store: :cookie, 35 | key: "_example_app_key", 36 | signing_salt: "eHBFCbKk" 37 | 38 | plug ExampleApp.Router 39 | end 40 | -------------------------------------------------------------------------------- /lib/addict/plugs/authenticated.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Plugs.Authenticated do 2 | @moduledoc """ 3 | Authenticated plug can be used to filter actions for users that are 4 | authenticated. 5 | """ 6 | import Plug.Conn 7 | 8 | def init(options) do 9 | options 10 | end 11 | 12 | 13 | @doc """ 14 | Call represents the use of the plug itself. 15 | 16 | When called, it will assign `current_user` to `conn`, so it is 17 | possible to always retrieve the user via `conn.assigns.current_user`. 18 | 19 | In case the user is not logged in, it will redirect the request to 20 | the Application :addict :not_logged_in_url page. If none is defined, it will 21 | redirect to `/error`. 22 | """ 23 | def call(conn, _) do 24 | conn = fetch_session(conn) 25 | not_logged_in_url = Addict.Configs.not_logged_in_url || "/login" 26 | if is_logged_in(get_session(conn, :current_user)) do 27 | assign(conn, :current_user, get_session(conn, :current_user)) 28 | else 29 | conn |> Phoenix.Controller.redirect(to: not_logged_in_url) |> halt 30 | end 31 | end 32 | 33 | def is_logged_in(user_session) do 34 | case user_session do 35 | nil -> false 36 | _ -> true 37 | end 38 | end 39 | 40 | end 41 | -------------------------------------------------------------------------------- /example_app/test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | imports other functionality to make it easier 8 | to build and query models. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | use Phoenix.ConnTest 22 | 23 | alias ExampleApp.Repo 24 | import Ecto 25 | import Ecto.Changeset 26 | import Ecto.Query, only: [from: 1, from: 2] 27 | 28 | import ExampleApp.Router.Helpers 29 | 30 | # The default endpoint for testing 31 | @endpoint ExampleApp.Endpoint 32 | end 33 | end 34 | 35 | setup tags do 36 | unless tags[:async] do 37 | Ecto.Adapters.SQL.restart_test_transaction(ExampleApp.Repo, []) 38 | end 39 | 40 | {:ok, conn: Phoenix.ConnTest.conn()} 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/plugs/authenticated_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AuthenticatedTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | alias Addict.Plugs.Authenticated, as: Auth 6 | 7 | @session_opts Plug.Session.init [ 8 | store: :cookie, 9 | key: "_test", 10 | encryption_salt: "abcdefgh", 11 | signing_salt: "abcdefgh" 12 | ] 13 | 14 | @authenticated_opts Auth.init [] 15 | 16 | setup_all do 17 | conn = conn(:get, "/") 18 | |> Map.put(:secret_key_base, String.duplicate("a", 64)) 19 | |> Plug.Session.call(@session_opts) 20 | |> fetch_session 21 | 22 | {:ok, %{conn: conn}} 23 | end 24 | 25 | test "assign current_user when logged in", context do 26 | conn = context.conn 27 | |> put_session(:current_user, "bob") 28 | 29 | refute Map.has_key?(conn.assigns, :current_user) 30 | 31 | conn = Auth.call(conn, @authenticated_opts) 32 | assert conn.assigns.current_user == "bob" 33 | end 34 | 35 | test "redirect when not logged in", context do 36 | conn = context.conn 37 | |> delete_session(:current_user) 38 | |> Auth.call(@authenticated_opts) 39 | 40 | assert conn.halted 41 | assert conn.status in 300..399 42 | assert get_resp_header(conn, "location") == ["/login"] 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Logger.configure(level: :info) 2 | ExUnit.start 3 | 4 | Code.require_file "./support/schema.exs", __DIR__ 5 | Code.require_file "./support/repo.exs", __DIR__ 6 | Code.require_file "./support/router.exs", __DIR__ 7 | Code.require_file "./support/migrations.exs", __DIR__ 8 | 9 | defmodule Addict.RepoSetup do 10 | use ExUnit.CaseTemplate 11 | setup_all do 12 | Ecto.Adapters.SQL.begin_test_transaction(TestAddictRepo, []) 13 | on_exit fn -> Ecto.Adapters.SQL.rollback_test_transaction(TestAddictRepo, []) end 14 | :ok 15 | end 16 | 17 | setup do 18 | Ecto.Adapters.SQL.restart_test_transaction(TestAddictRepo, []) 19 | :ok 20 | end 21 | end 22 | 23 | defmodule Addict.SessionSetup do 24 | def with_session(conn) do 25 | session_opts = Plug.Session.init(store: :cookie, key: "_app", 26 | encryption_salt: "abc", signing_salt: "abc") 27 | conn 28 | |> Map.put(:secret_key_base, String.duplicate("abcdefgh", 8)) 29 | |> Plug.Session.call(session_opts) 30 | |> Plug.Conn.fetch_session() 31 | end 32 | end 33 | 34 | _ = Ecto.Storage.down(TestAddictRepo) 35 | _ = Ecto.Storage.up(TestAddictRepo) 36 | 37 | {:ok, _pid} = TestAddictRepo.start_link 38 | _ = Ecto.Migrator.up(TestAddictRepo, 0, TestAddictMigrations, log: false) 39 | Process.flag(:trap_exit, true) 40 | -------------------------------------------------------------------------------- /example_app/web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | # channel "rooms:*", ExampleApp.RoomChannel 6 | 7 | ## Transports 8 | transport :websocket, Phoenix.Transports.WebSocket 9 | # transport :longpoll, Phoenix.Transports.LongPoll 10 | 11 | # Socket params are passed from the client and can 12 | # be used to verify and authenticate a user. After 13 | # verification, you can put default assigns into 14 | # the socket that will be set for all channels, ie 15 | # 16 | # {:ok, assign(socket, :user_id, verified_user_id)} 17 | # 18 | # To deny connection, return `:error`. 19 | # 20 | # See `Phoenix.Token` documentation for examples in 21 | # performing token verification on connect. 22 | def connect(_params, socket) do 23 | {:ok, socket} 24 | end 25 | 26 | # Socket id's are topics that allow you to identify all sockets for a given user: 27 | # 28 | # def id(socket), do: "users_socket:#{socket.assigns.user_id}" 29 | # 30 | # Would allow you to broadcast a "disconnect" event and terminate 31 | # all active sockets and channels for a given user: 32 | # 33 | # ExampleApp.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{}) 34 | # 35 | # Returning `nil` makes this socket anonymous. 36 | def id(_socket), do: nil 37 | end 38 | -------------------------------------------------------------------------------- /example_app/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with brunch.io to recompile .js and .css sources. 9 | config :example_app, ExampleApp.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | check_origin: false, 14 | watchers: [] 15 | 16 | # Watch static and templates for browser reloading. 17 | config :example_app, ExampleApp.Endpoint, 18 | live_reload: [ 19 | patterns: [ 20 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, 21 | ~r{priv/gettext/.*(po)$}, 22 | ~r{web/views/.*(ex)$}, 23 | ~r{web/templates/.*(eex)$} 24 | ] 25 | ] 26 | 27 | # Do not include metadata nor timestamps in development logs 28 | config :logger, :console, format: "[$level] $message\n" 29 | 30 | # Set a higher stacktrace during development. 31 | # Do not configure such in production as keeping 32 | # and calculating stacktraces is usually expensive. 33 | config :phoenix, :stacktrace_depth, 20 34 | 35 | # Configure your database 36 | config :example_app, ExampleApp.Repo, 37 | adapter: Ecto.Adapters.Postgres, 38 | username: "postgres", 39 | password: "postgres", 40 | database: "example_app_dev", 41 | hostname: "localhost", 42 | pool_size: 10 43 | -------------------------------------------------------------------------------- /lib/addict/routes_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.RoutesHelper do 2 | defmacro __using__(_) do 3 | quote do 4 | import Addict.RoutesHelper 5 | end 6 | end 7 | 8 | defmacro addict(:routes, options \\ %{}) do 9 | routes = [ 10 | {:register, [:get, :post]}, 11 | {:login, [:get, :post]}, 12 | {:recover_password, [:get, :post]}, 13 | {:reset_password, [:get, :post]}, 14 | {:logout, [:delete]} 15 | ] 16 | 17 | for {route, methods} <- routes do 18 | route_options = options_for_route(route, options[route]) 19 | for method <- methods do 20 | quote do 21 | unquote(method)( 22 | unquote(route_options[:path]), 23 | unquote(route_options[:controller]), 24 | unquote(route_options[:action]), 25 | as: unquote(route_options[:as])) 26 | end 27 | end 28 | end 29 | end 30 | 31 | defp options_for_route(route, options) when is_list(options) do 32 | path = route_path(route, options[:path]) 33 | controller = options[:controller] || Addict.AddictController 34 | action = options[:action] || route 35 | as = route 36 | 37 | %{path: path, controller: controller, action: action, as: as} 38 | end 39 | 40 | defp options_for_route(route, path) do 41 | options_for_route(route, [path: route_path(route, path)]) 42 | end 43 | 44 | defp route_path(route, path) do 45 | path || "/#{to_string(route)}" 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /example_app/web/controllers/user_management_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.UserManagementController do 2 | use ExampleApp.Web, :controller 3 | alias ExampleApp.User 4 | 5 | def index(conn, _params) do 6 | users = Repo.all(User) 7 | render(conn, "index.html", users: users) 8 | end 9 | 10 | def register(conn, _params) do 11 | render(conn, "register.html", csrf_token: csrf_token(conn)) 12 | end 13 | 14 | def login(conn,_params) do 15 | render(conn, "login.html", csrf_token: csrf_token(conn)) 16 | end 17 | 18 | def send_reset_password_link(conn,_params) do 19 | render(conn, "send_reset_password_link.html", csrf_token: csrf_token(conn)) 20 | end 21 | 22 | def reset_password(conn,params) do 23 | token = params["token"] 24 | signature = params["signature"] 25 | render(conn, "reset_password.html", token: token, signature: signature, csrf_token: csrf_token(conn)) 26 | end 27 | 28 | def csrf_token(conn) do 29 | # csrf_token = Plug.CSRFProtection.get_csrf_token 30 | # Plug.Conn.put_session(conn, :_csrf_token, csrf_token) 31 | # csrf_token 32 | end 33 | 34 | def delete(conn, %{"id" => id}) do 35 | user = Repo.get!(User, id) 36 | 37 | # Here we use delete! (with a bang) because we expect 38 | # it to always work (and if it does not, it will raise). 39 | Repo.delete!(user) 40 | 41 | conn 42 | |> put_flash(:info, "User deleted successfully.") 43 | |> redirect(to: user_management_path(conn, :index)) 44 | end 45 | 46 | end 47 | -------------------------------------------------------------------------------- /lib/addict/mailers/mail_sender.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Mailers.MailSender do 2 | @moduledoc """ 3 | Sends register and reset token e-mails 4 | """ 5 | require Logger 6 | 7 | def send_register(user_params) do 8 | template = Addict.Configs.email_register_template || "

Thanks for registering <%= email %>!

" 9 | subject = Addict.Configs.email_register_subject || "Welcome" 10 | user = user_params |> convert_to_list 11 | html_body = EEx.eval_string(template, user) 12 | from_email = Addict.Configs.from_email || "no-reply@addict.github.io" 13 | Addict.Mailers.send_email(user_params["email"], from_email, subject, html_body) 14 | end 15 | 16 | def send_reset_token(email, path, host \\ Addict.Configs.host) do 17 | host = host || "http://localhost:4000" 18 | template = Addict.Configs.email_reset_password_template || "

You've requested to reset your password. Click here to proceed!

" 19 | subject = Addict.Configs.email_reset_password_subject || "Reset Password" 20 | params = %{"email" => email, "path" => path} |> convert_to_list 21 | html_body = EEx.eval_string(template, params) 22 | from_email = Addict.Configs.from_email || "no-reply@addict.github.io" 23 | Addict.Mailers.send_email(email, from_email, subject, html_body) 24 | end 25 | 26 | defp convert_to_list(params) do 27 | params 28 | |> Map.to_list 29 | |> Enum.reduce([], fn ({key, value}, acc) -> 30 | Keyword.put(acc, String.to_atom(key), value) 31 | end) 32 | end 33 | 34 | end 35 | -------------------------------------------------------------------------------- /example_app/web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.Router do 2 | use ExampleApp.Web, :router 3 | use Addict.RoutesHelper 4 | 5 | pipeline :browser do 6 | plug :accepts, ["html", "json"] 7 | plug :fetch_session 8 | plug :fetch_flash 9 | # plug :protect_from_forgery 10 | plug :put_secure_browser_headers 11 | end 12 | 13 | pipeline :addict_routes do 14 | plug :accepts, ["json"] 15 | plug :fetch_session 16 | plug :fetch_flash 17 | plug :put_secure_browser_headers 18 | plug :put_layout, {Addict.AddictView, "addict.html"} 19 | 20 | end 21 | 22 | pipeline :api do 23 | plug :accepts, ["json"] 24 | end 25 | 26 | pipeline :addict_api do 27 | plug :accepts, ["json"] 28 | end 29 | 30 | scope "/" do 31 | pipe_through :addict_routes 32 | # get "/register", Addict.AddictController, :register 33 | # get "/recover_password", Addict.AddictController, :recover_password 34 | # get "/reset_password", Addict.AddictController, :reset_password 35 | # get "/login", Addict.AddictController, :login 36 | addict :routes 37 | end 38 | 39 | scope "/" do 40 | pipe_through :browser # Use the default browser stack 41 | get "/", ExampleApp.PageController, :index 42 | post "/", ExampleApp.PageController, :index 43 | get "/required_login", ExampleApp.PageController, :required_login 44 | resources "/user_management", ExampleApp.UserManagementController, only: [:index, :delete] 45 | end 46 | 47 | 48 | # Other scopes may use custom stacks. 49 | # scope "/api", ExampleApp do 50 | # pipe_through :api 51 | # end 52 | end 53 | -------------------------------------------------------------------------------- /lib/addict/interactors/validate_user_for_registration.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.PasswordUser do 2 | use Ecto.Schema 3 | 4 | schema "users" do 5 | field :password, :string 6 | field :email, :string 7 | end 8 | end 9 | 10 | defmodule Addict.Interactors.ValidateUserForRegistration do 11 | @doc """ 12 | Validates if the user is valid for insertion. 13 | Checks if `password` is valid and if `email` is well formatted and unique. 14 | 15 | Returns `{:ok, []}` or `{:error, [errors]}` 16 | """ 17 | import Ecto.Changeset 18 | alias Addict.Interactors.ValidatePassword 19 | def call(user_params, configs \\ Addict.Configs) do 20 | struct(configs.user_schema) 21 | |> cast(user_params, ~w(email), ~w()) 22 | |> validate_format(:email, ~r/.+@.+/) 23 | |> unique_constraint(:email) 24 | |> validate_password(user_params["password"], configs.password_strategies) 25 | |> format_response 26 | end 27 | 28 | defp format_response([]) do 29 | {:ok, []} 30 | end 31 | 32 | defp format_response(errors) do 33 | {:error, errors} 34 | end 35 | 36 | defp validate_password(changeset, password, password_strategies) do 37 | %Addict.PasswordUser{} 38 | |> Ecto.Changeset.cast(%{password: password}, ~w(password), []) 39 | |> ValidatePassword.call(password_strategies) 40 | |> do_validate_password(changeset.errors) 41 | end 42 | 43 | defp do_validate_password({:ok, _}, existing_errors) do 44 | existing_errors 45 | end 46 | 47 | defp do_validate_password({:error, messages}, existing_errors) do 48 | Enum.concat messages, existing_errors 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Addict.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :addict, 6 | version: "0.2.5", 7 | elixir: "~> 1.2", 8 | description: description, 9 | package: package, 10 | docs: &docs/0, 11 | deps: deps] 12 | end 13 | 14 | def application do 15 | [applications: applications(Mix.env)] 16 | end 17 | 18 | defp applications(:test) do 19 | [:plug] ++ applications(:prod) 20 | end 21 | 22 | defp applications(_) do 23 | [:phoenix, :ecto, :comeonin, :mailgun, :logger, :crypto] 24 | end 25 | 26 | defp deps do 27 | [{:cowboy, "~> 1.0"}, 28 | {:phoenix, "~> 1.1"}, 29 | {:ecto, "~> 1.1"}, 30 | {:comeonin, "~> 2.1" }, 31 | {:mailgun, "~> 0.1"}, 32 | {:mock, "~> 0.1.3", only: :test}, 33 | {:postgrex, ">= 0.0.0", only: :test}, 34 | {:earmark, "~> 0.2", only: :dev}, 35 | {:ex_doc, "~> 0.11", only: :dev}] 36 | end 37 | 38 | defp package do 39 | [ 40 | files: ["lib", "boilerplate", "docs", "mix.exs", "README*", "LICENSE*", "configs*"], 41 | contributors: ["Nizar Venturini"], 42 | maintainers: ["Nizar Venturini"], 43 | licenses: ["MIT"], 44 | links: %{"GitHub" => "https://github.com/trenpixster/addict"} 45 | ] 46 | end 47 | 48 | defp description do 49 | """ 50 | Addict allows you to manage users on your Phoenix app easily. Register, login, 51 | logout, recover password and password updating is available off-the-shelf. 52 | """ 53 | end 54 | 55 | defp docs do 56 | {ref, 0} = System.cmd("git", ["rev-parse", "--verify", "--quiet", "HEAD"]) 57 | [source_ref: ref, 58 | main: "readme", 59 | extras: ["README.md","configs.md"]] 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/interactors/validate_user_for_registration_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ValidateUserForRegistrationTest do 2 | alias Addict.Interactors.ValidateUserForRegistration 3 | use ExUnit.Case, async: false 4 | 5 | defmodule ConfigsMock do 6 | def password_strategies, do: [] 7 | def user_schema, do: %TestAddictSchema{} 8 | def fn_extra_validation(arg), do: arg 9 | end 10 | 11 | test "it validates the default params" do 12 | user_params = %{ 13 | "password" => "one passphrase", 14 | "email" => "bla@ble.com", 15 | } 16 | 17 | {status, errors} = ValidateUserForRegistration.call(user_params, ConfigsMock) 18 | 19 | assert errors == [] 20 | assert status == :ok 21 | end 22 | 23 | test "it fails for invalid e-mail" do 24 | user_params = %{ 25 | "password" => "one passphrase", 26 | "email" => "clearlyinvalid.com", 27 | } 28 | 29 | {status, errors} = ValidateUserForRegistration.call(user_params, ConfigsMock) 30 | 31 | assert errors == [email: "has invalid format"] 32 | assert status == :error 33 | end 34 | 35 | test "it fails for invalid e-mail and invalid password" do 36 | user_params = %{ 37 | "password" => "123", 38 | "email" => "clearlyinvalid.com", 39 | } 40 | 41 | {status, errors} = ValidateUserForRegistration.call(user_params, ConfigsMock) 42 | assert errors == [password: "is too short", email: "has invalid format"] 43 | assert status == :error 44 | end 45 | 46 | test "it fails for missing fields" do 47 | user_params = %{} 48 | 49 | {status, errors} = ValidateUserForRegistration.call(user_params, ConfigsMock) 50 | 51 | assert errors == [password: "can't be blank", email: "can't be blank"] 52 | assert status == :error 53 | end 54 | 55 | end 56 | -------------------------------------------------------------------------------- /example_app/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :example_app, 6 | version: "0.0.1", 7 | elixir: "~> 1.0", 8 | elixirc_paths: elixirc_paths(Mix.env), 9 | compilers: [:phoenix, :gettext] ++ Mix.compilers, 10 | build_embedded: Mix.env == :prod, 11 | start_permanent: Mix.env == :prod, 12 | aliases: aliases, 13 | deps: deps] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [mod: {ExampleApp, []}, 21 | applications: [:phoenix, :phoenix_html, :cowboy, :logger, :gettext, 22 | :phoenix_ecto, :postgrex]] 23 | end 24 | 25 | # Specifies which paths to compile per environment. 26 | defp elixirc_paths(:test), do: ["lib", "web", "test/support"] 27 | defp elixirc_paths(_), do: ["lib", "web"] 28 | 29 | # Specifies your project dependencies. 30 | # 31 | # Type `mix help deps` for examples and options. 32 | defp deps do 33 | [{:phoenix, "~> 1.1.4"}, 34 | {:postgrex, ">= 0.0.0"}, 35 | {:phoenix_ecto, "~> 2.0"}, 36 | {:phoenix_html, "~> 2.4"}, 37 | {:phoenix_live_reload, "~> 1.0", only: :dev}, 38 | {:gettext, "~> 0.9"}, 39 | {:cowboy, "~> 1.0"}, 40 | {:addict, path: "../"}] 41 | end 42 | 43 | # Aliases are shortcut or tasks specific to the current project. 44 | # For example, to create, migrate and run the seeds file at once: 45 | # 46 | # $ mix ecto.setup 47 | # 48 | # See the documentation for `Mix` for more info on aliases. 49 | defp aliases do 50 | ["ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 51 | "ecto.reset": ["ecto.drop", "ecto.setup"]] 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /example_app/test/support/model_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.ModelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | model tests. 5 | 6 | You may define functions here to be used as helpers in 7 | your model tests. See `errors_on/2`'s definition as reference. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | alias ExampleApp.Repo 20 | 21 | import Ecto 22 | import Ecto.Changeset 23 | import Ecto.Query, only: [from: 1, from: 2] 24 | import ExampleApp.ModelCase 25 | end 26 | end 27 | 28 | setup tags do 29 | unless tags[:async] do 30 | Ecto.Adapters.SQL.restart_test_transaction(ExampleApp.Repo, []) 31 | end 32 | 33 | :ok 34 | end 35 | 36 | @doc """ 37 | Helper for returning list of errors in model when passed certain data. 38 | 39 | ## Examples 40 | 41 | Given a User model that lists `:name` as a required field and validates 42 | `:password` to be safe, it would return: 43 | 44 | iex> errors_on(%User{}, %{password: "password"}) 45 | [password: "is unsafe", name: "is blank"] 46 | 47 | You could then write your assertion like: 48 | 49 | assert {:password, "is unsafe"} in errors_on(%User{}, %{password: "password"}) 50 | 51 | You can also create the changeset manually and retrieve the errors 52 | field directly: 53 | 54 | iex> changeset = User.changeset(%User{}, password: "password") 55 | iex> {:password, "is unsafe"} in changeset.errors 56 | true 57 | """ 58 | def errors_on(model, data) do 59 | model.__struct__.changeset(model, data).errors 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /example_app/web/web.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.Web do 2 | @moduledoc """ 3 | A module that keeps using definitions for controllers, 4 | views and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use ExampleApp.Web, :controller 9 | use ExampleApp.Web, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. 17 | """ 18 | 19 | def model do 20 | quote do 21 | use Ecto.Schema 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query, only: [from: 1, from: 2] 26 | end 27 | end 28 | 29 | def controller do 30 | quote do 31 | use Phoenix.Controller 32 | 33 | alias ExampleApp.Repo 34 | import Ecto 35 | import Ecto.Query, only: [from: 1, from: 2] 36 | 37 | import ExampleApp.Router.Helpers 38 | import ExampleApp.Gettext 39 | end 40 | end 41 | 42 | def view do 43 | quote do 44 | use Phoenix.View, root: "web/templates" 45 | 46 | # Import convenience functions from controllers 47 | import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1] 48 | 49 | # Use all HTML functionality (forms, tags, etc) 50 | use Phoenix.HTML 51 | 52 | import ExampleApp.Router.Helpers 53 | import ExampleApp.ErrorHelpers 54 | import ExampleApp.Gettext 55 | end 56 | end 57 | 58 | def router do 59 | quote do 60 | use Phoenix.Router 61 | end 62 | end 63 | 64 | def channel do 65 | quote do 66 | use Phoenix.Channel 67 | 68 | alias ExampleApp.Repo 69 | import Ecto 70 | import Ecto.Query, only: [from: 1, from: 2] 71 | import ExampleApp.Gettext 72 | end 73 | end 74 | 75 | @doc """ 76 | When used, dispatch to the appropriate controller/view/etc. 77 | """ 78 | defmacro __using__(which) when is_atom(which) do 79 | apply(__MODULE__, which, []) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/addict/interactors/reset_password.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.Interactors.ResetPassword do 2 | alias Addict.Interactors.{GetUserById, UpdateUserPassword, ValidatePassword} 3 | require Logger 4 | 5 | @doc """ 6 | Executes the password reset flow: parameters validation, password hash generation, user updating. 7 | 8 | Returns `{:ok, user}` or `{:error, [errors]}` 9 | """ 10 | def call(params) do 11 | token = params["token"] 12 | password = params["password"] 13 | signature = params["signature"] 14 | 15 | with {:ok} <- validate_params(token, password, signature), 16 | {:ok, true} <- Addict.Crypto.verify(token, signature), 17 | {:ok, generation_time, user_id} <- parse_token(token), 18 | {:ok} <- validate_generation_time(generation_time), 19 | {:ok, _} <- validate_password(password), 20 | {:ok, user} <- GetUserById.call(user_id), 21 | {:ok, _} <- UpdateUserPassword.call(user, password), 22 | do: {:ok, user} 23 | end 24 | 25 | defp validate_params(token, password, signature) do 26 | if token == nil || password == nil || signature == nil do 27 | Logger.debug("Invalid params for password reset") 28 | Logger.debug("token: #{token}") 29 | Logger.debug("password: #{password}") 30 | Logger.debug("signature: #{signature}") 31 | {:error, [{:params, "Invalid params"}]} 32 | else 33 | {:ok} 34 | end 35 | end 36 | 37 | defp parse_token(token) do 38 | [generation_time, user_id] = Base.decode16!(token) |> String.split(",") 39 | {:ok, String.to_integer(generation_time), String.to_integer(user_id)} 40 | end 41 | 42 | defp validate_generation_time(generation_time) do 43 | do_validate_generation_time(:erlang.system_time(:seconds) - generation_time <= 86_400) 44 | end 45 | 46 | defp do_validate_generation_time(true) do 47 | {:ok} 48 | end 49 | 50 | defp do_validate_generation_time(false) do 51 | {:error, [{:token, "Password reset token not valid."}]} 52 | end 53 | 54 | defp validate_password(password, password_strategies \\ Addict.Configs.password_strategies) do 55 | %Addict.PasswordUser{} 56 | |> Ecto.Changeset.cast(%{password: password}, ~w(password), []) 57 | |> ValidatePassword.call(password_strategies) 58 | end 59 | 60 | end 61 | -------------------------------------------------------------------------------- /example_app/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | use Mix.Config 7 | 8 | # Configures the endpoint 9 | config :example_app, ExampleApp.Endpoint, 10 | url: [host: "localhost"], 11 | root: Path.dirname(__DIR__), 12 | secret_key_base: "yfHQS4p4x6/k7h8XJ4jNC5usq8xSkeJsgiRhv/PNVFW3M/ch2XDuN8U4y1eyGW0O", 13 | render_errors: [accepts: ~w(html json)], 14 | pubsub: [name: ExampleApp.PubSub, 15 | adapter: Phoenix.PubSub.PG2] 16 | 17 | # Configures Elixir's Logger 18 | config :logger, :console, 19 | format: "$time $metadata[$level] $message\n", 20 | metadata: [:request_id] 21 | 22 | # Import environment specific config. This must remain at the bottom 23 | # of this file so it overrides the configuration defined above. 24 | import_config "#{Mix.env}.exs" 25 | 26 | # Configure phoenix generators 27 | config :phoenix, :generators, 28 | migration: true, 29 | binary_id: false 30 | 31 | config :addict, 32 | secret_key: "2432622431322479506177654c79303442354a5a4b784f592e444f332e", 33 | extra_validation: &ExampleApp.User.validate/2, # define extra validation here 34 | user_schema: ExampleApp.User, 35 | repo: ExampleApp.Repo, 36 | from_email: "no-reply@example.com", # CHANGE THIS 37 | mailgun_domain: "CHANGE THIS", 38 | mailgun_key: "CHANGE THIS", 39 | mail_service: :mailgun, 40 | post_register: fn(conn, status, model) -> 41 | IO.inspect status 42 | IO.inspect model 43 | conn 44 | end, 45 | post_login: fn(conn, status, model) -> 46 | IO.inspect status 47 | IO.inspect model 48 | conn 49 | end, 50 | post_logout: fn(conn, status, model) -> 51 | IO.inspect status 52 | IO.inspect model 53 | conn 54 | end, 55 | post_reset_password: fn(conn, status, model) -> 56 | IO.inspect status 57 | IO.inspect model 58 | conn 59 | end, 60 | post_recover_password: fn(conn, status, model) -> 61 | IO.inspect status 62 | IO.inspect model 63 | conn 64 | end 65 | -------------------------------------------------------------------------------- /example_app/config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For production, we configure the host to read the PORT 4 | # from the system environment. Therefore, you will need 5 | # to set PORT=80 before running your server. 6 | # 7 | # You should also configure the url host to something 8 | # meaningful, we use this information when generating URLs. 9 | # 10 | # Finally, we also include the path to a manifest 11 | # containing the digested version of static files. This 12 | # manifest is generated by the mix phoenix.digest task 13 | # which you typically run after static files are built. 14 | config :example_app, ExampleApp.Endpoint, 15 | http: [port: {:system, "PORT"}], 16 | url: [host: "example.com", port: 80], 17 | cache_static_manifest: "priv/static/manifest.json" 18 | 19 | # Do not print debug messages in production 20 | config :logger, level: :info 21 | 22 | # ## SSL Support 23 | # 24 | # To get SSL working, you will need to add the `https` key 25 | # to the previous section and set your `:url` port to 443: 26 | # 27 | # config :example_app, ExampleApp.Endpoint, 28 | # ... 29 | # url: [host: "example.com", port: 443], 30 | # https: [port: 443, 31 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 32 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")] 33 | # 34 | # Where those two env variables return an absolute path to 35 | # the key and cert in disk or a relative path inside priv, 36 | # for example "priv/ssl/server.key". 37 | # 38 | # We also recommend setting `force_ssl`, ensuring no data is 39 | # ever sent via http, always redirecting to https: 40 | # 41 | # config :example_app, ExampleApp.Endpoint, 42 | # force_ssl: [hsts: true] 43 | # 44 | # Check `Plug.SSL` for all available options in `force_ssl`. 45 | 46 | # ## Using releases 47 | # 48 | # If you are doing OTP releases, you need to instruct Phoenix 49 | # to start the server for all endpoints: 50 | # 51 | # config :phoenix, :serve_endpoints, true 52 | # 53 | # Alternatively, you can configure exactly which server to 54 | # start per endpoint: 55 | # 56 | # config :example_app, ExampleApp.Endpoint, server: true 57 | # 58 | # You will also need to set the application root to `.` in order 59 | # for the new static assets to be served after a hot upgrade: 60 | # 61 | # config :example_app, ExampleApp.Endpoint, root: "." 62 | 63 | # Finally import the config/prod.secret.exs 64 | # which should be versioned separately. 65 | import_config "prod.secret.exs" 66 | -------------------------------------------------------------------------------- /example_app/test/controllers/user_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.UserControllerTest do 2 | use ExampleApp.ConnCase 3 | 4 | alias ExampleApp.User 5 | @valid_attrs %{email: "some content", encrypted_password: "some content", name: "some content"} 6 | @invalid_attrs %{} 7 | 8 | test "lists all entries on index", %{conn: conn} do 9 | conn = get conn, user_management_path(conn, :index) 10 | assert html_response(conn, 200) =~ "Listing users" 11 | end 12 | 13 | test "renders form for new resources", %{conn: conn} do 14 | conn = get conn, user_management_path(conn, :new) 15 | assert html_response(conn, 200) =~ "New user" 16 | end 17 | 18 | test "creates resource and redirects when data is valid", %{conn: conn} do 19 | conn = post conn, user_management_path(conn, :create), user: @valid_attrs 20 | assert redirected_to(conn) == user_management_path(conn, :index) 21 | assert Repo.get_by(User, @valid_attrs) 22 | end 23 | 24 | test "does not create resource and renders errors when data is invalid", %{conn: conn} do 25 | conn = post conn, user_management_path(conn, :create), user: @invalid_attrs 26 | assert html_response(conn, 200) =~ "New user" 27 | end 28 | 29 | test "shows chosen resource", %{conn: conn} do 30 | user = Repo.insert! %User{} 31 | conn = get conn, user_management_path(conn, :show, user) 32 | assert html_response(conn, 200) =~ "Show user" 33 | end 34 | 35 | test "renders page not found when id is nonexistent", %{conn: conn} do 36 | assert_error_sent 404, fn -> 37 | get conn, user_management_path(conn, :show, -1) 38 | end 39 | end 40 | 41 | test "renders form for editing chosen resource", %{conn: conn} do 42 | user = Repo.insert! %User{} 43 | conn = get conn, user_management_path(conn, :edit, user) 44 | assert html_response(conn, 200) =~ "Edit user" 45 | end 46 | 47 | test "updates chosen resource and redirects when data is valid", %{conn: conn} do 48 | user = Repo.insert! %User{} 49 | conn = put conn, user_management_path(conn, :update, user), user: @valid_attrs 50 | assert redirected_to(conn) == user_management_path(conn, :show, user) 51 | assert Repo.get_by(User, @valid_attrs) 52 | end 53 | 54 | test "does not update chosen resource and renders errors when data is invalid", %{conn: conn} do 55 | user = Repo.insert! %User{} 56 | conn = put conn, user_management_path(conn, :update, user), user: @invalid_attrs 57 | assert html_response(conn, 200) =~ "Edit user" 58 | end 59 | 60 | test "deletes chosen resource", %{conn: conn} do 61 | user = Repo.insert! %User{} 62 | conn = delete conn, user_management_path(conn, :delete, user) 63 | assert redirected_to(conn) == user_management_path(conn, :index) 64 | refute Repo.get(User, user.id) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /example_app/priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This file is a PO Template file. `msgid`s here are often extracted from 2 | ## source code; add new translations manually only if they're dynamic 3 | ## translations that can't be statically extracted. Run `mix 4 | ## gettext.extract` to bring this file up to date. Leave `msgstr`s empty as 5 | ## changing them here as no effect; edit them in PO (`.po`) files instead. 6 | 7 | ## From Ecto.Changeset.cast/4 8 | msgid "can't be blank" 9 | msgstr "" 10 | 11 | ## From Ecto.Changeset.unique_constraint/3 12 | msgid "has already been taken" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.put_change/3 16 | msgid "is invalid" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.validate_format/3 20 | msgid "has invalid format" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_subset/3 24 | msgid "has an invalid entry" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_exclusion/3 28 | msgid "is reserved" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_confirmation/3 32 | msgid "does not match confirmation" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.no_assoc_constraint/3 36 | msgid "is still associated to this entry" 37 | msgstr "" 38 | 39 | msgid "are still associated to this entry" 40 | msgstr "" 41 | 42 | ## From Ecto.Changeset.validate_length/3 43 | msgid "should be %{count} character(s)" 44 | msgid_plural "should be %{count} character(s)" 45 | msgstr[0] "" 46 | msgstr[1] "" 47 | 48 | msgid "should have %{count} item(s)" 49 | msgid_plural "should have %{count} item(s)" 50 | msgstr[0] "" 51 | msgstr[1] "" 52 | 53 | msgid "should be at least %{count} character(s)" 54 | msgid_plural "should be at least %{count} character(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should have at least %{count} item(s)" 59 | msgid_plural "should have at least %{count} item(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be at most %{count} character(s)" 64 | msgid_plural "should be at most %{count} character(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at most %{count} item(s)" 69 | msgid_plural "should have at most %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | ## From Ecto.Changeset.validate_number/3 74 | msgid "must be less than %{count}" 75 | msgid_plural "must be less than %{count}" 76 | msgstr[0] "" 77 | msgstr[1] "" 78 | 79 | msgid "must be greater than %{count}" 80 | msgid_plural "must be greater than %{count}" 81 | msgstr[0] "" 82 | msgstr[1] "" 83 | 84 | msgid "must be less than or equal to %{count}" 85 | msgid_plural "must be less than or equal to %{count}" 86 | msgstr[0] "" 87 | msgstr[1] "" 88 | 89 | msgid "must be greater than or equal to %{count}" 90 | msgid_plural "must be greater than or equal to %{count}" 91 | msgstr[0] "" 92 | msgstr[1] "" 93 | 94 | msgid "must be equal to %{count}" 95 | msgid_plural "must be equal to %{count}" 96 | msgstr[0] "" 97 | msgstr[1] "" 98 | -------------------------------------------------------------------------------- /example_app/priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. Do not add, change, or 2 | ## remove `msgid`s manually here as they're tied to the ones in the 3 | ## corresponding POT file (with the same domain). Use `mix gettext.extract 4 | ## --merge` or `mix gettext.merge` to merge POT files into PO files. 5 | msgid "" 6 | msgstr "" 7 | "Language: en\n" 8 | 9 | ## From Ecto.Changeset.cast/4 10 | msgid "can't be blank" 11 | msgstr "" 12 | 13 | ## From Ecto.Changeset.unique_constraint/3 14 | msgid "has already been taken" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.put_change/3 18 | msgid "is invalid" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.validate_format/3 22 | msgid "has invalid format" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_subset/3 26 | msgid "has an invalid entry" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_exclusion/3 30 | msgid "is reserved" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_confirmation/3 34 | msgid "does not match confirmation" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.no_assoc_constraint/3 38 | msgid "is still associated to this entry" 39 | msgstr "" 40 | 41 | msgid "are still associated to this entry" 42 | msgstr "" 43 | 44 | ## From Ecto.Changeset.validate_length/3 45 | msgid "should be %{count} character(s)" 46 | msgid_plural "should be %{count} character(s)" 47 | msgstr[0] "" 48 | msgstr[1] "" 49 | 50 | msgid "should have %{count} item(s)" 51 | msgid_plural "should have %{count} item(s)" 52 | msgstr[0] "" 53 | msgstr[1] "" 54 | 55 | msgid "should be at least %{count} character(s)" 56 | msgid_plural "should be at least %{count} character(s)" 57 | msgstr[0] "" 58 | msgstr[1] "" 59 | 60 | msgid "should have at least %{count} item(s)" 61 | msgid_plural "should have at least %{count} item(s)" 62 | msgstr[0] "" 63 | msgstr[1] "" 64 | 65 | msgid "should be at most %{count} character(s)" 66 | msgid_plural "should be at most %{count} character(s)" 67 | msgstr[0] "" 68 | msgstr[1] "" 69 | 70 | msgid "should have at most %{count} item(s)" 71 | msgid_plural "should have at most %{count} item(s)" 72 | msgstr[0] "" 73 | msgstr[1] "" 74 | 75 | ## From Ecto.Changeset.validate_number/3 76 | msgid "must be less than %{count}" 77 | msgid_plural "must be less than %{count}" 78 | msgstr[0] "" 79 | msgstr[1] "" 80 | 81 | msgid "must be greater than %{count}" 82 | msgid_plural "must be greater than %{count}" 83 | msgstr[0] "" 84 | msgstr[1] "" 85 | 86 | msgid "must be less than or equal to %{count}" 87 | msgid_plural "must be less than or equal to %{count}" 88 | msgstr[0] "" 89 | msgstr[1] "" 90 | 91 | msgid "must be greater than or equal to %{count}" 92 | msgid_plural "must be greater than or equal to %{count}" 93 | msgstr[0] "" 94 | msgstr[1] "" 95 | 96 | msgid "must be equal to %{count}" 97 | msgid_plural "must be equal to %{count}" 98 | msgstr[0] "" 99 | msgstr[1] "" 100 | -------------------------------------------------------------------------------- /lib/addict/mix/generate_boilerplate.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Addict.Generate.Boilerplate do 2 | use Mix.Task 3 | import Mix.Generator 4 | embed_text :login, from_file: "./boilerplate/login.html.eex" 5 | embed_text :template, from_file: "./boilerplate/addict.html.eex" 6 | embed_text :register, from_file: "./boilerplate/register.html.eex" 7 | embed_text :reset_password, from_file: "./boilerplate/reset_password.html.eex" 8 | embed_text :recover_password, from_file: "./boilerplate/recover_password.html.eex" 9 | embed_template :view, """ 10 | defmodule Addict.AddictView do 11 | use Phoenix.HTML 12 | use Phoenix.View, root: "web/templates/" 13 | import Phoenix.Controller, only: [view_module: 1] 14 | import <%= @base_route_helper %> 15 | end 16 | """ 17 | 18 | def run(_) do 19 | configs_path = "./config/config.exs" 20 | 21 | if !addict_config_already_exists?(configs_path) do 22 | Mix.shell.error "[x] Please make sure your Addict configuration exists first. Generate it via mix:" 23 | Mix.shell.error "[x] mix addict.generate.configs" 24 | else 25 | base_module = guess_application_name 26 | Mix.shell.info "[o] Generating Addict boilerplate" 27 | 28 | create_addict_templates 29 | create_addict_view 30 | end 31 | 32 | Mix.shell.info "[o] Done!" 33 | end 34 | 35 | defp create_addict_templates do 36 | create_file Path.join(["web", "templates", "addict", "addict.html.eex"]) 37 | |> Path.relative_to(Mix.Project.app_path), 38 | template_text 39 | create_file Path.join(["web", "templates", "addict", "login.html.eex"]) 40 | |> Path.relative_to(Mix.Project.app_path), 41 | login_text 42 | create_file Path.join(["web", "templates", "addict", "register.html.eex"]) 43 | |> Path.relative_to(Mix.Project.app_path), 44 | register_text 45 | create_file Path.join(["web", "templates", "addict", "recover_password.html.eex"]) 46 | |> Path.relative_to(Mix.Project.app_path), 47 | recover_password_text 48 | create_file Path.join(["web", "templates", "addict", "reset_password.html.eex"]) 49 | |> Path.relative_to(Mix.Project.app_path), 50 | reset_password_text 51 | end 52 | 53 | defp create_addict_view do 54 | view_file = Path.join(["web", "views", "addict_view.ex"]) 55 | |> Path.relative_to(Mix.Project.app_path) 56 | create_file view_file, view_template(base_route_helper: (guess_application_name <> ".Router.Helpers")) 57 | end 58 | 59 | defp guess_application_name do 60 | Mix.Project.config()[:app] |> Atom.to_string |> Mix.Utils.camelize 61 | end 62 | 63 | defp addict_config_already_exists?(configs_path) do 64 | {data} = with {:ok, file} <- File.open(configs_path, [:read, :write, :utf8]), 65 | data <- IO.read(file, :all), 66 | :ok <- File.close(file), 67 | do: {data} 68 | 69 | String.contains?(data, "config :addict") 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/acceptance/controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ControllerTest do 2 | use ExUnit.Case, async: false 3 | use Addict.RepoSetup 4 | use Plug.Test 5 | import Addict.SessionSetup, only: [with_session: 1] 6 | 7 | @opts TestAddictRouter.init([]) 8 | 9 | test "it creates a user" do 10 | Application.put_env(:addict, :user_schema, TestAddictSchema) 11 | Application.put_env(:addict, :repo, TestAddictRepo) 12 | 13 | request_params = %{ 14 | "name" => "John Doe", 15 | "email" => "john.doe@example.com", 16 | "password" => "my password" 17 | } 18 | 19 | conn = with_session conn(:post, "/register", request_params) 20 | conn = TestAddictRouter.call(conn, @opts) 21 | 22 | user = conn |> Plug.Conn.get_session(:current_user) 23 | assert conn.status == 201 24 | assert user[:email] == "john.doe@example.com" 25 | end 26 | 27 | test "it logs in a user" do 28 | Application.put_env(:addict, :user_schema, TestAddictSchema) 29 | Application.put_env(:addict, :repo, TestAddictRepo) 30 | 31 | request_params = %{ 32 | "email" => "john.doe@example.com", 33 | "password" => "my passphrase" 34 | } 35 | 36 | Addict.Interactors.Register.call(request_params) 37 | 38 | conn = conn(:post, "/login", request_params) 39 | |> with_session 40 | |> TestAddictRouter.call(@opts) 41 | 42 | user = conn |> Plug.Conn.get_session(:current_user) 43 | 44 | assert conn.status == 200 45 | assert user[:email] == "john.doe@example.com" 46 | 47 | end 48 | 49 | test "it resets a password" do 50 | Application.put_env(:addict, :user_schema, TestAddictSchema) 51 | Application.put_env(:addict, :repo, TestAddictRepo) 52 | Application.put_env(:addict, :secret_key, "T01HLTEzMzctczNjcjM3NQ==") 53 | 54 | register_params = %{ 55 | "email" => "john.doe@example.com", 56 | "password" => "my passphrase" 57 | } 58 | 59 | {:ok, user} = Addict.Interactors.Register.call(register_params) 60 | original_encrypted_password = user.encrypted_password 61 | 62 | {:ok, reset_path} = Addict.Interactors.GeneratePasswordResetLink.call(user.id) 63 | 64 | [token, signature] = reset_path |> String.split("?") |> Enum.at(1) |> String.split("&") 65 | token = token |> String.split("=") |> Enum.at(1) 66 | signature = signature |> String.split("=") |> Enum.at(1) 67 | 68 | reset_params = %{ 69 | "token" => token, 70 | "signature" => signature, 71 | "password" => "new password" 72 | } 73 | 74 | conn(:post, "/reset_password", reset_params) 75 | |> TestAddictRouter.call(@opts) 76 | 77 | {:ok, user} = Addict.Interactors.GetUserByEmail.call(user.email) 78 | assert original_encrypted_password != user.encrypted_password 79 | 80 | end 81 | 82 | test "it logs out a user" do 83 | Application.put_env(:addict, :user_schema, TestAddictSchema) 84 | Application.put_env(:addict, :repo, TestAddictRepo) 85 | 86 | conn = conn(:delete, "/logout", nil) 87 | |> with_session 88 | |> Plug.Conn.put_session(:current_user, %{email: "john.doe@example.com"}) 89 | |> TestAddictRouter.call(@opts) 90 | 91 | user = conn |> Plug.Conn.get_session(:current_user) 92 | 93 | assert conn.status == 200 94 | assert user == nil 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/addict/mix/generate_configs.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Addict.Generate.Configs do 2 | use Mix.Task 3 | 4 | def run(_) do 5 | configs_path = "./config/config.exs" 6 | 7 | if addict_config_already_exists?(configs_path) do 8 | Mix.shell.error "[x] Please remove the existing Addict configuration before generating a new one" 9 | else 10 | base_module = guess_application_name 11 | user_schema = "#{base_module}.User" 12 | repo = "#{base_module}.Repo" 13 | mailgun_domain = "" 14 | mailgun_api_key = "" 15 | 16 | Mix.shell.info "[o] Generating Addict configuration" 17 | 18 | guessed = Mix.shell.yes? "Is your application root module #{base_module}?" 19 | unless guessed do 20 | base_module = Mix.shell.prompt("Please insert your application root module:") |> String.rstrip 21 | end 22 | 23 | guessed = Mix.shell.yes? "Is your Ecto Repository module #{repo}?" 24 | unless guessed do 25 | repo = Mix.shell.prompt("Please insert your Ecto Repository module:") |> String.rstrip 26 | end 27 | 28 | guessed = Mix.shell.yes? "Is your User Schema module #{user_schema}?" 29 | unless guessed do 30 | user_schema = Mix.shell.prompt("Please insert your User Schema module:") |> String.rstrip 31 | end 32 | 33 | use_mailgun = Mix.shell.yes? "Will you be using Mailgun?" 34 | if use_mailgun do 35 | mailgun_domain = Mix.shell.prompt("Please insert your Mailgun domain: (e.g.: https://api.mailgun.net/v3/sandbox123456.mailgun.org)") |> String.rstrip 36 | mailgun_api_key = Mix.shell.prompt("Please insert your Mailgun API key:") |> String.rstrip 37 | end 38 | 39 | add_addict_configs(configs_path, user_schema, repo, use_mailgun, mailgun_domain, mailgun_api_key) 40 | end 41 | 42 | Mix.shell.info "[o] Done!" 43 | end 44 | 45 | defp guess_application_name do 46 | Mix.Project.config()[:app] |> Atom.to_string |> Mix.Utils.camelize 47 | end 48 | 49 | defp addict_config_already_exists?(configs_path) do 50 | {data} = with {:ok, file} <- File.open(configs_path, [:read, :write, :utf8]), 51 | data <- IO.read(file, :all), 52 | :ok <- File.close(file), 53 | do: {data} 54 | 55 | Regex.match? ~r/config :addict\s*?,/, data 56 | end 57 | 58 | defp add_addict_configs(configs_path, user_schema, repo, use_mailgun, mailgun_domain, mailgun_api_key) do 59 | {:ok, file} = File.open(configs_path, [:read, :write, :utf8]) 60 | IO.read(file, :all) 61 | secret_key = Comeonin.Bcrypt.gen_salt |> Base.encode16 |> String.downcase 62 | 63 | default_configs = """ 64 | 65 | config :addict, 66 | secret_key: "#{secret_key}", 67 | extra_validation: fn ({valid, errors}, user_params) -> {valid, errors} end, # define extra validation here 68 | user_schema: #{user_schema}, 69 | repo: #{repo}, 70 | from_email: "no-reply@example.com", # CHANGE THIS 71 | """ 72 | 73 | if use_mailgun do 74 | default_configs = default_configs <> """ 75 | mailgun_domain: "#{mailgun_domain}", 76 | mailgun_key: "#{mailgun_api_key}", 77 | mail_service: :mailgun 78 | """ 79 | else 80 | default_configs = default_configs <> "mail_service: nil" 81 | end 82 | 83 | IO.write(file, default_configs) 84 | :ok = File.close(file) 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/manager_interactor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Addict.ManagerInteractorTest do 2 | use ExUnit.Case, async: true 3 | # alias Addict.ManagerInteractor, as: Interactor 4 | 5 | # test "creates a user" do 6 | # user_params = %{"email" => "test@example.com", "password" => "password", "username" => "test"} 7 | # assert Interactor.create(user_params, RepoStub, MailerStub, PasswordInteractorStub) == {:ok, %{email: "test@example.com"}} 8 | # end 9 | # 10 | # test "validates for invalid params" do 11 | # user_params = %{} 12 | # assert catch_throw(Interactor.create(user_params, RepoStub, MailerStub)) == "Unable to create user, invalid hash. Required params: email, password, username" 13 | # end 14 | # 15 | # test "validates for nil params" do 16 | # assert catch_throw(Interactor.create(nil, RepoStub, MailerStub)) == "Unable to create user, invalid hash: nil" 17 | # end 18 | # 19 | # test "allows for password to be recovered" do 20 | # email = "test@example.com" 21 | # assert Interactor.recover_password(email, RepoStub, MailerStub) == {:ok, %{email: "test@example.com"}} 22 | # end 23 | # 24 | # test "handles invalid password recovery requests" do 25 | # email = "test2@example.com" 26 | # assert Interactor.recover_password(email, RepoNoMailStub, MailerStub) == {:error, "Unable to send recovery e-mail"} 27 | # end 28 | # 29 | # test "resets password" do 30 | # assert Interactor.reset_password("token123", "valid_password", "valid_password", RepoStub, PasswordInteractorStub) == {:ok, %{email: "test@example.com"}} 31 | # end 32 | # 33 | # test "handles reset password with nilled token" do 34 | # assert Interactor.reset_password(nil, "password", "password", RepoStub) == {:error, "invalid recovery hash"} 35 | # end 36 | # 37 | # test "handles reset password with invalid token" do 38 | # assert Interactor.reset_password("invalidtoken", "password", "password", RepoStub) == {:error, "invalid recovery hash"} 39 | # end 40 | # 41 | # test "handles reset password with invalid password confirmation" do 42 | # assert Interactor.reset_password("invalidtoken", "password", "password_invalid") == {:error, "passwords must match"} 43 | # end 44 | # end 45 | # 46 | # defmodule PasswordInteractorStub do 47 | # def generate_hash(_) do 48 | # "1337h4$h" 49 | # end 50 | # end 51 | # 52 | # defmodule RepoStub do 53 | # def create(_) do 54 | # {:ok, %{email: "test@example.com"}} 55 | # end 56 | # 57 | # def add_recovery_hash(_,_) do 58 | # {:ok, %{email: "test@example.com"}} 59 | # end 60 | # 61 | # def find_by_email(_) do 62 | # {:ok, %{email: "test@example.com"}} 63 | # end 64 | # 65 | # def change_password(_,_) do 66 | # {:ok, %{email: "test@example.com"}} 67 | # end 68 | # 69 | # def find_by_recovery_hash("token123") do 70 | # {:ok, %{email: "test@example.com"}} 71 | # end 72 | # 73 | # def find_by_recovery_hash("invalidtoken") do 74 | # nil 75 | # end 76 | # 77 | # def find_by_recovery_hash(nil) do 78 | # nil 79 | # end 80 | end 81 | 82 | defmodule RepoNoMailStub do 83 | def find_by_email(_) do 84 | nil 85 | end 86 | 87 | def add_recovery_hash(nil,_) do 88 | {:error, "invalid user"} 89 | end 90 | end 91 | 92 | defmodule MailerStub do 93 | def send_welcome_email(_) do 94 | {:ok, %{email: "test@example.com"}} 95 | end 96 | def send_password_recovery_email(_) do 97 | {:ok, %{email: "test@example.com"}} 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /configs.md: -------------------------------------------------------------------------------- 1 | # Addict Configs 2 | 3 | Addict allows you to extend behaviour by fine tuning some configurations. 4 | 5 | # Post action hooks 6 | 7 | After you register, login, reset password or recover password, you might want to trigger some kind of logic in your application. 8 | 9 | Here's an example on how you'd print some debug information after you login a user. The same logic applies to the other endpoints. 10 | 11 | The only thing you have to do, is to obey the signature in your custom functions: 12 | 13 | ```elixir 14 | defmodule MyApp.PostLoginAction do 15 | def log(conn, status, model) do 16 | IO.inspect status 17 | IO.inspect model 18 | conn 19 | end 20 | end 21 | ``` 22 | 23 | And add it to the configuration: 24 | 25 | ```elixir 26 | # config.exs 27 | 28 | config :addict, 29 | (...), 30 | post_login: &MyApp.PostLoginAction.log/3 31 | ``` 32 | 33 | If you want to take different flows according to the success criteria of the action, you can pattern match the arguments: 34 | 35 | ```elixir 36 | defmodule MyApp.PostLoginAction do 37 | def log(conn, :ok, model) do 38 | IO.puts "User logged in successfully" 39 | conn 40 | end 41 | 42 | def log(conn, :error, errors) do 43 | IO.puts "User wasn't able to log in due to:" 44 | IO.inspect errors 45 | conn 46 | end 47 | end 48 | ``` 49 | 50 | These configurations are exposed as: 51 | ``` 52 | post_login 53 | post_logout 54 | post_register 55 | post_reset_password 56 | post_recover_password 57 | ``` 58 | 59 | # E-mail configurations 60 | 61 | When sending e-mails, you most likely want to personalize the way the e-mail is presented to the user. 62 | 63 | ## From E-mail 64 | 65 | Set your `from_email` configuration to whatever e-mail makes sense to you. This is usually a `"no-reply@yourdomain.com"`. 66 | 67 | ## E-mail Subject 68 | 69 | Set the subject for your registration e-mails via `email_register_subject` and for your reset password e-mails via `email_reset_password_subject`. 70 | 71 | ## E-mail Templates 72 | 73 | Addict uses EEx templates to generate the e-mail body. These are set via `email_register_template` and `email_reset_password_template`.Here's an example on how you could do it: 74 | 75 | ```elixir 76 | defmodule MyApp.EmailTemplates do 77 | def register do 78 | """ 79 |

This is a registration e-mail.

80 |

You can access your model params as you'd do in a normal EEx template

81 |

For example, to render the e-mail you'd do <%= email %>.

82 |

If you have a name on your model, you can also display it: <%= name %>.

83 | """ 84 | end 85 | end 86 | ``` 87 | 88 | Then on your configuration file: 89 | 90 | ```elixir 91 | # config.exs 92 | 93 | config :addict, 94 | (...), 95 | email_register_template: MyApp.EmailTemplates.register 96 | ``` 97 | 98 | The same logic applies for the `email_reset_password_template`. Just take into consideration that the only available user field will be `email`. 99 | 100 | # User model validation 101 | 102 | Addict by default validates that the password is at least 6 characters long and the e-mail is valid and unique. If you need to add extra validation, you can define your function validator via `extra_validation`. 103 | 104 | Here's an example (pay attention to the function signature): 105 | 106 | ```elixir 107 | defmodule MyApp.User do 108 | (...) 109 | def validate({:ok, _}, user_params) do 110 | if user_params["name"] == "Murdoch" do 111 | {:error, [name: "Invalid name. I have this thing against Murdoch."]} 112 | else 113 | {:ok, []} 114 | end 115 | end 116 | 117 | def validate({:error, errors}, user_params) do 118 | IO.puts "I could do something fancy here. But I won't." 119 | {:error, errors} 120 | end 121 | end 122 | ``` 123 | 124 | And in your configuration file: 125 | 126 | ```elixir 127 | # config.exs 128 | 129 | config :addict, 130 | (...), 131 | extra_validation: &MyApp.User.validate/2 132 | ``` 133 | 134 | # CSRF Token 135 | 136 | If you're using CSRF token generation, use the `generate_csrf_token` configuration value to pass the function responsible for it. 137 | 138 | # Not Logged in Redirect 139 | 140 | When using the `Addict.Plugs.Authenticated`, if the user isn't logged in, it will be redirected to `"/login"`. You may change the path by setting the url or path in `not_logged_in_url`: 141 | 142 | ```elixir 143 | # config.exs 144 | 145 | config :addict, 146 | (...), 147 | not_logged_in_url: "/some/other/path" 148 | ``` 149 | -------------------------------------------------------------------------------- /lib/addict/controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Addict.AddictController do 2 | @moduledoc """ 3 | Controller for addict 4 | 5 | Responsible for handling requests for serving templates (GETs) and managing users (POSTs) 6 | """ 7 | use Phoenix.Controller 8 | 9 | @doc """ 10 | Registers a user. Invokes `Addict.Configs.post_register/3` afterwards. 11 | 12 | Requires to have at least `"email"` and "`password`" on `user_params` 13 | """ 14 | def register(%{method: "POST"} = conn, user_params) do 15 | user_params = parse(user_params) 16 | result = with {:ok, user} <- Addict.Interactors.Register.call(user_params), 17 | {:ok, conn} <- Addict.Interactors.CreateSession.call(conn, user), 18 | do: {:ok, conn, user} 19 | 20 | case result do 21 | {:ok, conn, user} -> return_success(conn, user, Addict.Configs.post_register, 201) 22 | {:error, errors} -> return_error(conn, errors, Addict.Configs.post_register) 23 | end 24 | end 25 | 26 | @doc """ 27 | Renders registration layout 28 | """ 29 | def register(%{method: "GET"} = conn, _) do 30 | csrf_token = generate_csrf_token 31 | conn 32 | |> put_addict_layout 33 | |> render("register.html", csrf_token: csrf_token) 34 | end 35 | 36 | @doc """ 37 | Logs in a user. Invokes `Addict.Configs.post_login/3` afterwards. 38 | 39 | Requires to have at least `"email"` and "`password`" on `auth_params` 40 | """ 41 | def login(%{method: "POST"} = conn, auth_params) do 42 | auth_params = parse(auth_params) 43 | result = with {:ok, user} <- Addict.Interactors.Login.call(auth_params), 44 | {:ok, conn} <- Addict.Interactors.CreateSession.call(conn, user), 45 | do: {:ok, conn, user} 46 | 47 | case result do 48 | {:ok, conn, user} -> return_success(conn, user, Addict.Configs.post_login) 49 | {:error, errors} -> return_error(conn, errors, Addict.Configs.post_login) 50 | end 51 | end 52 | 53 | @doc """ 54 | Renders login layout 55 | """ 56 | def login(%{method: "GET"} = conn, _) do 57 | csrf_token = generate_csrf_token 58 | conn 59 | |> put_addict_layout 60 | |> render("login.html", csrf_token: csrf_token) 61 | end 62 | 63 | @doc """ 64 | Logs out the user. Invokes `Addict.Configs.post_logout/3` afterwards. 65 | 66 | No required params, it removes the session of the logged in user. 67 | """ 68 | def logout(%{method: "DELETE"} = conn, _) do 69 | case Addict.Interactors.DestroySession.call(conn) do 70 | {:ok, conn} -> return_success(conn, %{}, Addict.Configs.post_logout) 71 | {:error, errors} -> return_error(conn, errors, Addict.Configs.post_logout) 72 | end 73 | end 74 | 75 | @doc """ 76 | Recover user password. Sends an e-mail with a reset password link. Invokes `Addict.Configs.post_recover_password/3` afterwards. 77 | 78 | Requires to have `"email"` on `user_params` 79 | """ 80 | def recover_password(%{method: "POST"} = conn, user_params) do 81 | user_params = parse(user_params) 82 | email = user_params["email"] 83 | case Addict.Interactors.SendResetPasswordEmail.call(email) do 84 | {:ok, _} -> return_success(conn, %{}, Addict.Configs.post_recover_password) 85 | {:error, errors} -> return_error(conn, errors, Addict.Configs.post_recover_password) 86 | end 87 | end 88 | 89 | @doc """ 90 | Renders Password Recovery layout 91 | """ 92 | def recover_password(%{method: "GET"} = conn, _) do 93 | csrf_token = generate_csrf_token 94 | conn 95 | |> put_addict_layout 96 | |> render("recover_password.html", csrf_token: csrf_token) 97 | end 98 | 99 | @doc """ 100 | Resets the user password. Invokes `Addict.Configs.post_reset_password/3` afterwards. 101 | 102 | Requires to have `"token"`, `"signature"` and "`password`" on `params` 103 | """ 104 | def reset_password(%{method: "POST"} = conn, params) do 105 | params = parse(params) 106 | case Addict.Interactors.ResetPassword.call(params) do 107 | {:ok, _} -> return_success(conn, %{}, Addict.Configs.post_reset_password) 108 | {:error, errors} -> return_error(conn, errors, Addict.Configs.post_reset_password) 109 | end 110 | end 111 | 112 | @doc """ 113 | Renders Password Reset layout 114 | """ 115 | def reset_password(%{method: "GET"} = conn, params) do 116 | csrf_token = generate_csrf_token 117 | token = params["token"] 118 | signature = params["signature"] 119 | conn 120 | |> put_addict_layout 121 | |> render("reset_password.html", token: token, signature: signature, csrf_token: csrf_token) 122 | end 123 | 124 | defp return_success(conn, user, custom_fn, status \\ 200) do 125 | if custom_fn == nil, do: custom_fn = fn(a,_,_) -> a end 126 | 127 | conn 128 | |> put_status(status) 129 | |> custom_fn.(:ok, user) 130 | |> json(Addict.Presenter.strip_all(user)) 131 | end 132 | 133 | defp return_error(conn, errors, custom_fn) do 134 | if custom_fn == nil, do: custom_fn = fn (a,_,_) -> a end 135 | errors = errors |> Enum.map(fn {key, value} -> 136 | %{message: "#{Macro.camelize(Atom.to_string(key))}: #{value}"} 137 | end) 138 | conn 139 | |> custom_fn.(:error, errors) 140 | |> put_status(400) 141 | |> json(%{errors: errors}) 142 | end 143 | 144 | defp put_addict_layout(conn) do 145 | conn 146 | |> put_layout({Addict.AddictView, "addict.html"}) 147 | end 148 | 149 | defp generate_csrf_token do 150 | if Addict.Configs.generate_csrf_token != nil do 151 | Addict.Configs.generate_csrf_token.() 152 | else 153 | "" 154 | end 155 | end 156 | 157 | defp parse(user_params) do 158 | if user_params[schema_name_string] != nil do 159 | user_params[schema_name_string] 160 | else 161 | user_params 162 | end 163 | end 164 | 165 | defp schema_name_string do 166 | to_string(Addict.Configs.user_schema) 167 | |> String.split(".") 168 | |> Enum.at(-1) 169 | |> String.downcase 170 | end 171 | 172 | end 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/trenpixster/addict.svg)](https://travis-ci.org/trenpixster/addict) [![Hex.pm](http://img.shields.io/hexpm/v/addict.svg)](https://hex.pm/packages/addict) [![Hex.pm](http://img.shields.io/hexpm/dt/addict.svg)](https://hex.pm/packages/addict) 2 | [![Deps Status](https://beta.hexfaktor.org/badge/all/github/trenpixster/addict.svg)](https://beta.hexfaktor.org/github/trenpixster/addict) 3 | [![Inline docs](http://inch-ci.org/github/trenpixster/addict.svg)](http://inch-ci.org/github/trenpixster/addict) 4 | 5 | # Addict 6 | 7 | Addict allows you to manage users registration and authentication on your [Phoenix Framework](http://www.phoenixframework.org) app easily. 8 | 9 | ## What does it do? 10 | For now, it enables your users to register, login, logout and recover/reset their passwords. 11 | 12 | ## Requirements 13 | 14 | Addict is dependent on an ecto [User Model](https://github.com/elixir-lang/ecto/blob/master/examples/simple/lib/simple.ex#L18) and a [Database connection interface](https://github.com/elixir-lang/ecto/blob/master/examples/simple/lib/simple.ex#L12). 15 | 16 | The user model must have at least the following schema: 17 | ```elixir 18 | field :email, :string 19 | field :encrypted_password, :string 20 | ``` 21 | 22 | ## Plug and go 23 | 1 - Add Addict to your dependencies: 24 | ``` 25 | {:addict, "~> 0.2"} 26 | ``` 27 | 2 - Generate Addict [configs](https://github.com/trenpixster/addict/blob/master/configs.md) via: 28 | ``` 29 | mix addict.generate.configs 30 | ``` 31 | 3 - Generate ([opinionated](#gen_boilerplate)) boilerplate: 32 | ``` 33 | mix addict.generate.boilerplate 34 | ``` 35 | 4 - Add Addict routes to your `router.ex`: 36 | ```elixir 37 | defmodule YourApp.Router do 38 | (...) 39 | use Addict.RoutesHelper 40 | (...) 41 | scope "/" do 42 | addict :routes 43 | end 44 | end 45 | ``` 46 | 5 - Visit any of these paths on your app 47 | ``` 48 | /login 49 | /register 50 | /recover_password 51 | /reset_password 52 | ``` 53 | 54 | ## On what does it depend? 55 | Addict depends on: 56 | - [Phoenix Framework](http://www.phoenixframework.org) 57 | - [Ecto](https://github.com/elixir-lang/ecto) 58 | 59 | Optionally you can make Addict send e-mails for you too. At the moment only [Mailgun](https://mailgun.com) is supported. Feel free to contribute with [another service](#adding-custom-mailer)! 60 | 61 | ## Addict Configs 62 | 63 | See all available configurations [here](https://github.com/trenpixster/addict/blob/master/configs.md). 64 | 65 | ## How can I use it? 66 | 67 | ### Routes 68 | 69 | Add the following to your `router.ex`: 70 | 71 | ```elixir 72 | defmodule ExampleApp.Router do 73 | use Phoenix.Router 74 | use Addict.RoutesHelper 75 | 76 | ... 77 | 78 | scope "/" do 79 | addict :routes 80 | end 81 | end 82 | ``` 83 | 84 | This will generate the following routes: 85 | 86 | ``` 87 | register_path POST / register Addict.Controller.register/2 88 | login_path POST / login Addict.Controller.login/2 89 | logout_path DELETE / logout Addict.Controller.logout/2 90 | recover_password_path POST / recover_password Addict.Controller.recover_password/2 91 | reset_password_path POST / reset_password Addict.Controller.reset_password/2 92 | ``` 93 | 94 | You can also override the `path` or `controller`/`action` for a given route: 95 | 96 | ```elixir 97 | addict :routes, 98 | logout: [path: "/sign-out", controller: ExampleApp.UserController, action: :sign_out], 99 | recover_password: "/password/recover", 100 | reset_password: "/password/reset" 101 | ``` 102 | 103 | These overrides will generate the following routes: 104 | 105 | ``` 106 | register_path POST / register Addict.Controller.register/2 107 | login_path POST / login Addict.Controller.login/2 108 | logout_path DELETE / sign-out ExampleApp.UserController.sign_out/2 109 | recover_password_path POST / password/recover Addict.Controller.recover_password/2 110 | reset_password_path POST / password/reset Addict.Controller.reset_password/2 111 | ``` 112 | 113 | ### Interacting with Addict 114 | 115 | After you've added the router and generated the configs, please take look at the optional boilerplate and the Example App. Here are the interesting bits: 116 | - [Example AJAX interactions](https://github.com/trenpixster/addict/blob/master/boilerplate/addict.js) 117 | - [AJAX Error Handling](https://github.com/trenpixster/addict/blob/master/boilerplate/addict.js#L67) 118 | - [Restricted login path](https://github.com/trenpixster/addict/blob/master/example_app/web/controllers/page_controller.ex#L3) 119 | - [Login Form](https://github.com/trenpixster/addict/blob/master/boilerplate/login.html.eex) 120 | 121 | ### Addict Helper 122 | 123 | Addict saves information on the logged in user by setting `current_user` on the user's Session. 124 | You might want to use the `Addict.Helper` module that encapsulates this logic: 125 | 126 | - [`Addict.Helper.current_user/1`](https://hexdocs.pm/addict/Addict.Helper.html#current_user/1): Provided the `conn`, it returns the user model's hash representation, without any associations. 127 | - [`Addict.Helper.is_logged_in/1`](https://hexdocs.pm/addict/Addict.Helper.html#is_logged_in/1): Provided the `conn`, it returns `true` if the user is logged in, `false` otherwise. 128 | 129 | ## Checking for authentication 130 | Use `Addict.Plugs.Authenticated` plug to validate requests on your controllers: 131 | ```elixir 132 | defmodule MyAwesomeApp.PageController do 133 | use Phoenix.Controller 134 | 135 | plug Addict.Plugs.Authenticated when action in [:foobar] 136 | plug :action 137 | 138 | def foobar(conn, _params) do 139 | render conn, "index.html" 140 | end 141 | 142 | end 143 | ``` 144 | 145 | If the user is not logged in and requests for the above action, he will be redirected to `not_logged_in_url`. 146 | 147 | ## Adding Custom Mailer 148 | 149 | For adding a custom Mailer just follow the conventions: 150 | - Module must be `Addict.Mailers.TheEmailProvider` 151 | - Add the Mailer file in `/lib/addict/mailers` 152 | - Make sure the mailer implements the behaviour defined [here](https://github.com/trenpixster/addict/blob/master/lib/addict/mailers/generic.ex) 153 | 154 | Once that is done, just set `mail_service` configuration to `:the_email_provider`. 155 | 156 | ## TODO 157 | Check the [issues](https://github.com/trenpixster/addict/issues) on this repository to check or track the ongoing improvements and new features. 158 | 159 | ## Contributing 160 | 161 | Feel free to send your PR with improvements or corrections! 162 | 163 | Special thanks to the folks at #elixir-lang on freenet for being so helpful every damn time! 164 | -------------------------------------------------------------------------------- /boilerplate/addict.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | User Management 11 | 175 | 176 | 177 | 178 |
179 | 180 | 181 |
182 | <%= render @view_module, @view_template, assigns %> 183 |
184 | 185 |
186 | 309 | 310 | 311 | -------------------------------------------------------------------------------- /example_app/web/templates/addict/addict.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | User Management 11 | 175 | 176 | 177 | 178 |
179 | 180 | 181 |
182 | <%= render @view_module, @view_template, assigns %> 183 |
184 | 185 |
186 | 309 | 310 | 311 | -------------------------------------------------------------------------------- /docs/all.json: -------------------------------------------------------------------------------- 1 | {"shell":true,"revision":"45eab609f296e944957cebbce69c65acd066ec13","objects":[{"type":null,"source":"lib/addict/controller.ex:1","object_type":"ModuleObject","moduledoc":" Addict BaseController is used as a base to be extended by controllers if needed.\n BaseController has functions to receive User related requests directly from\n the Phoenix router. Adds `register/2`, `logout/2` and `login/2` as public functions.\n","module":"Elixir.Addict.BaseController","id":"Addict.BaseController"},{"type":null,"source":"lib/addict/interactors/addict_manager_interactor.ex:1","object_type":"ModuleObject","moduledoc":"Addict BaseManagerInteractor is used as a base manager to be extended if needed.\nIts responsability is to provide simple primitives for\nuser operations.\n","module":"Elixir.Addict.BaseManagerInteractor","id":"Addict.BaseManagerInteractor"},{"type":null,"source":"lib/addict/controller.ex:89","object_type":"ModuleObject","moduledoc":"Default controller used by Addict\n","module":"Elixir.Addict.Controller","id":"Addict.Controller"},{"type":null,"source":"lib/addict/email_gateway.ex:1","object_type":"ModuleObject","moduledoc":"The Addict EmailGateway is a wrapper for sending e-mails with the preferred\nmail library. For now, only Mailgun is supported.\n","module":"Elixir.Addict.EmailGateway","id":"Addict.EmailGateway"},{"type":null,"source":"lib/addict/mailers/mailgun.ex:1","object_type":"ModuleObject","moduledoc":"Wrapper for Mailgun client that handles eventual errors.\n","module":"Elixir.Addict.Mailers.Mailgun","id":"Addict.Mailers.Mailgun"},{"type":null,"source":"lib/addict/interactors/addict_manager_interactor.ex:162","object_type":"ModuleObject","moduledoc":"Default manager used by Addict\n","module":"Elixir.Addict.ManagerInteractor","id":"Addict.ManagerInteractor"},{"type":null,"source":"lib/addict/model.ex:1","object_type":"ModuleObject","moduledoc":null,"module":"Elixir.Addict.Model","id":"Addict.Model"},{"type":null,"source":"lib/addict/interactors/password_interactor.ex:1","object_type":"ModuleObject","moduledoc":null,"module":"Elixir.Addict.PasswordInteractor","id":"Addict.PasswordInteractor"},{"type":null,"source":"lib/addict/plugs/authenticated.ex:1","object_type":"ModuleObject","moduledoc":"Authenticated plug can be used to filter actions for users that are\nauthenticated.\n","module":"Elixir.Addict.Plugs.Authenticated","id":"Addict.Plugs.Authenticated"},{"type":null,"source":"lib/addict/repository.ex:1","object_type":"ModuleObject","moduledoc":"Addict Repository is responsible for interacting with the DB on the query\nlevel in order to manipulate user data.\n","module":"Elixir.Addict.Repository","id":"Addict.Repository"},{"type":null,"source":"lib/addict/routes_helper.ex:1","object_type":"ModuleObject","moduledoc":null,"module":"Elixir.Addict.RoutesHelper","id":"Addict.RoutesHelper"},{"type":null,"source":"lib/addict/interactors/session_interactor.ex:1","object_type":"ModuleObject","moduledoc":null,"module":"Elixir.Addict.SessionInteractor","id":"Addict.SessionInteractor"},{"type":null,"source":"lib/addict/view.ex:1","object_type":"ModuleObject","moduledoc":" Addict helper view functions to be used on templates\n","module":"Elixir.Addict.View","id":"Addict.View"},{"type":null,"source":"lib/addict/postgres_error_handler.ex:1","object_type":"ModuleObject","moduledoc":"Handles Postgres errors and provides friendly messages on known failures.\n","module":"Elixir.PostgresErrorHandler","id":"PostgresErrorHandler"},{"type":"def","source":"lib/addict/controller.ex:93","signature":[["conn",[],null],["options",[],"Elixir"]],"object_type":"FunctionObject","name":"action","module_id":"Addict.Controller","id":"action/2","doc":null,"arity":2},{"type":"def","source":"lib/addict/controller.ex:93","signature":[["conn",[],null],["action",[],null]],"object_type":"FunctionObject","name":"call","module_id":"Addict.Controller","id":"call/2","doc":false,"arity":2},{"type":"def","source":"lib/addict/controller.ex:93","signature":[["action",[],null]],"object_type":"FunctionObject","name":"init","module_id":"Addict.Controller","id":"init/1","doc":false,"arity":1},{"type":"def","source":"lib/addict/controller.ex:95","signature":[["conn",[],null],["params",[],null]],"object_type":"FunctionObject","name":"login","module_id":"Addict.Controller","id":"login/2","doc":" Entry point for logging users in.\n\n Params needs to be populated with `email` and `password`. It returns `200`\n status code along with the JSON response `{message: \"logged in\", user: %User{}` or `400`\n with `{message: \"invalid email or password\"}`\n\n","arity":2},{"type":"def","source":"lib/addict/controller.ex:95","signature":[["conn",[],null],["",[],"Elixir"]],"object_type":"FunctionObject","name":"logout","module_id":"Addict.Controller","id":"logout/2","doc":" Entry point for logging out users.\n\n Since it only deletes session data, it should always return a JSON response\n in the format `{message: \"logged out\"}` with a `200` status code.\n","arity":2},{"type":"def","source":"lib/addict/controller.ex:95","signature":[["conn",[],null],["params",[],null]],"object_type":"FunctionObject","name":"recover_password","module_id":"Addict.Controller","id":"recover_password/2","doc":" Entry point for asking for a new password.\n\n Params need to be populated with `email`\n","arity":2},{"type":"def","source":"lib/addict/controller.ex:95","signature":[["conn",[],null],["user_params",[],null]],"object_type":"FunctionObject","name":"register","module_id":"Addict.Controller","id":"register/2","doc":" Entry point for registering new users.\n\n `params` needs to include email, password and username.\n Returns a JSON response in the format `{message: text, user: %User{}}` with status `201` for\n successful creation, or `400` for when an error occurs.\n On success, it also logs the new user in.\n","arity":2},{"type":"def","source":"lib/addict/controller.ex:95","signature":[["conn",[],null],["params",[],null]],"object_type":"FunctionObject","name":"reset_password","module_id":"Addict.Controller","id":"reset_password/2","doc":" Entry point for setting a user's password given the reset token.\n\n Params needed to be populated with `token`, `password` and `password_confirm`\n","arity":2},{"type":"def","source":"lib/addict/email_gateway.ex:13","signature":[["user",[],null],["\\\\",[],[["mailer",[],null],"Elixir.Addict.Mailers.Mailgun"]]],"object_type":"FunctionObject","name":"send_password_recovery_email","module_id":"Addict.EmailGateway","id":"send_password_recovery_email/2","doc":null,"arity":2},{"type":"def","source":"lib/addict/email_gateway.ex:6","signature":[["user",[],null],["\\\\",[],[["mailer",[],null],"Elixir.Addict.Mailers.Mailgun"]]],"object_type":"FunctionObject","name":"send_welcome_email","module_id":"Addict.EmailGateway","id":"send_welcome_email/2","doc":null,"arity":2},{"type":"def","source":"lib/addict/mailers/mailgun.ex:6","signature":[],"object_type":"FunctionObject","name":"conf","module_id":"Addict.Mailers.Mailgun","id":"conf/0","doc":null,"arity":0},{"type":"def","source":"lib/addict/mailers/mailgun.ex:6","signature":[["email",[],null]],"object_type":"FunctionObject","name":"send_email","module_id":"Addict.Mailers.Mailgun","id":"send_email/1","doc":null,"arity":1},{"type":"def","source":"lib/addict/mailers/mailgun.ex:22","signature":[["email",[],null],["from",[],null],["subject",[],null],["html_body",[],null]],"object_type":"FunctionObject","name":"send_email","module_id":"Addict.Mailers.Mailgun","id":"send_email/4","doc":null,"arity":4},{"type":"def","source":"lib/addict/mailers/mailgun.ex:13","signature":[["email",[],null],["from",[],null],["subject",[],null],["html_body",[],null]],"object_type":"FunctionObject","name":"send_email_to_user","module_id":"Addict.Mailers.Mailgun","id":"send_email_to_user/4","doc":"Sends an e-mail to a user. Returns a tuple with `{:ok, result}` on success or\n`{:error, status_error}` on failure.\n","arity":4},{"type":"def","source":"lib/addict/interactors/addict_manager_interactor.ex:167","signature":[["atom1",[],"Elixir"]],"object_type":"FunctionObject","name":"create","module_id":"Addict.ManagerInteractor","id":"create/1","doc":" Throws exception when user params is invalid.\n","arity":1},{"type":"def","source":"lib/addict/interactors/addict_manager_interactor.ex:167","signature":[["user_params",[],null],["\\\\",[],[["repo",[],null],"Elixir.Addict.Repository"]],["\\\\",[],[["mailer",[],null],"Elixir.Addict.EmailGateway"]],["\\\\",[],[["password_interactor",[],null],"Elixir.Addict.PasswordInteractor"]]],"object_type":"FunctionObject","name":"create","module_id":"Addict.ManagerInteractor","id":"create/4","doc":" Creates a user on the database and sends the welcoming e-mail via the defined\n `mailer`.\n\n Required fields in `user_params` `Dict` are: `email`, `password`, `username`.\n","arity":4},{"type":"def","source":"lib/addict/interactors/addict_manager_interactor.ex:167","signature":[["email",[],null],["\\\\",[],[["repo",[],null],"Elixir.Addict.Repository"]],["\\\\",[],[["mailer",[],null],"Elixir.Addict.EmailGateway"]]],"object_type":"FunctionObject","name":"recover_password","module_id":"Addict.ManagerInteractor","id":"recover_password/3","doc":" Sends an e-mail to the user with a link to recover the password.\n","arity":3},{"type":"def","source":"lib/addict/interactors/addict_manager_interactor.ex:167","signature":[["recovery_hash",[],null],["password",[],null],["password_confirm",[],null]],"object_type":"FunctionObject","name":"reset_password","module_id":"Addict.ManagerInteractor","id":"reset_password/3","doc":" Triggers an error when `recovery_hash` is invalid.\n","arity":3},{"type":"def","source":"lib/addict/interactors/addict_manager_interactor.ex:167","signature":[["recovery_hash",[],null],["password",[],null],["password_confirm",[],null],["\\\\",[],[["repo",[],null],"Elixir.Addict.Repository"]],["\\\\",[],[["password_interactor",[],null],"Elixir.Addict.PasswordInteractor"]]],"object_type":"FunctionObject","name":"reset_password","module_id":"Addict.ManagerInteractor","id":"reset_password/5","doc":" Resets the password for the user with the given `recovery_hash`.\n","arity":5},{"type":"def","source":"lib/addict/interactors/addict_manager_interactor.ex:167","signature":[["email",[],null],["password",[],null],["\\\\",[],[["repo",[],null],"Elixir.Addict.Repository"]],["\\\\",[],[["password_interactor",[],null],"Elixir.Addict.PasswordInteractor"]]],"object_type":"FunctionObject","name":"verify_password","module_id":"Addict.ManagerInteractor","id":"verify_password/4","doc":" Verifies if the provided `password` is the same as the `password` for the user\n associated with the given `email`.\n","arity":4},{"type":"def","source":"lib/addict/model.ex:20","signature":[["user_params",[],null]],"object_type":"FunctionObject","name":"validate_new_model","module_id":"Addict.Model","id":"validate_new_model/1","doc":"Creates a changeset based on the `model` and `params`.\n\nIf no params are provided, an invalid changeset is returned\nwith no validation performed.\n","arity":1},{"type":"def","source":"lib/addict/interactors/password_interactor.ex:8","signature":[["password",[],null]],"object_type":"FunctionObject","name":"generate_hash","module_id":"Addict.PasswordInteractor","id":"generate_hash/1","doc":null,"arity":1},{"type":"def","source":"lib/addict/interactors/password_interactor.ex:4","signature":[],"object_type":"FunctionObject","name":"generate_random_hash","module_id":"Addict.PasswordInteractor","id":"generate_random_hash/0","doc":null,"arity":0},{"type":"def","source":"lib/addict/interactors/password_interactor.ex:12","signature":[["hash",[],null],["password",[],null]],"object_type":"FunctionObject","name":"verify_credentials","module_id":"Addict.PasswordInteractor","id":"verify_credentials/2","doc":null,"arity":2},{"type":"def","source":"lib/addict/plugs/authenticated.ex:23","signature":[["conn",[],null],["",[],"Elixir"]],"object_type":"FunctionObject","name":"call","module_id":"Addict.Plugs.Authenticated","id":"call/2","doc":"Call represents the use of the plug itself.\n\nWhen called, it will assign `current_user` to `conn`, so it is\npossible to always retrieve the user via `conn.assigns.current_user`.\n\nIn case the user is not logged in, it will redirect the request to\nthe Application :addict :not_logged_in_url page. If none is defined, it will\nredirect to `/error`.\n","arity":2},{"type":"def","source":"lib/addict/plugs/authenticated.ex:8","signature":[["options",[],null]],"object_type":"FunctionObject","name":"init","module_id":"Addict.Plugs.Authenticated","id":"init/1","doc":null,"arity":1},{"type":"def","source":"lib/addict/plugs/authenticated.ex:33","signature":[["user_session",[],null]],"object_type":"FunctionObject","name":"is_logged_in","module_id":"Addict.Plugs.Authenticated","id":"is_logged_in/1","doc":null,"arity":1},{"type":"def","source":"lib/addict/plugs/authenticated.ex:40","signature":[],"object_type":"FunctionObject","name":"not_logged_in_url","module_id":"Addict.Plugs.Authenticated","id":"not_logged_in_url/0","doc":null,"arity":0},{"type":"def","source":"lib/addict/repository.ex:29","signature":[["user",[],null],["hash",[],null]],"object_type":"FunctionObject","name":"add_recovery_hash","module_id":"Addict.Repository","id":"add_recovery_hash/2","doc":"Adds a recovery hash to the user.\n\nIt either returns a tuple with `{:ok, user}` or, in case an error\nhappens, a tuple with `{:error, error_message}`\n","arity":2},{"type":"def","source":"lib/addict/repository.ex:56","signature":[["user",[],null],["hash",[],null]],"object_type":"FunctionObject","name":"change_password","module_id":"Addict.Repository","id":"change_password/2","doc":"Changes the hashed password for the target user.\n\nIt either returns a tuple with `{:ok, user}` or, in case an error\nhappens, a tuple with `{:error, error_message}`\n","arity":2},{"type":"def","source":"lib/addict/repository.ex:19","signature":[["user_params",[],null]],"object_type":"FunctionObject","name":"create","module_id":"Addict.Repository","id":"create/1","doc":"Creates a new user on the database with the given parameters.\n\nIt either returns a tuple with `{:ok, user}` or, in case an error\nhappens, a tuple with `{:error, error_message}`\n","arity":1},{"type":"def","source":"lib/addict/repository.ex:72","signature":[["email",[],null]],"object_type":"FunctionObject","name":"find_by_email","module_id":"Addict.Repository","id":"find_by_email/1","doc":"Retrieves a single user from the database based on the user's e-mail.\n\nIt either returns the `user` or, in case an error occurs, a tuple with\n`{:error, error_message}`. If no user exists, `nil` will be returned.\n","arity":1},{"type":"def","source":"lib/addict/repository.ex:87","signature":[["hash",[],null]],"object_type":"FunctionObject","name":"find_by_recovery_hash","module_id":"Addict.Repository","id":"find_by_recovery_hash/1","doc":"Retrieves a single user from the database based on the user's recovery hash.\n\nIt either returns the `user` or, in case an error occurs, a tuple with\n`{:error, error_message}`. If no user exists, `nil` will be returned.\n","arity":1},{"type":"defmacro","source":"lib/addict/routes_helper.ex:8","signature":[["atom1",[],"Elixir"],["\\\\",[],[["options",[],null],["%{}",[["line",8]],[]]]]],"object_type":"FunctionObject","name":"addict","module_id":"Addict.RoutesHelper","id":"addict/2","doc":null,"arity":2},{"type":"def","source":"lib/addict/interactors/session_interactor.ex:18","signature":[["arg1",[],"Elixir"],["conn",[],null]],"object_type":"FunctionObject","name":"login","module_id":"Addict.SessionInteractor","id":"login/2","doc":null,"arity":2},{"type":"def","source":"lib/addict/interactors/session_interactor.ex:31","signature":[["conn",[],null]],"object_type":"FunctionObject","name":"logout","module_id":"Addict.SessionInteractor","id":"logout/1","doc":null,"arity":1},{"type":"def","source":"lib/addict/interactors/session_interactor.ex:39","signature":[["arg1",[],"Elixir"],["conn",[],null]],"object_type":"FunctionObject","name":"password_recover","module_id":"Addict.SessionInteractor","id":"password_recover/2","doc":null,"arity":2},{"type":"def","source":"lib/addict/interactors/session_interactor.ex:51","signature":[["arg1",[],"Elixir"],["conn",[],null]],"object_type":"FunctionObject","name":"password_reset","module_id":"Addict.SessionInteractor","id":"password_reset/2","doc":null,"arity":2},{"type":"def","source":"lib/addict/interactors/session_interactor.ex:4","signature":[["arg1",[],"Elixir"],["conn",[],null]],"object_type":"FunctionObject","name":"register","module_id":"Addict.SessionInteractor","id":"register/2","doc":null,"arity":2},{"type":"def","source":"lib/addict/view.ex:19","signature":[["conn",[],null],["prop",[],null]],"object_type":"FunctionObject","name":"get_user","module_id":"Addict.View","id":"get_user/2","doc":" gets user model properties\n","arity":2},{"type":"def","source":"lib/addict/view.ex:12","signature":[["conn",[],null]],"object_type":"FunctionObject","name":"logged_in","module_id":"Addict.View","id":"logged_in/1","doc":" checks if user is logged in, returns true if so,\n and false if not\n","arity":1},{"type":"def","source":"lib/addict/postgres_error_handler.ex:10","signature":[["arg1",[],"Elixir"],["postgres_error",[],null]],"object_type":"FunctionObject","name":"handle_error","module_id":"PostgresErrorHandler","id":"handle_error/2","doc":"Handles generic errors.\n","arity":2}],"language":"elixir","git_repo_url":"https://github.com/trenpixster/addict.git","client_version":"0.3.3","client_name":"inch_ex","branch_name":"master","args":[]} -------------------------------------------------------------------------------- /example_app/priv/static/js/phoenix.js: -------------------------------------------------------------------------------- 1 | (function(exports){ 2 | "use strict"; 3 | 4 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol ? "symbol" : typeof obj; }; 5 | 6 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 7 | 8 | Object.defineProperty(exports, "__esModule", { 9 | value: true 10 | }); 11 | 12 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 13 | 14 | // Phoenix Channels JavaScript client 15 | // 16 | // ## Socket Connection 17 | // 18 | // A single connection is established to the server and 19 | // channels are mulitplexed over the connection. 20 | // Connect to the server using the `Socket` class: 21 | // 22 | // let socket = new Socket("/ws", {params: {userToken: "123"}}) 23 | // socket.connect() 24 | // 25 | // The `Socket` constructor takes the mount point of the socket, 26 | // the authentication params, as well as options that can be found in 27 | // the Socket docs, such as configuring the `LongPoll` transport, and 28 | // heartbeat. 29 | // 30 | // ## Channels 31 | // 32 | // Channels are isolated, concurrent processes on the server that 33 | // subscribe to topics and broker events between the client and server. 34 | // To join a channel, you must provide the topic, and channel params for 35 | // authorization. Here's an example chat room example where `"new_msg"` 36 | // events are listened for, messages are pushed to the server, and 37 | // the channel is joined with ok/error/timeout matches: 38 | // 39 | // let channel = socket.channel("rooms:123", {token: roomToken}) 40 | // channel.on("new_msg", msg => console.log("Got message", msg) ) 41 | // $input.onEnter( e => { 42 | // channel.push("new_msg", {body: e.target.val}, 10000) 43 | // .receive("ok", (msg) => console.log("created message", msg) ) 44 | // .receive("error", (reasons) => console.log("create failed", reasons) ) 45 | // .receive("timeout", () => console.log("Networking issue...") ) 46 | // }) 47 | // channel.join() 48 | // .receive("ok", ({messages}) => console.log("catching up", messages) ) 49 | // .receive("error", ({reason}) => console.log("failed join", reason) ) 50 | // .receive("timeout", () => console.log("Networking issue. Still waiting...") ) 51 | // 52 | // 53 | // ## Joining 54 | // 55 | // Creating a channel with `socket.channel(topic, params)`, binds the params to 56 | // `channel.params`, which are sent up on `channel.join()`. 57 | // Subsequent rejoins will send up the modified params for 58 | // updating authorization params, or passing up last_message_id information. 59 | // Successful joins receive an "ok" status, while unsuccessful joins 60 | // receive "error". 61 | // 62 | // 63 | // ## Pushing Messages 64 | // 65 | // From the previous example, we can see that pushing messages to the server 66 | // can be done with `channel.push(eventName, payload)` and we can optionally 67 | // receive responses from the push. Additionally, we can use 68 | // `receive("timeout", callback)` to abort waiting for our other `receive` hooks 69 | // and take action after some period of waiting. The default timeout is 5000ms. 70 | // 71 | // 72 | // ## Socket Hooks 73 | // 74 | // Lifecycle events of the multiplexed connection can be hooked into via 75 | // `socket.onError()` and `socket.onClose()` events, ie: 76 | // 77 | // socket.onError( () => console.log("there was an error with the connection!") ) 78 | // socket.onClose( () => console.log("the connection dropped") ) 79 | // 80 | // 81 | // ## Channel Hooks 82 | // 83 | // For each joined channel, you can bind to `onError` and `onClose` events 84 | // to monitor the channel lifecycle, ie: 85 | // 86 | // channel.onError( () => console.log("there was an error!") ) 87 | // channel.onClose( () => console.log("the channel has gone away gracefully") ) 88 | // 89 | // ### onError hooks 90 | // 91 | // `onError` hooks are invoked if the socket connection drops, or the channel 92 | // crashes on the server. In either case, a channel rejoin is attemtped 93 | // automatically in an exponential backoff manner. 94 | // 95 | // ### onClose hooks 96 | // 97 | // `onClose` hooks are invoked only in two cases. 1) the channel explicitly 98 | // closed on the server, or 2). The client explicitly closed, by calling 99 | // `channel.leave()` 100 | // 101 | 102 | var VSN = "1.0.0"; 103 | var SOCKET_STATES = { connecting: 0, open: 1, closing: 2, closed: 3 }; 104 | var DEFAULT_TIMEOUT = 10000; 105 | var CHANNEL_STATES = { 106 | closed: "closed", 107 | errored: "errored", 108 | joined: "joined", 109 | joining: "joining" 110 | }; 111 | var CHANNEL_EVENTS = { 112 | close: "phx_close", 113 | error: "phx_error", 114 | join: "phx_join", 115 | reply: "phx_reply", 116 | leave: "phx_leave" 117 | }; 118 | var TRANSPORTS = { 119 | longpoll: "longpoll", 120 | websocket: "websocket" 121 | }; 122 | 123 | var Push = function () { 124 | 125 | // Initializes the Push 126 | // 127 | // channel - The Channel 128 | // event - The event, for example `"phx_join"` 129 | // payload - The payload, for example `{user_id: 123}` 130 | // timeout - The push timeout in milliseconds 131 | // 132 | 133 | function Push(channel, event, payload, timeout) { 134 | _classCallCheck(this, Push); 135 | 136 | this.channel = channel; 137 | this.event = event; 138 | this.payload = payload || {}; 139 | this.receivedResp = null; 140 | this.timeout = timeout; 141 | this.timeoutTimer = null; 142 | this.recHooks = []; 143 | this.sent = false; 144 | } 145 | 146 | _createClass(Push, [{ 147 | key: "resend", 148 | value: function resend(timeout) { 149 | this.timeout = timeout; 150 | this.cancelRefEvent(); 151 | this.ref = null; 152 | this.refEvent = null; 153 | this.receivedResp = null; 154 | this.sent = false; 155 | this.send(); 156 | } 157 | }, { 158 | key: "send", 159 | value: function send() { 160 | if (this.hasReceived("timeout")) { 161 | return; 162 | } 163 | this.startTimeout(); 164 | this.sent = true; 165 | this.channel.socket.push({ 166 | topic: this.channel.topic, 167 | event: this.event, 168 | payload: this.payload, 169 | ref: this.ref 170 | }); 171 | } 172 | }, { 173 | key: "receive", 174 | value: function receive(status, callback) { 175 | if (this.hasReceived(status)) { 176 | callback(this.receivedResp.response); 177 | } 178 | 179 | this.recHooks.push({ status: status, callback: callback }); 180 | return this; 181 | } 182 | 183 | // private 184 | 185 | }, { 186 | key: "matchReceive", 187 | value: function matchReceive(_ref) { 188 | var status = _ref.status; 189 | var response = _ref.response; 190 | var ref = _ref.ref; 191 | 192 | this.recHooks.filter(function (h) { 193 | return h.status === status; 194 | }).forEach(function (h) { 195 | return h.callback(response); 196 | }); 197 | } 198 | }, { 199 | key: "cancelRefEvent", 200 | value: function cancelRefEvent() { 201 | if (!this.refEvent) { 202 | return; 203 | } 204 | this.channel.off(this.refEvent); 205 | } 206 | }, { 207 | key: "cancelTimeout", 208 | value: function cancelTimeout() { 209 | clearTimeout(this.timeoutTimer); 210 | this.timeoutTimer = null; 211 | } 212 | }, { 213 | key: "startTimeout", 214 | value: function startTimeout() { 215 | var _this = this; 216 | 217 | if (this.timeoutTimer) { 218 | return; 219 | } 220 | this.ref = this.channel.socket.makeRef(); 221 | this.refEvent = this.channel.replyEventName(this.ref); 222 | 223 | this.channel.on(this.refEvent, function (payload) { 224 | _this.cancelRefEvent(); 225 | _this.cancelTimeout(); 226 | _this.receivedResp = payload; 227 | _this.matchReceive(payload); 228 | }); 229 | 230 | this.timeoutTimer = setTimeout(function () { 231 | _this.trigger("timeout", {}); 232 | }, this.timeout); 233 | } 234 | }, { 235 | key: "hasReceived", 236 | value: function hasReceived(status) { 237 | return this.receivedResp && this.receivedResp.status === status; 238 | } 239 | }, { 240 | key: "trigger", 241 | value: function trigger(status, response) { 242 | this.channel.trigger(this.refEvent, { status: status, response: response }); 243 | } 244 | }]); 245 | 246 | return Push; 247 | }(); 248 | 249 | var Channel = exports.Channel = function () { 250 | function Channel(topic, params, socket) { 251 | var _this2 = this; 252 | 253 | _classCallCheck(this, Channel); 254 | 255 | this.state = CHANNEL_STATES.closed; 256 | this.topic = topic; 257 | this.params = params || {}; 258 | this.socket = socket; 259 | this.bindings = []; 260 | this.timeout = this.socket.timeout; 261 | this.joinedOnce = false; 262 | this.joinPush = new Push(this, CHANNEL_EVENTS.join, this.params, this.timeout); 263 | this.pushBuffer = []; 264 | this.rejoinTimer = new Timer(function () { 265 | return _this2.rejoinUntilConnected(); 266 | }, this.socket.reconnectAfterMs); 267 | this.joinPush.receive("ok", function () { 268 | _this2.state = CHANNEL_STATES.joined; 269 | _this2.rejoinTimer.reset(); 270 | _this2.pushBuffer.forEach(function (pushEvent) { 271 | return pushEvent.send(); 272 | }); 273 | _this2.pushBuffer = []; 274 | }); 275 | this.onClose(function () { 276 | _this2.socket.log("channel", "close " + _this2.topic); 277 | _this2.state = CHANNEL_STATES.closed; 278 | _this2.socket.remove(_this2); 279 | }); 280 | this.onError(function (reason) { 281 | _this2.socket.log("channel", "error " + _this2.topic, reason); 282 | _this2.state = CHANNEL_STATES.errored; 283 | _this2.rejoinTimer.scheduleTimeout(); 284 | }); 285 | this.joinPush.receive("timeout", function () { 286 | if (_this2.state !== CHANNEL_STATES.joining) { 287 | return; 288 | } 289 | 290 | _this2.socket.log("channel", "timeout " + _this2.topic, _this2.joinPush.timeout); 291 | _this2.state = CHANNEL_STATES.errored; 292 | _this2.rejoinTimer.scheduleTimeout(); 293 | }); 294 | this.on(CHANNEL_EVENTS.reply, function (payload, ref) { 295 | _this2.trigger(_this2.replyEventName(ref), payload); 296 | }); 297 | } 298 | 299 | _createClass(Channel, [{ 300 | key: "rejoinUntilConnected", 301 | value: function rejoinUntilConnected() { 302 | this.rejoinTimer.scheduleTimeout(); 303 | if (this.socket.isConnected()) { 304 | this.rejoin(); 305 | } 306 | } 307 | }, { 308 | key: "join", 309 | value: function join() { 310 | var timeout = arguments.length <= 0 || arguments[0] === undefined ? this.timeout : arguments[0]; 311 | 312 | if (this.joinedOnce) { 313 | throw "tried to join multiple times. 'join' can only be called a single time per channel instance"; 314 | } else { 315 | this.joinedOnce = true; 316 | } 317 | this.rejoin(timeout); 318 | return this.joinPush; 319 | } 320 | }, { 321 | key: "onClose", 322 | value: function onClose(callback) { 323 | this.on(CHANNEL_EVENTS.close, callback); 324 | } 325 | }, { 326 | key: "onError", 327 | value: function onError(callback) { 328 | this.on(CHANNEL_EVENTS.error, function (reason) { 329 | return callback(reason); 330 | }); 331 | } 332 | }, { 333 | key: "on", 334 | value: function on(event, callback) { 335 | this.bindings.push({ event: event, callback: callback }); 336 | } 337 | }, { 338 | key: "off", 339 | value: function off(event) { 340 | this.bindings = this.bindings.filter(function (bind) { 341 | return bind.event !== event; 342 | }); 343 | } 344 | }, { 345 | key: "canPush", 346 | value: function canPush() { 347 | return this.socket.isConnected() && this.state === CHANNEL_STATES.joined; 348 | } 349 | }, { 350 | key: "push", 351 | value: function push(event, payload) { 352 | var timeout = arguments.length <= 2 || arguments[2] === undefined ? this.timeout : arguments[2]; 353 | 354 | if (!this.joinedOnce) { 355 | throw "tried to push '" + event + "' to '" + this.topic + "' before joining. Use channel.join() before pushing events"; 356 | } 357 | var pushEvent = new Push(this, event, payload, timeout); 358 | if (this.canPush()) { 359 | pushEvent.send(); 360 | } else { 361 | pushEvent.startTimeout(); 362 | this.pushBuffer.push(pushEvent); 363 | } 364 | 365 | return pushEvent; 366 | } 367 | 368 | // Leaves the channel 369 | // 370 | // Unsubscribes from server events, and 371 | // instructs channel to terminate on server 372 | // 373 | // Triggers onClose() hooks 374 | // 375 | // To receive leave acknowledgements, use the a `receive` 376 | // hook to bind to the server ack, ie: 377 | // 378 | // channel.leave().receive("ok", () => alert("left!") ) 379 | // 380 | 381 | }, { 382 | key: "leave", 383 | value: function leave() { 384 | var _this3 = this; 385 | 386 | var timeout = arguments.length <= 0 || arguments[0] === undefined ? this.timeout : arguments[0]; 387 | 388 | var onClose = function onClose() { 389 | _this3.socket.log("channel", "leave " + _this3.topic); 390 | _this3.trigger(CHANNEL_EVENTS.close, "leave"); 391 | }; 392 | var leavePush = new Push(this, CHANNEL_EVENTS.leave, {}, timeout); 393 | leavePush.receive("ok", function () { 394 | return onClose(); 395 | }).receive("timeout", function () { 396 | return onClose(); 397 | }); 398 | leavePush.send(); 399 | if (!this.canPush()) { 400 | leavePush.trigger("ok", {}); 401 | } 402 | 403 | return leavePush; 404 | } 405 | 406 | // Overridable message hook 407 | // 408 | // Receives all events for specialized message handling 409 | 410 | }, { 411 | key: "onMessage", 412 | value: function onMessage(event, payload, ref) {} 413 | 414 | // private 415 | 416 | }, { 417 | key: "isMember", 418 | value: function isMember(topic) { 419 | return this.topic === topic; 420 | } 421 | }, { 422 | key: "sendJoin", 423 | value: function sendJoin(timeout) { 424 | this.state = CHANNEL_STATES.joining; 425 | this.joinPush.resend(timeout); 426 | } 427 | }, { 428 | key: "rejoin", 429 | value: function rejoin() { 430 | var timeout = arguments.length <= 0 || arguments[0] === undefined ? this.timeout : arguments[0]; 431 | this.sendJoin(timeout); 432 | } 433 | }, { 434 | key: "trigger", 435 | value: function trigger(triggerEvent, payload, ref) { 436 | this.onMessage(triggerEvent, payload, ref); 437 | this.bindings.filter(function (bind) { 438 | return bind.event === triggerEvent; 439 | }).map(function (bind) { 440 | return bind.callback(payload, ref); 441 | }); 442 | } 443 | }, { 444 | key: "replyEventName", 445 | value: function replyEventName(ref) { 446 | return "chan_reply_" + ref; 447 | } 448 | }]); 449 | 450 | return Channel; 451 | }(); 452 | 453 | var Socket = exports.Socket = function () { 454 | 455 | // Initializes the Socket 456 | // 457 | // endPoint - The string WebSocket endpoint, ie, "ws://example.com/ws", 458 | // "wss://example.com" 459 | // "/ws" (inherited host & protocol) 460 | // opts - Optional configuration 461 | // transport - The Websocket Transport, for example WebSocket or Phoenix.LongPoll. 462 | // Defaults to WebSocket with automatic LongPoll fallback. 463 | // timeout - The default timeout in milliseconds to trigger push timeouts. 464 | // Defaults `DEFAULT_TIMEOUT` 465 | // heartbeatIntervalMs - The millisec interval to send a heartbeat message 466 | // reconnectAfterMs - The optional function that returns the millsec 467 | // reconnect interval. Defaults to stepped backoff of: 468 | // 469 | // function(tries){ 470 | // return [1000, 5000, 10000][tries - 1] || 10000 471 | // } 472 | // 473 | // logger - The optional function for specialized logging, ie: 474 | // `logger: (kind, msg, data) => { console.log(`${kind}: ${msg}`, data) } 475 | // 476 | // longpollerTimeout - The maximum timeout of a long poll AJAX request. 477 | // Defaults to 20s (double the server long poll timer). 478 | // 479 | // params - The optional params to pass when connecting 480 | // 481 | // For IE8 support use an ES5-shim (https://github.com/es-shims/es5-shim) 482 | // 483 | 484 | function Socket(endPoint) { 485 | var _this4 = this; 486 | 487 | var opts = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; 488 | 489 | _classCallCheck(this, Socket); 490 | 491 | this.stateChangeCallbacks = { open: [], close: [], error: [], message: [] }; 492 | this.channels = []; 493 | this.sendBuffer = []; 494 | this.ref = 0; 495 | this.timeout = opts.timeout || DEFAULT_TIMEOUT; 496 | this.transport = opts.transport || window.WebSocket || LongPoll; 497 | this.heartbeatIntervalMs = opts.heartbeatIntervalMs || 30000; 498 | this.reconnectAfterMs = opts.reconnectAfterMs || function (tries) { 499 | return [1000, 2000, 5000, 10000][tries - 1] || 10000; 500 | }; 501 | this.logger = opts.logger || function () {}; // noop 502 | this.longpollerTimeout = opts.longpollerTimeout || 20000; 503 | this.params = opts.params || {}; 504 | this.endPoint = endPoint + "/" + TRANSPORTS.websocket; 505 | this.reconnectTimer = new Timer(function () { 506 | _this4.disconnect(function () { 507 | return _this4.connect(); 508 | }); 509 | }, this.reconnectAfterMs); 510 | } 511 | 512 | _createClass(Socket, [{ 513 | key: "protocol", 514 | value: function protocol() { 515 | return location.protocol.match(/^https/) ? "wss" : "ws"; 516 | } 517 | }, { 518 | key: "endPointURL", 519 | value: function endPointURL() { 520 | var uri = Ajax.appendParams(Ajax.appendParams(this.endPoint, this.params), { vsn: VSN }); 521 | if (uri.charAt(0) !== "/") { 522 | return uri; 523 | } 524 | if (uri.charAt(1) === "/") { 525 | return this.protocol() + ":" + uri; 526 | } 527 | 528 | return this.protocol() + "://" + location.host + uri; 529 | } 530 | }, { 531 | key: "disconnect", 532 | value: function disconnect(callback, code, reason) { 533 | if (this.conn) { 534 | this.conn.onclose = function () {}; // noop 535 | if (code) { 536 | this.conn.close(code, reason || ""); 537 | } else { 538 | this.conn.close(); 539 | } 540 | this.conn = null; 541 | } 542 | callback && callback(); 543 | } 544 | 545 | // params - The params to send when connecting, for example `{user_id: userToken}` 546 | 547 | }, { 548 | key: "connect", 549 | value: function connect(params) { 550 | var _this5 = this; 551 | 552 | if (params) { 553 | console && console.log("passing params to connect is deprecated. Instead pass :params to the Socket constructor"); 554 | this.params = params; 555 | } 556 | if (this.conn) { 557 | return; 558 | } 559 | 560 | this.conn = new this.transport(this.endPointURL()); 561 | this.conn.timeout = this.longpollerTimeout; 562 | this.conn.onopen = function () { 563 | return _this5.onConnOpen(); 564 | }; 565 | this.conn.onerror = function (error) { 566 | return _this5.onConnError(error); 567 | }; 568 | this.conn.onmessage = function (event) { 569 | return _this5.onConnMessage(event); 570 | }; 571 | this.conn.onclose = function (event) { 572 | return _this5.onConnClose(event); 573 | }; 574 | } 575 | 576 | // Logs the message. Override `this.logger` for specialized logging. noops by default 577 | 578 | }, { 579 | key: "log", 580 | value: function log(kind, msg, data) { 581 | this.logger(kind, msg, data); 582 | } 583 | 584 | // Registers callbacks for connection state change events 585 | // 586 | // Examples 587 | // 588 | // socket.onError(function(error){ alert("An error occurred") }) 589 | // 590 | 591 | }, { 592 | key: "onOpen", 593 | value: function onOpen(callback) { 594 | this.stateChangeCallbacks.open.push(callback); 595 | } 596 | }, { 597 | key: "onClose", 598 | value: function onClose(callback) { 599 | this.stateChangeCallbacks.close.push(callback); 600 | } 601 | }, { 602 | key: "onError", 603 | value: function onError(callback) { 604 | this.stateChangeCallbacks.error.push(callback); 605 | } 606 | }, { 607 | key: "onMessage", 608 | value: function onMessage(callback) { 609 | this.stateChangeCallbacks.message.push(callback); 610 | } 611 | }, { 612 | key: "onConnOpen", 613 | value: function onConnOpen() { 614 | var _this6 = this; 615 | 616 | this.log("transport", "connected to " + this.endPointURL(), this.transport.prototype); 617 | this.flushSendBuffer(); 618 | this.reconnectTimer.reset(); 619 | if (!this.conn.skipHeartbeat) { 620 | clearInterval(this.heartbeatTimer); 621 | this.heartbeatTimer = setInterval(function () { 622 | return _this6.sendHeartbeat(); 623 | }, this.heartbeatIntervalMs); 624 | } 625 | this.stateChangeCallbacks.open.forEach(function (callback) { 626 | return callback(); 627 | }); 628 | } 629 | }, { 630 | key: "onConnClose", 631 | value: function onConnClose(event) { 632 | this.log("transport", "close", event); 633 | this.triggerChanError(); 634 | clearInterval(this.heartbeatTimer); 635 | this.reconnectTimer.scheduleTimeout(); 636 | this.stateChangeCallbacks.close.forEach(function (callback) { 637 | return callback(event); 638 | }); 639 | } 640 | }, { 641 | key: "onConnError", 642 | value: function onConnError(error) { 643 | this.log("transport", error); 644 | this.triggerChanError(); 645 | this.stateChangeCallbacks.error.forEach(function (callback) { 646 | return callback(error); 647 | }); 648 | } 649 | }, { 650 | key: "triggerChanError", 651 | value: function triggerChanError() { 652 | this.channels.forEach(function (channel) { 653 | return channel.trigger(CHANNEL_EVENTS.error); 654 | }); 655 | } 656 | }, { 657 | key: "connectionState", 658 | value: function connectionState() { 659 | switch (this.conn && this.conn.readyState) { 660 | case SOCKET_STATES.connecting: 661 | return "connecting"; 662 | case SOCKET_STATES.open: 663 | return "open"; 664 | case SOCKET_STATES.closing: 665 | return "closing"; 666 | default: 667 | return "closed"; 668 | } 669 | } 670 | }, { 671 | key: "isConnected", 672 | value: function isConnected() { 673 | return this.connectionState() === "open"; 674 | } 675 | }, { 676 | key: "remove", 677 | value: function remove(channel) { 678 | this.channels = this.channels.filter(function (c) { 679 | return !c.isMember(channel.topic); 680 | }); 681 | } 682 | }, { 683 | key: "channel", 684 | value: function channel(topic) { 685 | var chanParams = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; 686 | 687 | var chan = new Channel(topic, chanParams, this); 688 | this.channels.push(chan); 689 | return chan; 690 | } 691 | }, { 692 | key: "push", 693 | value: function push(data) { 694 | var _this7 = this; 695 | 696 | var topic = data.topic; 697 | var event = data.event; 698 | var payload = data.payload; 699 | var ref = data.ref; 700 | 701 | var callback = function callback() { 702 | return _this7.conn.send(JSON.stringify(data)); 703 | }; 704 | this.log("push", topic + " " + event + " (" + ref + ")", payload); 705 | if (this.isConnected()) { 706 | callback(); 707 | } else { 708 | this.sendBuffer.push(callback); 709 | } 710 | } 711 | 712 | // Return the next message ref, accounting for overflows 713 | 714 | }, { 715 | key: "makeRef", 716 | value: function makeRef() { 717 | var newRef = this.ref + 1; 718 | if (newRef === this.ref) { 719 | this.ref = 0; 720 | } else { 721 | this.ref = newRef; 722 | } 723 | 724 | return this.ref.toString(); 725 | } 726 | }, { 727 | key: "sendHeartbeat", 728 | value: function sendHeartbeat() { 729 | if (!this.isConnected()) { 730 | return; 731 | } 732 | this.push({ topic: "phoenix", event: "heartbeat", payload: {}, ref: this.makeRef() }); 733 | } 734 | }, { 735 | key: "flushSendBuffer", 736 | value: function flushSendBuffer() { 737 | if (this.isConnected() && this.sendBuffer.length > 0) { 738 | this.sendBuffer.forEach(function (callback) { 739 | return callback(); 740 | }); 741 | this.sendBuffer = []; 742 | } 743 | } 744 | }, { 745 | key: "onConnMessage", 746 | value: function onConnMessage(rawMessage) { 747 | var msg = JSON.parse(rawMessage.data); 748 | var topic = msg.topic; 749 | var event = msg.event; 750 | var payload = msg.payload; 751 | var ref = msg.ref; 752 | 753 | this.log("receive", (payload.status || "") + " " + topic + " " + event + " " + (ref && "(" + ref + ")" || ""), payload); 754 | this.channels.filter(function (channel) { 755 | return channel.isMember(topic); 756 | }).forEach(function (channel) { 757 | return channel.trigger(event, payload, ref); 758 | }); 759 | this.stateChangeCallbacks.message.forEach(function (callback) { 760 | return callback(msg); 761 | }); 762 | } 763 | }]); 764 | 765 | return Socket; 766 | }(); 767 | 768 | var LongPoll = exports.LongPoll = function () { 769 | function LongPoll(endPoint) { 770 | _classCallCheck(this, LongPoll); 771 | 772 | this.endPoint = null; 773 | this.token = null; 774 | this.skipHeartbeat = true; 775 | this.onopen = function () {}; // noop 776 | this.onerror = function () {}; // noop 777 | this.onmessage = function () {}; // noop 778 | this.onclose = function () {}; // noop 779 | this.pollEndpoint = this.normalizeEndpoint(endPoint); 780 | this.readyState = SOCKET_STATES.connecting; 781 | 782 | this.poll(); 783 | } 784 | 785 | _createClass(LongPoll, [{ 786 | key: "normalizeEndpoint", 787 | value: function normalizeEndpoint(endPoint) { 788 | return endPoint.replace("ws://", "http://").replace("wss://", "https://").replace(new RegExp("(.*)\/" + TRANSPORTS.websocket), "$1/" + TRANSPORTS.longpoll); 789 | } 790 | }, { 791 | key: "endpointURL", 792 | value: function endpointURL() { 793 | return Ajax.appendParams(this.pollEndpoint, { token: this.token }); 794 | } 795 | }, { 796 | key: "closeAndRetry", 797 | value: function closeAndRetry() { 798 | this.close(); 799 | this.readyState = SOCKET_STATES.connecting; 800 | } 801 | }, { 802 | key: "ontimeout", 803 | value: function ontimeout() { 804 | this.onerror("timeout"); 805 | this.closeAndRetry(); 806 | } 807 | }, { 808 | key: "poll", 809 | value: function poll() { 810 | var _this8 = this; 811 | 812 | if (!(this.readyState === SOCKET_STATES.open || this.readyState === SOCKET_STATES.connecting)) { 813 | return; 814 | } 815 | 816 | Ajax.request("GET", this.endpointURL(), "application/json", null, this.timeout, this.ontimeout.bind(this), function (resp) { 817 | if (resp) { 818 | var status = resp.status; 819 | var token = resp.token; 820 | var messages = resp.messages; 821 | 822 | _this8.token = token; 823 | } else { 824 | var status = 0; 825 | } 826 | 827 | switch (status) { 828 | case 200: 829 | messages.forEach(function (msg) { 830 | return _this8.onmessage({ data: JSON.stringify(msg) }); 831 | }); 832 | _this8.poll(); 833 | break; 834 | case 204: 835 | _this8.poll(); 836 | break; 837 | case 410: 838 | _this8.readyState = SOCKET_STATES.open; 839 | _this8.onopen(); 840 | _this8.poll(); 841 | break; 842 | case 0: 843 | case 500: 844 | _this8.onerror(); 845 | _this8.closeAndRetry(); 846 | break; 847 | default: 848 | throw "unhandled poll status " + status; 849 | } 850 | }); 851 | } 852 | }, { 853 | key: "send", 854 | value: function send(body) { 855 | var _this9 = this; 856 | 857 | Ajax.request("POST", this.endpointURL(), "application/json", body, this.timeout, this.onerror.bind(this, "timeout"), function (resp) { 858 | if (!resp || resp.status !== 200) { 859 | _this9.onerror(status); 860 | _this9.closeAndRetry(); 861 | } 862 | }); 863 | } 864 | }, { 865 | key: "close", 866 | value: function close(code, reason) { 867 | this.readyState = SOCKET_STATES.closed; 868 | this.onclose(); 869 | } 870 | }]); 871 | 872 | return LongPoll; 873 | }(); 874 | 875 | var Ajax = exports.Ajax = function () { 876 | function Ajax() { 877 | _classCallCheck(this, Ajax); 878 | } 879 | 880 | _createClass(Ajax, null, [{ 881 | key: "request", 882 | value: function request(method, endPoint, accept, body, timeout, ontimeout, callback) { 883 | if (window.XDomainRequest) { 884 | var req = new XDomainRequest(); // IE8, IE9 885 | this.xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback); 886 | } else { 887 | var req = window.XMLHttpRequest ? new XMLHttpRequest() : // IE7+, Firefox, Chrome, Opera, Safari 888 | new ActiveXObject("Microsoft.XMLHTTP"); // IE6, IE5 889 | this.xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback); 890 | } 891 | } 892 | }, { 893 | key: "xdomainRequest", 894 | value: function xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback) { 895 | var _this10 = this; 896 | 897 | req.timeout = timeout; 898 | req.open(method, endPoint); 899 | req.onload = function () { 900 | var response = _this10.parseJSON(req.responseText); 901 | callback && callback(response); 902 | }; 903 | if (ontimeout) { 904 | req.ontimeout = ontimeout; 905 | } 906 | 907 | // Work around bug in IE9 that requires an attached onprogress handler 908 | req.onprogress = function () {}; 909 | 910 | req.send(body); 911 | } 912 | }, { 913 | key: "xhrRequest", 914 | value: function xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback) { 915 | var _this11 = this; 916 | 917 | req.timeout = timeout; 918 | req.open(method, endPoint, true); 919 | req.setRequestHeader("Content-Type", accept); 920 | req.onerror = function () { 921 | callback && callback(null); 922 | }; 923 | req.onreadystatechange = function () { 924 | if (req.readyState === _this11.states.complete && callback) { 925 | var response = _this11.parseJSON(req.responseText); 926 | callback(response); 927 | } 928 | }; 929 | if (ontimeout) { 930 | req.ontimeout = ontimeout; 931 | } 932 | 933 | req.send(body); 934 | } 935 | }, { 936 | key: "parseJSON", 937 | value: function parseJSON(resp) { 938 | return resp && resp !== "" ? JSON.parse(resp) : null; 939 | } 940 | }, { 941 | key: "serialize", 942 | value: function serialize(obj, parentKey) { 943 | var queryStr = []; 944 | for (var key in obj) { 945 | if (!obj.hasOwnProperty(key)) { 946 | continue; 947 | } 948 | var paramKey = parentKey ? parentKey + "[" + key + "]" : key; 949 | var paramVal = obj[key]; 950 | if ((typeof paramVal === "undefined" ? "undefined" : _typeof(paramVal)) === "object") { 951 | queryStr.push(this.serialize(paramVal, paramKey)); 952 | } else { 953 | queryStr.push(encodeURIComponent(paramKey) + "=" + encodeURIComponent(paramVal)); 954 | } 955 | } 956 | return queryStr.join("&"); 957 | } 958 | }, { 959 | key: "appendParams", 960 | value: function appendParams(url, params) { 961 | if (Object.keys(params).length === 0) { 962 | return url; 963 | } 964 | 965 | var prefix = url.match(/\?/) ? "&" : "?"; 966 | return "" + url + prefix + this.serialize(params); 967 | } 968 | }]); 969 | 970 | return Ajax; 971 | }(); 972 | 973 | Ajax.states = { complete: 4 }; 974 | 975 | // Creates a timer that accepts a `timerCalc` function to perform 976 | // calculated timeout retries, such as exponential backoff. 977 | // 978 | // ## Examples 979 | // 980 | // let reconnectTimer = new Timer(() => this.connect(), function(tries){ 981 | // return [1000, 5000, 10000][tries - 1] || 10000 982 | // }) 983 | // reconnectTimer.scheduleTimeout() // fires after 1000 984 | // reconnectTimer.scheduleTimeout() // fires after 5000 985 | // reconnectTimer.reset() 986 | // reconnectTimer.scheduleTimeout() // fires after 1000 987 | // 988 | 989 | var Timer = function () { 990 | function Timer(callback, timerCalc) { 991 | _classCallCheck(this, Timer); 992 | 993 | this.callback = callback; 994 | this.timerCalc = timerCalc; 995 | this.timer = null; 996 | this.tries = 0; 997 | } 998 | 999 | _createClass(Timer, [{ 1000 | key: "reset", 1001 | value: function reset() { 1002 | this.tries = 0; 1003 | clearTimeout(this.timer); 1004 | } 1005 | 1006 | // Cancels any previous scheduleTimeout and schedules callback 1007 | 1008 | }, { 1009 | key: "scheduleTimeout", 1010 | value: function scheduleTimeout() { 1011 | var _this12 = this; 1012 | 1013 | clearTimeout(this.timer); 1014 | 1015 | this.timer = setTimeout(function () { 1016 | _this12.tries = _this12.tries + 1; 1017 | _this12.callback(); 1018 | }, this.timerCalc(this.tries + 1)); 1019 | } 1020 | }]); 1021 | 1022 | return Timer; 1023 | }(); 1024 | 1025 | 1026 | })(typeof(exports) === "undefined" ? window.Phoenix = window.Phoenix || {} : exports); 1027 | --------------------------------------------------------------------------------