├── .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 |
--------------------------------------------------------------------------------
/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 |
4 |
5 |
6 | Name:
7 | <%= @user.name %>
8 |
9 |
10 |
11 | Email:
12 | <%= @user.email %>
13 |
14 |
15 |
16 | Encrypted password:
17 | <%= @user.encrypted_password %>
18 |
19 |
20 |
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 |
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 |
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 |
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 |
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 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/example_app/web/templates/user_management/_reset_password.html.eex:
--------------------------------------------------------------------------------
1 | Reset Password
2 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/boilerplate/login.html.eex:
--------------------------------------------------------------------------------
1 | Login
2 |
23 |
--------------------------------------------------------------------------------
/example_app/web/templates/addict/login.html.eex:
--------------------------------------------------------------------------------
1 | Login
2 |
23 |
--------------------------------------------------------------------------------
/example_app/web/templates/user_management/_login.html.eex:
--------------------------------------------------------------------------------
1 | Login
2 |
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 |
11 | Listing users
12 |
13 |
14 |
15 | Name
16 | Email
17 | Encrypted password
18 |
19 |
20 |
21 |
22 |
23 | <%= for user <- @users do %>
24 |
25 | <%= user.name %>
26 | <%= user.email %>
27 | <%= user.encrypted_password %>
28 |
29 |
30 | <%= link "Delete", to: user_management_path(@conn, :delete, user), method: :delete, data: [confirm: "Are you sure?"], class: "btn btn-danger btn-xs" %>
31 |
32 |
33 | <% end %>
34 |
35 |
36 |
37 | <%= link "Register user", to: "/register" %>
38 |
--------------------------------------------------------------------------------
/boilerplate/register.html.eex:
--------------------------------------------------------------------------------
1 | Register
2 |
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 |
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 |
19 |
20 |
<%= get_flash(@conn, :info) %>
21 |
<%= get_flash(@conn, :error) %>
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 |
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 |
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 | [](https://travis-ci.org/trenpixster/addict) [](https://hex.pm/packages/addict) [](https://hex.pm/packages/addict)
2 | [](https://beta.hexfaktor.org/github/trenpixster/addict)
3 | [](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 |
--------------------------------------------------------------------------------