├── rebar.lock ├── Procfile ├── assets ├── scss │ ├── utils │ │ ├── colors.scss │ │ ├── flex.scss │ │ └── spacing.scss │ └── app.scss ├── js │ ├── pages │ │ ├── individual.js │ │ └── collection.js │ ├── app.js │ ├── util │ │ └── bulma.js │ └── socket.js ├── static │ ├── files │ │ ├── favicon.ico │ │ ├── favicon-32x32.png │ │ └── robots.txt │ └── images │ │ └── phoenix.png ├── browsers.js ├── postcss.config.js ├── package.json └── webpack.config.js ├── lib ├── myapp_web │ ├── templates │ │ ├── layout │ │ │ ├── header.html.eex │ │ │ ├── notification.html.eex │ │ │ └── app.html.eex │ │ ├── user │ │ │ ├── edit.html.eex │ │ │ ├── new.html.eex │ │ │ ├── show.html.eex │ │ │ ├── index.html.eex │ │ │ └── form.html.eex │ │ ├── account │ │ │ ├── password_reset_get_email.html.eex │ │ │ ├── show_password_change.html.eex │ │ │ └── activation_status.html.eex │ │ ├── session │ │ │ └── new.html.eex │ │ └── page │ │ │ └── index.html.eex │ ├── views │ │ ├── page_view.ex │ │ ├── user_view.ex │ │ ├── account_view.ex │ │ ├── layout_view.ex │ │ ├── session_view.ex │ │ ├── error_view.ex │ │ └── error_helpers.ex │ ├── controllers │ │ ├── page_controller.ex │ │ ├── session_controller.ex │ │ ├── user_controller.ex │ │ └── account_controller.ex │ ├── auth │ │ ├── guardian_auth_error_handler.ex │ │ ├── guardian.ex │ │ ├── guardian_auth_pipeline.ex │ │ └── auth.ex │ ├── gettext.ex │ ├── channels │ │ └── user_socket.ex │ ├── router.ex │ └── endpoint.ex ├── myapp │ ├── mailer.ex │ ├── repo.ex │ ├── application.ex │ ├── email │ │ └── email.ex │ └── accounts │ │ ├── user.ex │ │ └── accounts.ex ├── myapp.ex └── myapp_web.ex ├── docs └── assets │ ├── home.png │ └── dev_email.png ├── test ├── test_helper.exs ├── myapp_web │ ├── views │ │ ├── page_view_test.exs │ │ ├── layout_view_test.exs │ │ └── error_view_test.exs │ └── controllers │ │ ├── page_controller_test.exs │ │ ├── session_controller_test.exs │ │ ├── user_controller_test.exs │ │ └── account_controller_test.exs ├── support │ ├── channel_case.ex │ ├── data_case.ex │ └── conn_case.ex └── myapp │ └── accounts │ └── accounts_test.exs ├── .formatter.exs ├── elixir_buildpack.config ├── compile ├── .editorconfig ├── priv ├── repo │ ├── migrations │ │ ├── 20170926023424_update_users_add_activation_attempts.exs │ │ ├── 20170926001658_update_users_add_activation.exs │ │ ├── 20171004010756_update_user_add_password_reset.exs │ │ └── 20170827220903_create_users.exs │ └── seeds.exs └── gettext │ ├── en │ └── LC_MESSAGES │ │ └── errors.po │ └── errors.pot ├── config ├── test.exs ├── config.exs ├── dev.exs └── prod.exs ├── .gitignore ├── .gitlab-ci.yml ├── mix.exs ├── README.md ├── .credo.exs └── mix.lock /rebar.lock: -------------------------------------------------------------------------------- 1 | []. 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: MIX_ENV=prod mix phoenix.server 2 | -------------------------------------------------------------------------------- /assets/scss/utils/colors.scss: -------------------------------------------------------------------------------- 1 | .text-grey { 2 | color: $grey; 3 | } 4 | -------------------------------------------------------------------------------- /assets/js/pages/individual.js: -------------------------------------------------------------------------------- 1 | console.log('I am a seperately built module!'); 2 | -------------------------------------------------------------------------------- /lib/myapp_web/templates/layout/header.html.eex: -------------------------------------------------------------------------------- 1 |
<%= @text %>
2 | -------------------------------------------------------------------------------- /lib/myapp/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Mailer do 2 | use Bamboo.Mailer, otp_app: :myapp 3 | end 4 | -------------------------------------------------------------------------------- /docs/assets/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awestbro/phoenix-starter/HEAD/docs/assets/home.png -------------------------------------------------------------------------------- /lib/myapp_web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.PageView do 2 | use MyAppWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/myapp_web/views/user_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.UserView do 2 | use MyAppWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual) 4 | -------------------------------------------------------------------------------- /lib/myapp_web/views/account_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.AccountView do 2 | use MyAppWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/myapp_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.LayoutView do 2 | use MyAppWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/myapp_web/views/session_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.SessionView do 2 | use MyAppWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"], 3 | line_length: 120 4 | ] 5 | -------------------------------------------------------------------------------- /docs/assets/dev_email.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awestbro/phoenix-starter/HEAD/docs/assets/dev_email.png -------------------------------------------------------------------------------- /assets/static/files/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awestbro/phoenix-starter/HEAD/assets/static/files/favicon.ico -------------------------------------------------------------------------------- /elixir_buildpack.config: -------------------------------------------------------------------------------- 1 | erlang_version=20.0 2 | elixir_version=1.5.1 3 | always_rebuild=false 4 | runtime_path=/app 5 | -------------------------------------------------------------------------------- /assets/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awestbro/phoenix-starter/HEAD/assets/static/images/phoenix.png -------------------------------------------------------------------------------- /test/myapp_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.PageViewTest do 2 | use MyAppWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /test/myapp_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.LayoutViewTest do 2 | use MyAppWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /assets/static/files/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awestbro/phoenix-starter/HEAD/assets/static/files/favicon-32x32.png -------------------------------------------------------------------------------- /assets/browsers.js: -------------------------------------------------------------------------------- 1 | // https://github.com/ai/browserslist 2 | module.exports = [ 3 | ">1%", 4 | "last 4 versions", 5 | "Firefox ESR", 6 | "not ie < 9", 7 | ]; 8 | -------------------------------------------------------------------------------- /compile: -------------------------------------------------------------------------------- 1 | yarn deploy 2 | 3 | cd $phoenix_dir 4 | 5 | mix "${phoenix_ex}.digest" 6 | 7 | if mix help "${phoenix_ex}.digest.clean" 1>/dev/null 2>&1; then 8 | mix "${phoenix_ex}.digest.clean" 9 | fi 10 | -------------------------------------------------------------------------------- /lib/myapp_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.PageController do 2 | use MyAppWeb, :controller 3 | 4 | def index(conn, _params) do 5 | conn 6 | |> render("index.html") 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/myapp_web/templates/user/edit.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 | <%= render MyAppWeb.LayoutView, "header.html", text: "Edit User" %> 4 | Nothing to do here yet 5 |
6 |
7 | -------------------------------------------------------------------------------- /assets/static/files/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 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | 10 | [*.{ex,eex,exs}] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /assets/scss/utils/flex.scss: -------------------------------------------------------------------------------- 1 | .flex-row { 2 | display: flex; 3 | flex-direction: row; 4 | 5 | &.justify-content-between { 6 | justify-content: space-between; 7 | } 8 | 9 | &.align-items-center { 10 | align-items: center; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/myapp_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.PageControllerTest do 2 | use MyAppWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, "/") 6 | assert html_response(conn, 200) =~ "MyApp" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/myapp_web/templates/user/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 | <%= render MyAppWeb.LayoutView, "header.html", text: "New User" %> 4 | <%= render "form.html", Map.put(assigns, :action, user_path(@conn, :create)) %> 5 |
6 |
7 | -------------------------------------------------------------------------------- /lib/myapp_web/auth/guardian_auth_error_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.Guardian.AuthErrorHandler do 2 | import Plug.Conn 3 | 4 | def auth_error(conn, {type, _reason}, _opts) do 5 | body = Poison.encode!(%{message: to_string(type)}) 6 | send_resp(conn, 401, body) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /assets/postcss.config.js: -------------------------------------------------------------------------------- 1 | const supportedBrowsers = require("./browsers"); 2 | 3 | module.exports = { 4 | plugins: [ 5 | require("autoprefixer")({ 6 | browsers: supportedBrowsers, 7 | flexbox: true, 8 | }), 9 | require("postcss-flexbugs-fixes"), 10 | ], 11 | } 12 | -------------------------------------------------------------------------------- /lib/myapp.ex: -------------------------------------------------------------------------------- 1 | defmodule MyApp do 2 | @moduledoc """ 3 | MyApp keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /assets/scss/app.scss: -------------------------------------------------------------------------------- 1 | /* This file is for your main application css. */ 2 | @import "~bulma/bulma.sass"; 3 | @import "utils/spacing.scss"; 4 | @import "utils/flex.scss"; 5 | @import "utils/colors.scss"; 6 | 7 | .section { 8 | padding: 1rem 1.5rem; 9 | } 10 | 11 | .navbar-item a { 12 | color: #4a4a4a; 13 | } 14 | -------------------------------------------------------------------------------- /lib/myapp/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Repo do 2 | use Ecto.Repo, otp_app: :myapp 3 | 4 | @doc """ 5 | Dynamically loads the repository url from the 6 | DATABASE_URL environment variable. 7 | """ 8 | def init(_, opts) do 9 | {:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))} 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20170926023424_update_users_add_activation_attempts.exs: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Repo.Migrations.UpdateUsersAddActivationAttempts do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :activation_attempts, :integer, default: 0, null: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20170926001658_update_users_add_activation.exs: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Repo.Migrations.UpdateUsersAddActivation do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :activation_token, :uuid 7 | add :activated, :boolean, default: false, null: false 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /priv/repo/migrations/20171004010756_update_user_add_password_reset.exs: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Repo.Migrations.UpdateUserAddPasswordReset do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :password_reset_token, :uuid 7 | add :last_password_reset_attempt, :naive_datetime 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /assets/js/pages/collection.js: -------------------------------------------------------------------------------- 1 | function searchInputFilterHandler(event) { 2 | const text = event.target.value; 3 | console.log("Search text change: ", text); 4 | } 5 | 6 | document.addEventListener('DOMContentLoaded',function() { 7 | console.log("Adding event thing"); 8 | document.querySelector('select[id="collection-filter"]').onchange = searchInputFilterHandler; 9 | },false); 10 | 11 | -------------------------------------------------------------------------------- /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 | # MyApp.Repo.insert!(%MyApp.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | import "phoenix" 2 | import "phoenix_html" 3 | import "./util/bulma" 4 | 5 | // Authentication 6 | 7 | let authenticated = false; 8 | let token; 9 | let header_string; 10 | 11 | const token_element = document.querySelector('meta[name="guardian_token"]'); 12 | 13 | if (token_element) { 14 | token = token_element.getAttribute('content'); 15 | header_string = `Bearer: ${token}`; 16 | authenticated = true; 17 | } 18 | -------------------------------------------------------------------------------- /lib/myapp_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.ErrorView do 2 | use MyAppWeb, :view 3 | 4 | def render("404.html", _assigns) do 5 | "Page not found" 6 | end 7 | 8 | def render("500.html", _assigns) do 9 | "Internal server 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 | -------------------------------------------------------------------------------- /priv/repo/migrations/20170827220903_create_users.exs: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Repo.Migrations.CreateUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add :username, :string 7 | add :email, :string 8 | add :password_hash, :string 9 | add :type, :string 10 | 11 | timestamps() 12 | end 13 | 14 | create unique_index(:users, [:username]) 15 | create unique_index(:users, [:email]) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/myapp_web/templates/user/show.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 | <%= render MyAppWeb.LayoutView, "header.html", text: "#{@user.username}" %> 4 | <%= if @current_user == @user do %> 5 |
6 | <%= link "Edit", to: user_path(@conn, :edit, @user) %> 7 | <%= link "Delete", to: user_path(@conn, :delete, @user), method: :delete, data: [confirm: "Are you sure?"], class: "btn btn-danger btn-xs" %> 8 |
9 | <% end %> 10 |
11 |
12 | -------------------------------------------------------------------------------- /lib/myapp_web/auth/guardian.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.Guardian do 2 | use Guardian, otp_app: :myapp 3 | alias MyApp.Accounts.User 4 | alias MyApp.Repo 5 | 6 | def subject_for_token(%User{} = user, _claims) do 7 | {:ok, "User:#{user.id}"} 8 | end 9 | 10 | def subject_for_token(_, _) do 11 | {:error, "Unknown resource type"} 12 | end 13 | 14 | def resource_from_claims(%{"sub" => "User:" <> id}) do 15 | {:ok, Repo.get(User, id)} 16 | end 17 | 18 | def resource_from_claims(_claims) do 19 | {:error, "Unknown resource type"} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/myapp_web/templates/account/password_reset_get_email.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 | <%= render MyAppWeb.LayoutView, "header.html", text: "Forgotten Password" %> 4 | <%= form_for @conn, account_path(@conn, :send_password_reset_email), [as: :email_params], fn f -> %> 5 |
6 | 7 | <%= text_input f, :email, placeholder: "Email", class: "input" %> 8 |
9 | <%= submit "Send Reset Password Email", class: "button is-primary" %> 10 | <% end %> 11 |
12 |
13 | -------------------------------------------------------------------------------- /lib/myapp_web/templates/layout/notification.html.eex: -------------------------------------------------------------------------------- 1 | <%= if get_flash(@conn, :info) != nil or get_flash(@conn, :error) != nil do %> 2 |
3 | <%= if get_flash(@conn, :info) do %> 4 |
5 | 6 | <%= get_flash(@conn, :info) %> 7 |
8 | <% end %> 9 | <%= if get_flash(@conn, :error) do %> 10 |
11 | 12 | <%= get_flash(@conn, :error) %> 13 |
14 | <% end %> 15 |
16 | <% end %> 17 | -------------------------------------------------------------------------------- /test/myapp_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.ErrorViewTest do 2 | use MyAppWeb.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(MyAppWeb.ErrorView, "404.html", []) == "Page not found" 9 | end 10 | 11 | test "render 500.html" do 12 | assert render_to_string(MyAppWeb.ErrorView, "500.html", []) == "Internal server error" 13 | end 14 | 15 | test "render any other" do 16 | assert render_to_string(MyAppWeb.ErrorView, "505.html", []) == "Internal server error" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/myapp_web/templates/user/index.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 | <%= render MyAppWeb.LayoutView, "header.html", text: "Listing Users" %> 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | <%= for user <- @users do %> 13 | 14 | 15 | 16 | 17 | <% end %> 18 | 19 |
UserActions
<%= user.username %><%= link "Show", to: user_path(@conn, :show, user.id) %>
20 |
21 |
22 | -------------------------------------------------------------------------------- /lib/myapp_web/auth/guardian_auth_pipeline.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.Guardian.AuthBrowserPipeline do 2 | @claims %{typ: "access"} 3 | 4 | use Guardian.Plug.Pipeline, 5 | otp_app: :myapp, 6 | module: MyAppWeb.Guardian, 7 | error_handler: MyAppWeb.Guardian.AuthErrorHandler 8 | 9 | plug(Guardian.Plug.VerifySession, claims: @claims) 10 | plug(Guardian.Plug.LoadResource, allow_blank: true) 11 | end 12 | 13 | defmodule MyAppWeb.Guardian.AuthApiPipeline do 14 | @claims %{typ: "access"} 15 | 16 | use Guardian.Plug.Pipeline, 17 | otp_app: :myapp, 18 | module: MyAppWeb.Guardian, 19 | error_handler: MyAppWeb.Guardian.AuthErrorHandler 20 | 21 | plug(Guardian.Plug.VerifyHeader, claims: @claims) 22 | plug(Guardian.Plug.LoadResource) 23 | end 24 | -------------------------------------------------------------------------------- /lib/myapp_web/templates/session/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 | <%= render MyAppWeb.LayoutView, "header.html", text: "Login" %> 4 | <%= form_for @conn, session_path(@conn, :create), [as: :session], fn f -> %> 5 |
6 | <%= text_input f, :username, placeholder: "Username", class: "input" %> 7 |
8 |
9 | <%= password_input f, :password, placeholder: "Password", class: "input" %> 10 |
11 | <%= submit "Log in", class: "button is-primary" %> 12 | <%= button("Forgot password?", to: account_path(@conn, :show_reset_password), method: "get", class: "button is-info is-outlined") %> 13 | <% end %> 14 |
15 |
16 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # General application configuration 4 | config :myapp, 5 | reset_password_interval_ms: 250 6 | 7 | # We don't run a server during test. If one is required, 8 | # you can enable the server option below. 9 | config :myapp, MyAppWeb.Endpoint, 10 | http: [port: 4001], 11 | server: false 12 | 13 | # Print only warnings and errors during test 14 | config :logger, level: :warn 15 | 16 | # Configure your database 17 | config :myapp, MyApp.Repo, 18 | adapter: Ecto.Adapters.Postgres, 19 | username: "postgres", 20 | password: "postgres", 21 | database: "myapp_test", 22 | hostname: if(System.get_env("CI"), do: "postgres", else: "localhost"), 23 | pool: Ecto.Adapters.SQL.Sandbox 24 | 25 | config :myapp, MyApp.Mailer, adapter: Bamboo.TestAdapter 26 | -------------------------------------------------------------------------------- /lib/myapp_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import MyAppWeb.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](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :myapp 24 | end 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | 7 | # Generated on crash by the VM 8 | erl_crash.dump 9 | 10 | # Generated on crash by NPM 11 | npm-debug.log 12 | 13 | # Static artifacts 14 | /assets/node_modules 15 | 16 | # Since we are building assets from assets/, 17 | # we ignore priv/static. You may want to comment 18 | # this depending on your deployment strategy. 19 | /priv/static/ 20 | 21 | # Files matching config/*.secret.exs pattern contain sensitive 22 | # data and you should not commit them into version control. 23 | # 24 | # Alternatively, you may comment the line below and commit the 25 | # secrets files as long as you replace their contents by environment 26 | # variables. 27 | /config/*.secret.exs 28 | .DS_Store 29 | .elixir_ls/ 30 | 31 | # Just use yarn 32 | assets/package-lock.json 33 | -------------------------------------------------------------------------------- /lib/myapp_web/controllers/session_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.SessionController do 2 | use MyAppWeb, :controller 3 | 4 | def new(conn, _) do 5 | render(conn, "new.html") 6 | end 7 | 8 | def create(conn, %{"session" => %{"username" => user, "password" => pass}}) do 9 | case MyAppWeb.Auth.login_by_username_and_pass(conn, user, pass, repo: MyApp.Repo) do 10 | {:ok, conn} -> 11 | conn 12 | |> put_flash(:info, "Welcome back!") 13 | |> redirect(to: page_path(conn, :index)) 14 | 15 | {:error, _reason, conn} -> 16 | conn 17 | |> put_flash(:error, "Invalid username/ password combination") 18 | |> render("new.html") 19 | end 20 | end 21 | 22 | def delete(conn, _) do 23 | conn 24 | |> MyAppWeb.Auth.logout() 25 | |> redirect(to: page_path(conn, :index)) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/myapp_web/templates/account/show_password_change.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 | <%= render MyAppWeb.LayoutView, "header.html", text: "Reset Password" %> 4 | <%= form_for @changeset, account_path(@conn, :reset_password, @user_id), [as: :reset_params], fn f -> %> 5 |
6 | 7 | <%= password_input f, :password, placeholder: "New Password", class: "input" %> 8 | <%= error_tag f, :password %> 9 |
10 |
11 | 12 | <%= password_input f, :password_confirmation, placeholder: "Password Confirmation", class: "input" %> 13 | <%= error_tag f, :password_confirmation %> 14 |
15 | <%= hidden_input f, :password_reset_token, value: @reset_token %> 16 | <%= submit "Submit", class: "button is-primary" %> 17 | <% end %> 18 |
19 |
20 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: elixir:1.5.1 2 | 3 | services: 4 | - postgres:9.6 5 | 6 | test: 7 | stage: test 8 | variables: 9 | MIX_ENV: "test" 10 | script: 11 | - apt-get update 12 | - apt-get install -y postgresql-client 13 | - mix local.hex --force 14 | - mix deps.get --only test 15 | - mix local.rebar --force 16 | - mix test 17 | 18 | deploy: 19 | stage: deploy 20 | script: 21 | - apt-get update 22 | - apt-get install apt-transport-https 23 | - apt-get install -y ruby ruby-dev rubygems-integration 24 | - echo "deb https://cli-assets.heroku.com/branches/stable/apt ./" > /etc/apt/sources.list.d/heroku.list 25 | - wget -O- https://cli-assets.heroku.com/apt/release.key | apt-key add - 26 | - apt-get update 27 | - apt-get install -y heroku 28 | - gem install dpl 29 | - dpl --provider=heroku --app=myapp --api-key=$HEROKU_API_KEY 30 | - heroku run "POOL_SIZE=2 mix ecto.migrate" --exit-code --app myapp 31 | only: 32 | - production 33 | -------------------------------------------------------------------------------- /lib/myapp_web/templates/user/form.html.eex: -------------------------------------------------------------------------------- 1 | <%= form_for @changeset, @action, fn f -> %> 2 |
3 | 4 | <%= text_input f, :email, placeholder: "Email", class: "input" %> 5 | <%= error_tag f, :email %> 6 |
7 |
8 | 9 | <%= text_input f, :username, placeholder: "Username", class: "input" %> 10 | <%= error_tag f, :username %> 11 |
12 |
13 | 14 | <%= password_input f, :password, placeholder: "Password", class: "input" %> 15 | <%= error_tag f, :password %> 16 |
17 |
18 | 19 | <%= password_input f, :password_confirmation, placeholder: "Password Confirmation", class: "input" %> 20 | <%= error_tag f, :password_confirmation %> 21 |
22 | <%= submit "Submit", class: "button is-primary" %> 23 | <% end %> 24 | -------------------------------------------------------------------------------- /lib/myapp_web/templates/account/activation_status.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 | <%= render MyAppWeb.LayoutView, "header.html", text: "Activation Status" %> 4 | <%= if @user.activated do %> 5 |
It looks like your account is already active!
6 | <% else %> 7 |
An activation email has been sent to: <%= @user.email %>
8 |
Please check your email for an activation link!
9 |
10 |
If you have not recieved an email, <%= link "click here", to: account_path(@conn, :resend_activation, @current_user.id)%> to resend the validation email.
11 |
12 |
If you are still having trouble, please contact support
13 | <% end %> 14 | <%= if Mix.env == :dev do %> 15 |
16 | DEV MODE: ">Check Emails 17 | <% end %> 18 |
19 | 20 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "license": "MIT", 4 | "scripts": { 5 | "deploy": "webpack --mode production -p", 6 | "watch": "webpack --mode development --watch-stdin --progress --color" 7 | }, 8 | "dependencies": { 9 | "bulma": "0.7.1", 10 | "phoenix": "^1.3.0", 11 | "phoenix_html": "file:../deps/phoenix_html" 12 | }, 13 | "devDependencies": { 14 | "autoprefixer": "^9.0.1", 15 | "babel-core": "^6.26.3", 16 | "babel-loader": "^7.1.5", 17 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 18 | "babel-polyfill": "^6.26.0", 19 | "babel-preset-env": "^1.7.0", 20 | "copy-webpack-plugin": "^4.5.2", 21 | "css-loader": "^1.0.0", 22 | "jquery": "^3.1.1", 23 | "mini-css-extract-plugin": "^0.4.1", 24 | "node-sass": "^4.9.2", 25 | "postcss": "^7.0.1", 26 | "postcss-flexbugs-fixes": "^4.0.0", 27 | "postcss-loader": "^2.1.6", 28 | "sass-loader": "^7.0.3", 29 | "webpack": "4.15.1", 30 | "webpack-cli": "^3.1.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.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 | import other functionality to make it easier 8 | to build common datastructures and query the data layer. 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 | # The default endpoint for testing 24 | @endpoint MyAppWeb.Endpoint 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(MyApp.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/myapp/application.ex: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Application do 2 | use Application 3 | 4 | # See https://hexdocs.pm/elixir/Application.html 5 | # for more information on OTP Applications 6 | def start(_type, _args) do 7 | import Supervisor.Spec 8 | 9 | # Define workers and child supervisors to be supervised 10 | children = [ 11 | # Start the Ecto repository 12 | supervisor(MyApp.Repo, []), 13 | # Start the endpoint when the application starts 14 | supervisor(MyAppWeb.Endpoint, []) 15 | # Start your own worker by calling: MyApp.Worker.start_link(arg1, arg2, arg3) 16 | # worker(MyApp.Worker, [arg1, arg2, arg3]), 17 | ] 18 | 19 | # See https://hexdocs.pm/elixir/Supervisor.html 20 | # for other strategies and supported options 21 | opts = [strategy: :one_for_one, name: MyApp.Supervisor] 22 | Supervisor.start_link(children, opts) 23 | end 24 | 25 | # Tell Phoenix to update the endpoint configuration 26 | # whenever the application is updated. 27 | def config_change(changed, _new, removed) do 28 | MyAppWeb.Endpoint.config_change(changed, removed) 29 | :ok 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/myapp_web/templates/page/index.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | Welcome to MyApp! 6 |

7 |

8 | Hit yourself right in the niche 9 |

10 |
11 |
12 |
13 | 14 |
15 |
16 |

Application colors

17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 |
27 |
28 | 29 |
30 |
31 | <%= if @current_user do %> 32 |

Pages

33 |
34 | <%= link "Users", to: user_path(@conn, :index) %> 35 |
36 | <% end %> 37 |
38 |
39 | 40 | -------------------------------------------------------------------------------- /lib/myapp_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | # channel "room:*", MyAppWeb.RoomChannel 6 | 7 | ## Transports 8 | transport(:websocket, Phoenix.Transports.WebSocket, timeout: 45_000) 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: "user_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 | # MyAppWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 34 | # 35 | # Returning `nil` makes this socket anonymous. 36 | def id(_socket), do: nil 37 | end 38 | -------------------------------------------------------------------------------- /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 | # General application configuration 9 | config :myapp, 10 | ecto_repos: [MyApp.Repo], 11 | email_activation_max: 5, 12 | # 20 minutes 13 | reset_password_interval_ms: 1_200_000 14 | 15 | # Configures the endpoint 16 | config :myapp, MyAppWeb.Endpoint, 17 | url: [host: "localhost"], 18 | secret_key_base: "/ZcINUEmABGYtbGFXtJ6mubK6RAkWggqu0w3zh1pOgxCZuYszDoSDLnYgoOjek67", 19 | render_errors: [view: MyAppWeb.ErrorView, accepts: ~w(html json)], 20 | pubsub: [name: MyApp.PubSub, adapter: Phoenix.PubSub.PG2] 21 | 22 | config :myapp, MyApp.Mailer, adapter: Bamboo.LocalAdapter 23 | 24 | # Configures Elixir's Logger 25 | config :logger, :console, 26 | format: "$time $metadata[$level] $message\n", 27 | metadata: [:request_id] 28 | 29 | config :myapp, MyAppWeb.Guardian, 30 | allower_algos: ["HS512"], 31 | verify_module: Guardian.JWT, 32 | issuer: "myapp", 33 | ttl: {30, :days}, 34 | allowed_drift: 2000, 35 | verify_issuer: true, 36 | secret_key: "AyyylmaoImASecrett" 37 | 38 | # Import environment specific config. This must remain at the bottom 39 | # of this file so it overrides the configuration defined above. 40 | import_config "#{Mix.env()}.exs" 41 | -------------------------------------------------------------------------------- /lib/myapp_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.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 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 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. 25 | # Ecto will pass the :count keyword if the error message is 26 | # meant to be pluralized. 27 | # On your own code and templates, depending on whether you 28 | # need the message to be pluralized or not, this could be 29 | # written simply as: 30 | # 31 | # dngettext "errors", "1 file", "%{count} files", count 32 | # dgettext "errors", "is invalid" 33 | # 34 | if count = opts[:count] do 35 | Gettext.dngettext(MyAppWeb.Gettext, "errors", msg, msg, count, opts) 36 | else 37 | Gettext.dgettext(MyAppWeb.Gettext, "errors", msg, opts) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /assets/js/util/bulma.js: -------------------------------------------------------------------------------- 1 | const initNavbar = () => { 2 | document.addEventListener('DOMContentLoaded', function () { 3 | // Get all "navbar-burger" elements 4 | const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0); 5 | // Check if there are any nav burgers 6 | if ($navbarBurgers.length > 0) { 7 | // Add a click event on each of them 8 | $navbarBurgers.forEach(function ($el) { 9 | $el.addEventListener('click', () => { 10 | // Get the target from the "data-target" attribute 11 | const target = $el.dataset.target; 12 | const $target = document.getElementById(target); 13 | // Toggle the class on both the "navbar-burger" and the "navbar-menu" 14 | $el.classList.toggle('is-active'); 15 | $target.classList.toggle('is-active'); 16 | }); 17 | }); 18 | } 19 | }); 20 | } 21 | 22 | const initNotificationListener = () => { 23 | // Notification message dismiss 24 | document.querySelectorAll('.notification-section .notification .delete').forEach((elem) => { 25 | elem.addEventListener('click', () => { 26 | console.log('click fire'); 27 | const article = elem.parentNode; 28 | const section = article.parentNode; 29 | section.removeChild(article); 30 | if (section.innerHTML.trim() === "") { 31 | section.parentElement.removeChild(section); 32 | } 33 | }) 34 | }); 35 | } 36 | 37 | initNavbar(); 38 | initNotificationListener(); 39 | -------------------------------------------------------------------------------- /lib/myapp/email/email.ex: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Email do 2 | import Bamboo.Email 3 | 4 | # Usage: MyApp.Email.welcome_email(conn, user) |> MyApp.Mailer.deliver_now 5 | def activation_email(conn, user) do 6 | activation_link = 7 | MyAppWeb.Router.Helpers.url(conn) <> 8 | MyAppWeb.Router.Helpers.account_path(conn, :activate, user.id, user.activation_token) 9 | 10 | base_email() 11 | |> to(user.email) 12 | |> subject("MyApp - Activate your account!") 13 | |> html_body(""" 14 |
15 |
Welcome to MyApp!
16 |
Activate your email!
17 |
18 | """) 19 | |> text_body("Welcome to MyApp! Activation link: #{activation_link}") 20 | end 21 | 22 | def reset_password_email(conn, user) do 23 | reset_link = 24 | MyAppWeb.Router.Helpers.url(conn) <> 25 | MyAppWeb.Router.Helpers.account_path(conn, :show_password_change, user.id, user.password_reset_token) 26 | 27 | base_email() 28 | |> to(user.email) 29 | |> subject("MyApp - Password reset") 30 | |> html_body(""" 31 |
32 |
Here's a link to reset your MyApp password!
33 |
Reset your password
34 |
35 | """) 36 | |> text_body("MyApp password reset link: #{reset_link}") 37 | end 38 | 39 | defp base_email do 40 | new_email() 41 | |> from("support@myapp.com") 42 | |> put_header("Reply-To", "support@myapp.com") 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule MyApp.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 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 MyApp.Repo 20 | 21 | import Ecto 22 | import Ecto.Changeset 23 | import Ecto.Query 24 | import MyApp.DataCase 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(MyApp.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | 38 | @doc """ 39 | A helper that transform changeset errors to a map of messages. 40 | 41 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 42 | assert "password is too short" in errors_on(changeset).password 43 | assert %{password: ["password is too short"]} = errors_on(changeset) 44 | 45 | """ 46 | def errors_on(changeset) do 47 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 48 | Enum.reduce(opts, message, fn {key, value}, acc -> 49 | String.replace(acc, "%{#{key}}", to_string(value)) 50 | end) 51 | end) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/myapp_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.Router do 2 | use MyAppWeb, :router 3 | 4 | pipeline :browser do 5 | plug(:accepts, ["html"]) 6 | plug(:fetch_session) 7 | plug(:fetch_flash) 8 | plug(:protect_from_forgery) 9 | plug(:put_secure_browser_headers) 10 | # plug Guardian.Plug.VerifySession 11 | # plug Guardian.Plug.LoadResource 12 | plug(MyAppWeb.Guardian.AuthBrowserPipeline) 13 | plug(MyAppWeb.Auth) 14 | end 15 | 16 | pipeline :api do 17 | plug(:accepts, ["json"]) 18 | # plug Guardian.Plug.VerifyHeader, realm: "Bearer" 19 | # plug Guardian.Plug.LoadResource 20 | plug(MyAppWeb.Guardian.AuthApiPipeline) 21 | plug(MyAppWeb.Auth) 22 | end 23 | 24 | scope "/", MyAppWeb do 25 | pipe_through(:browser) 26 | # Index 27 | get("/", PageController, :index) 28 | # Users/ Registration 29 | resources("/users", UserController) 30 | # Accounts 31 | get("/accounts/:id/activation", AccountController, :show_activation_status) 32 | get("/accounts/:id/activation/resend", AccountController, :resend_activation) 33 | get("/accounts/:id/activation/:activation_token", AccountController, :activate) 34 | get("/accounts/password/reset", AccountController, :show_reset_password) 35 | post("/accounts/password/reset", AccountController, :send_password_reset_email) 36 | get("/accounts/password/reset/:id/:reset_token", AccountController, :show_password_change) 37 | post("/accounts/password/reset/:id", AccountController, :reset_password) 38 | # Sessions 39 | resources("/sessions", SessionController, only: [:new, :create, :delete]) 40 | end 41 | 42 | if Mix.env() == :dev do 43 | forward("/sent_emails", Bamboo.SentEmailViewerPlug) 44 | end 45 | 46 | # Other scopes may use custom stacks. 47 | # scope "/api", MyAppWeb do 48 | # pipe_through :api 49 | # end 50 | end 51 | -------------------------------------------------------------------------------- /lib/myapp_web.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use MyAppWeb, :controller 9 | use MyAppWeb, :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. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: MyAppWeb 23 | import Plug.Conn 24 | import MyAppWeb.Router.Helpers 25 | import MyAppWeb.Gettext 26 | import MyAppWeb.Auth, only: [authenticate_user: 2] 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/myapp_web/templates", 34 | namespace: MyAppWeb 35 | 36 | # Import convenience functions from controllers 37 | import Phoenix.Controller, only: [get_flash: 2, view_module: 1] 38 | 39 | # Use all HTML functionality (forms, tags, etc) 40 | use Phoenix.HTML 41 | 42 | import MyAppWeb.Router.Helpers 43 | import MyAppWeb.ErrorHelpers 44 | import MyAppWeb.Gettext 45 | end 46 | end 47 | 48 | def router do 49 | quote do 50 | use Phoenix.Router 51 | import Plug.Conn 52 | import Phoenix.Controller 53 | end 54 | end 55 | 56 | def channel do 57 | quote do 58 | use Phoenix.Channel 59 | import MyAppWeb.Gettext 60 | end 61 | end 62 | 63 | @doc """ 64 | When used, dispatch to the appropriate controller/view/etc. 65 | """ 66 | defmacro __using__(which) when is_atom(which) do 67 | apply(__MODULE__, which, []) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/myapp_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :myapp 3 | 4 | socket("/socket", MyAppWeb.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, at: "/", from: :myapp, gzip: false, only: ~w(dist css fonts images js files favicon.ico robots.txt)) 11 | 12 | # Code reloading can be explicitly enabled under the 13 | # :code_reloader configuration of your endpoint. 14 | if code_reloading? do 15 | socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket) 16 | plug(Phoenix.LiveReloader) 17 | plug(Phoenix.CodeReloader) 18 | end 19 | 20 | plug(Plug.RequestId) 21 | plug(Plug.Logger) 22 | 23 | plug( 24 | Plug.Parsers, 25 | parsers: [:urlencoded, :multipart, :json], 26 | pass: ["*/*"], 27 | json_decoder: Poison 28 | ) 29 | 30 | plug(Plug.MethodOverride) 31 | plug(Plug.Head) 32 | 33 | # The session will be stored in the cookie and signed, 34 | # this means its contents can be read but not tampered with. 35 | # Set :encryption_salt if you would also like to encrypt it. 36 | plug( 37 | Plug.Session, 38 | store: :cookie, 39 | key: "_myapp_key", 40 | signing_salt: "dLdJRIo3" 41 | ) 42 | 43 | plug(MyAppWeb.Router) 44 | 45 | @doc """ 46 | Callback invoked for dynamically configuring the endpoint. 47 | 48 | It receives the endpoint configuration and checks if 49 | configuration should be loaded from the system environment. 50 | """ 51 | def init(_key, config) do 52 | if config[:load_from_system_env] do 53 | port = System.get_env("PORT") || raise "expected the PORT environment variable to be set" 54 | {:ok, Keyword.put(config, :http, [:inet6, port: port])} 55 | else 56 | {:ok, config} 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/myapp_web/controllers/session_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.SessionControllerTest do 2 | use MyAppWeb.ConnCase 3 | 4 | alias MyApp.Accounts.User 5 | 6 | def fixture(:user) do 7 | {:ok, user} = 8 | MyApp.Accounts.create_user(%{ 9 | email: "unittest@test.com", 10 | password: "test", 11 | password_confirmation: "test", 12 | password_hash: "aasdfasdf", 13 | type: User.types().user, 14 | username: "unittestuser" 15 | }) 16 | 17 | user 18 | end 19 | 20 | describe "new" do 21 | test "renders form", %{conn: conn} do 22 | conn = get(conn, session_path(conn, :new)) 23 | assert html_response(conn, 200) =~ "Login" 24 | end 25 | end 26 | 27 | describe "create" do 28 | setup [:create_user] 29 | 30 | test "logs in valid user", %{conn: conn} do 31 | conn = post(conn, session_path(conn, :create, %{"session" => %{username: "unittestuser", password: "test"}})) 32 | assert redirected_to(conn) == page_path(conn, :index) 33 | assert conn.assigns.current_user 34 | end 35 | 36 | test "rejects invalid credentials", %{conn: conn} do 37 | conn = post(conn, session_path(conn, :create, %{"session" => %{username: "unittestuser", password: "wrongpw"}})) 38 | assert html_response(conn, 200) =~ "Invalid" 39 | assert conn.assigns.current_user == nil 40 | end 41 | end 42 | 43 | describe "delete" do 44 | setup [:create_user] 45 | 46 | test "logs a user out", %{conn: conn, user: user} do 47 | conn = post(conn, session_path(conn, :create, %{"session" => %{username: "unittestuser", password: "test"}})) 48 | conn = delete(conn, session_path(conn, :delete, user)) 49 | assert redirected_to(conn) == page_path(conn, :index) 50 | assert conn.private.guardian_default_resource == nil 51 | end 52 | end 53 | 54 | defp create_user(_) do 55 | user = fixture(:user) 56 | {:ok, user: user} 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.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 | import other functionality to make it easier 8 | to build common datastructures and query the data layer. 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 | import MyAppWeb.Router.Helpers 23 | 24 | @claims %{typ: "access"} 25 | 26 | # The default endpoint for testing 27 | @endpoint MyAppWeb.Endpoint 28 | 29 | def authenticated_connection(user) do 30 | build_conn() 31 | |> bypass_through(MyApp.Router, [:browser]) 32 | |> get("/") 33 | |> Map.update!(:state, fn _ -> :set end) 34 | |> MyAppWeb.Guardian.Plug.sign_in(user, @claims) 35 | |> Plug.Conn.send_resp(200, "Flush the session") 36 | |> recycle 37 | end 38 | 39 | def authenticated_json_connection(user) do 40 | {:ok, jwt, _full_claims} = MyAppWeb.Guardian.encode_and_sign(user) 41 | 42 | build_conn() 43 | |> bypass_through(MyApp.Router, [:api]) 44 | |> get("/") 45 | |> Plug.Conn.send_resp(200, "Flush the session") 46 | |> recycle 47 | |> put_req_header("authorization", "Bearer #{jwt}") 48 | end 49 | end 50 | end 51 | 52 | setup tags do 53 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(MyApp.Repo) 54 | 55 | unless tags[:async] do 56 | Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, {:shared, self()}) 57 | end 58 | 59 | {:ok, conn: Phoenix.ConnTest.build_conn()} 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/myapp_web/auth/auth.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.Auth do 2 | @claims %{typ: "access"} 3 | 4 | import Plug.Conn 5 | import Phoenix.Controller 6 | import Comeonin.Bcrypt, only: [checkpw: 2, dummy_checkpw: 0] 7 | 8 | def init([]), do: false 9 | 10 | def call(conn, _opts) do 11 | current_user = MyAppWeb.Guardian.Plug.current_resource(conn) 12 | assign(conn, :current_user, current_user) 13 | end 14 | 15 | def get_auth_error_message(conn) do 16 | cond do 17 | conn.assigns.current_user == nil -> 18 | "You must be logged in to perform that action" 19 | 20 | conn.assigns.current_user.activated == false -> 21 | "You must activate your account to perform that action" 22 | 23 | true -> 24 | "You must be logged in to perform that action" 25 | end 26 | end 27 | 28 | def authenticate_user(conn, _opts) do 29 | if conn.assigns.current_user && conn.assigns.current_user.activated do 30 | conn 31 | else 32 | case conn.private[:phoenix_pipelines] do 33 | [:api] -> 34 | conn 35 | |> put_status(403) 36 | |> json(%{error: get_auth_error_message(conn)}) 37 | |> halt() 38 | 39 | [:browser] -> 40 | conn 41 | |> put_flash(:error, get_auth_error_message(conn)) 42 | |> redirect(to: MyAppWeb.Router.Helpers.page_path(conn, :index)) 43 | |> halt() 44 | end 45 | end 46 | end 47 | 48 | def login(conn, user) do 49 | conn 50 | |> MyAppWeb.Guardian.Plug.sign_in(user, @claims) 51 | |> assign(:current_user, user) 52 | end 53 | 54 | def login_by_username_and_pass(conn, username, given_pass, opts) do 55 | repo = Keyword.fetch!(opts, :repo) 56 | user = repo.get_by(MyApp.Accounts.User, username: username) 57 | 58 | cond do 59 | user && checkpw(given_pass, user.password_hash) -> 60 | {:ok, login(conn, user)} 61 | 62 | user -> 63 | {:error, :unauthorized, conn} 64 | 65 | true -> 66 | dummy_checkpw() 67 | {:error, :not_found, conn} 68 | end 69 | end 70 | 71 | def logout(conn) do 72 | MyAppWeb.Guardian.Plug.sign_out(conn) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 3 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 4 | const supportedBrowsers = require("./browsers"); 5 | 6 | const paths = { 7 | static: path.join(__dirname, "../priv/static"), 8 | build: path.join(__dirname, "../priv/static/dist"), 9 | node_modules: path.join(__dirname, "./node_modules"), 10 | src: path.join(__dirname, "./"), 11 | } 12 | const webpackConfig = { 13 | context: process.cwd(), 14 | entry: { 15 | 'app': ["babel-polyfill", path.join(paths.src, "js/app.js")], 16 | 'individual': [path.join(paths.src, "js/pages/individual.js")], 17 | 'css': path.join(paths.src, "scss/app.scss"), 18 | }, 19 | output: { 20 | path: paths.build, 21 | filename: "[name].js", 22 | }, 23 | resolve: { 24 | extensions: [".js", ".jsx"], 25 | symlinks: false, 26 | }, 27 | plugins: [ 28 | new MiniCssExtractPlugin({ 29 | filename: "app.css", 30 | }), 31 | new CopyWebpackPlugin([ 32 | { 33 | from: path.join(paths.src, 'static'), 34 | to: paths.static 35 | } 36 | ]) 37 | ], 38 | module: { 39 | rules: [ 40 | { 41 | test: /\.js$/, 42 | exclude: /(node_modules)/, 43 | use: { 44 | loader: "babel-loader", 45 | options: { 46 | presets: [ 47 | ["env", { 48 | targets: { 49 | browsers: supportedBrowsers, 50 | }, 51 | }], 52 | ], 53 | plugins: [ 54 | ["transform-object-rest-spread", { useBuiltIns: true }], 55 | ], 56 | }, 57 | }, 58 | }, 59 | { 60 | test: /\.scss$/, 61 | use: [ 62 | { 63 | loader: MiniCssExtractPlugin.loader, 64 | }, 65 | "css-loader?importLoaders=1&minimize&sourceMap&-autoprefixer", 66 | "postcss-loader", 67 | "sass-loader", 68 | ], 69 | }, 70 | 71 | ], 72 | }, 73 | devServer: { 74 | publicPath: "/", 75 | }, 76 | }; 77 | 78 | module.exports = webpackConfig; 79 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :myapp, 7 | version: "0.0.1", 8 | elixir: "~> 1.5", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: [:phoenix, :gettext] ++ Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | # Configuration for the OTP application. 18 | # 19 | # Type `mix help compile.app` for more information. 20 | def application do 21 | [ 22 | mod: {MyApp.Application, []}, 23 | extra_applications: [:logger, :runtime_tools] 24 | ] 25 | end 26 | 27 | # Specifies which paths to compile per environment. 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | # Specifies your project dependencies. 32 | # 33 | # Type `mix help deps` for examples and options. 34 | defp deps do 35 | [ 36 | {:bamboo, "~> 1.0.0"}, 37 | {:bcrypt_elixir, "~> 1.0.1"}, 38 | {:comeonin, "~> 4.1.1"}, 39 | {:cowboy, "~> 1.1.2"}, 40 | {:credo, "~> 0.9.3", only: [:dev], runtime: false}, 41 | {:gettext, "~> 0.15.0"}, 42 | {:guardian, "~> 1.1.0"}, 43 | {:httpoison, "~> 1.2.0"}, 44 | {:plug_cowboy, "~> 1.0"}, 45 | {:phoenix, "~> 1.3.3"}, 46 | {:phoenix_ecto, "~> 3.3.0"}, 47 | {:phoenix_html, "~> 2.11.2"}, 48 | {:phoenix_live_reload, "~> 1.1.5", only: :dev}, 49 | {:phoenix_pubsub, "~> 1.0"}, 50 | {:postgrex, "~> 0.13.5"}, 51 | {:pre_commit, "~> 0.3.4", only: :dev}, 52 | {:timex, "~> 3.3.0"}, 53 | {:uuid, "~> 1.1.7"} 54 | ] 55 | end 56 | 57 | # Aliases are shortcuts or tasks specific to the current project. 58 | # For example, to create, migrate and run the seeds file at once: 59 | # 60 | # $ mix ecto.setup 61 | # 62 | # See the documentation for `Mix` for more info on aliases. 63 | defp aliases do 64 | [ 65 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 66 | "ecto.reset": ["ecto.drop", "ecto.setup"], 67 | test: ["ecto.create --quiet", "ecto.migrate", "test"] 68 | ] 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /assets/js/socket.js: -------------------------------------------------------------------------------- 1 | // NOTE: The contents of this file will only be executed if 2 | // you uncomment its entry in "web/static/js/app.js". 3 | 4 | // To use Phoenix channels, the first step is to import Socket 5 | // and connect at the socket path in "lib/my_app/endpoint.ex": 6 | import {Socket} from "phoenix" 7 | 8 | let socket = new Socket("/socket", {params: {token: window.userToken}}) 9 | 10 | // When you connect, you'll often need to authenticate the client. 11 | // For example, imagine you have an authentication plug, `MyAuth`, 12 | // which authenticates the session and assigns a `:current_user`. 13 | // If the current user exists you can assign the user's token in 14 | // the connection for use in the layout. 15 | // 16 | // In your "web/router.ex": 17 | // 18 | // pipeline :browser do 19 | // ... 20 | // plug MyAuth 21 | // plug :put_user_token 22 | // end 23 | // 24 | // defp put_user_token(conn, _) do 25 | // if current_user = conn.assigns[:current_user] do 26 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id) 27 | // assign(conn, :user_token, token) 28 | // else 29 | // conn 30 | // end 31 | // end 32 | // 33 | // Now you need to pass this token to JavaScript. You can do so 34 | // inside a script tag in "web/templates/layout/app.html.eex": 35 | // 36 | // 37 | // 38 | // You will need to verify the user token in the "connect/2" function 39 | // in "web/channels/user_socket.ex": 40 | // 41 | // def connect(%{"token" => token}, socket) do 42 | // # max_age: 1209600 is equivalent to two weeks in seconds 43 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do 44 | // {:ok, user_id} -> 45 | // {:ok, assign(socket, :user, user_id)} 46 | // {:error, reason} -> 47 | // :error 48 | // end 49 | // end 50 | // 51 | // Finally, pass the token on connect as below. Or remove it 52 | // from connect if you don't care about authentication. 53 | 54 | socket.connect() 55 | 56 | // Now that you are connected, you can join channels with a topic: 57 | let channel = socket.channel("topic:subtopic", {}) 58 | channel.join() 59 | .receive("ok", resp => { console.log("Joined successfully", resp) }) 60 | .receive("error", resp => { console.log("Unable to join", resp) }) 61 | 62 | export default socket 63 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # General application configuration 4 | config :myapp, 5 | reset_password_interval_ms: 1000 6 | 7 | # For development, we disable any cache and enable 8 | # debugging and code reloading. 9 | # 10 | # The watchers configuration can be used to run external 11 | # watchers to your application. For example, we use it 12 | # with brunch.io to recompile .js and .css sources. 13 | config :myapp, MyAppWeb.Endpoint, 14 | http: [port: 4000], 15 | debug_errors: true, 16 | code_reloader: true, 17 | check_origin: false, 18 | watchers: [ 19 | node: [ 20 | "node_modules/webpack/bin/webpack.js", 21 | "--watch-stdin", 22 | "--progress", 23 | "--color", 24 | "--mode", 25 | "development", 26 | cd: Path.expand("../assets", __DIR__) 27 | ] 28 | ] 29 | 30 | # ## SSL Support 31 | # 32 | # In order to use HTTPS in development, a self-signed 33 | # certificate can be generated by running the following 34 | # command from your terminal: 35 | # 36 | # openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 -subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=www.example.com" -keyout priv/server.key -out priv/server.pem 37 | # 38 | # The `http:` config above can be replaced with: 39 | # 40 | # https: [port: 4000, keyfile: "priv/server.key", certfile: "priv/server.pem"], 41 | # 42 | # If desired, both `http:` and `https:` keys can be 43 | # configured to run both http and https servers on 44 | # different ports. 45 | 46 | # Watch static and templates for browser reloading. 47 | config :myapp, MyAppWeb.Endpoint, 48 | live_reload: [ 49 | patterns: [ 50 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, 51 | ~r{priv/gettext/.*(po)$}, 52 | ~r{lib/myapp_web/views/.*(ex)$}, 53 | ~r{lib/myapp_web/templates/.*(eex)$} 54 | ] 55 | ] 56 | 57 | # Do not include metadata nor timestamps in development logs 58 | config :logger, :console, format: "[$level] $message\n" 59 | 60 | # Set a higher stacktrace during development. Avoid configuring such 61 | # in production as building large stacktraces may be expensive. 62 | config :phoenix, :stacktrace_depth, 20 63 | 64 | # Configure your database 65 | config :myapp, MyApp.Repo, 66 | adapter: Ecto.Adapters.Postgres, 67 | username: "postgres", 68 | password: "postgres", 69 | database: "myapp_dev", 70 | hostname: "localhost", 71 | pool_size: 10 72 | 73 | # On commit, run the formatter 74 | config :pre_commit, 75 | commands: ["format"], 76 | verbose: true 77 | -------------------------------------------------------------------------------- /lib/myapp/accounts/user.ex: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Accounts.User do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | alias MyApp.Accounts.User 5 | 6 | schema "users" do 7 | field(:username, :string) 8 | field(:email, :string) 9 | field(:password, :string, virtual: true) 10 | field(:password_confirmation, :string, virtual: true) 11 | field(:password_hash, :string) 12 | field(:type, :string) 13 | field(:activation_token, Ecto.UUID) 14 | field(:activated, :boolean) 15 | field(:activation_attempts, :integer) 16 | field(:password_reset_token, Ecto.UUID) 17 | field(:last_password_reset_attempt, :naive_datetime) 18 | 19 | timestamps() 20 | end 21 | 22 | def types, 23 | do: %{ 24 | user: "user", 25 | user_paid: "user_paid", 26 | moderator: "moderator", 27 | admin: "admin" 28 | } 29 | 30 | @doc false 31 | def changeset(%User{} = user, attrs) do 32 | user 33 | |> cast(attrs, [ 34 | :username, 35 | :email, 36 | :type, 37 | :activation_token, 38 | :activated, 39 | :activation_attempts, 40 | :password_reset_token, 41 | :last_password_reset_attempt 42 | ]) 43 | |> validate_required([:username, :email, :type]) 44 | |> validate_inclusion(:type, Map.values(types())) 45 | |> validate_length(:username, min: 3, max: 30) 46 | |> validate_format(:email, ~r/([\w-\.]+)@((?:[\w]+\.)+)([a-zA-Z]{2,4})/, message: "Must have a valid email address") 47 | |> unique_constraint(:username) 48 | |> unique_constraint(:email) 49 | end 50 | 51 | def registration_changeset(model, params) do 52 | model 53 | |> cast(params, [:password, :password_confirmation]) 54 | |> validate_required(:password) 55 | |> validate_required(:password_confirmation) 56 | |> cast(params, [:password, :password_confirmation]) 57 | |> validate_length(:password, min: 4) 58 | |> validate_confirmation(:password) 59 | |> put_pass_hash() 60 | end 61 | 62 | def update_changeset(model, params) do 63 | model 64 | |> cast(params, []) 65 | end 66 | 67 | def change_password_changeset(model, params) do 68 | model 69 | |> registration_changeset(params) 70 | end 71 | 72 | def put_pass_hash(changeset) do 73 | case changeset do 74 | %Ecto.Changeset{valid?: true, changes: %{password: pass}} -> 75 | put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(pass)) 76 | 77 | _ -> 78 | changeset 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/myapp_web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <%= if MyAppWeb.Guardian.Plug.current_token(@conn) do %> 10 | 11 | <% end %> 12 | <%= assigns[:page_title] || "MyApp" %> 13 | "> 14 | " sizes="32x32" /> 15 | " sizes="16x16" /> 16 | 17 | 18 | 19 |
20 | 50 |
51 | 52 | <%= render MyAppWeb.LayoutView, "notification.html", assigns %> 53 | 54 | <%= render @view_module, @view_template, assigns %> 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_acceptance/3 26 | msgid "must be accepted" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_format/3 30 | msgid "has invalid format" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_subset/3 34 | msgid "has an invalid entry" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_exclusion/3 38 | msgid "is reserved" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.validate_confirmation/3 42 | msgid "does not match confirmation" 43 | msgstr "" 44 | 45 | ## From Ecto.Changeset.no_assoc_constraint/3 46 | msgid "is still associated with this entry" 47 | msgstr "" 48 | 49 | msgid "are still associated with this entry" 50 | msgstr "" 51 | 52 | ## From Ecto.Changeset.validate_length/3 53 | msgid "should be %{count} character(s)" 54 | msgid_plural "should be %{count} character(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should have %{count} item(s)" 59 | msgid_plural "should have %{count} item(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be at least %{count} character(s)" 64 | msgid_plural "should be at least %{count} character(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at least %{count} item(s)" 69 | msgid_plural "should have at least %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | msgid "should be at most %{count} character(s)" 74 | msgid_plural "should be at most %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should have at most %{count} item(s)" 79 | msgid_plural "should have at most %{count} item(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | ## From Ecto.Changeset.validate_number/3 84 | msgid "must be less than %{number}" 85 | msgstr "" 86 | 87 | msgid "must be greater than %{number}" 88 | msgstr "" 89 | 90 | msgid "must be less than or equal to %{number}" 91 | msgstr "" 92 | 93 | msgid "must be greater than or equal to %{number}" 94 | msgstr "" 95 | 96 | msgid "must be equal to %{number}" 97 | msgstr "" 98 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This file is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here as no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | ## From Ecto.Changeset.cast/4 12 | msgid "can't be blank" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.unique_constraint/3 16 | msgid "has already been taken" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.put_change/3 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_acceptance/3 24 | msgid "must be accepted" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_format/3 28 | msgid "has invalid format" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_subset/3 32 | msgid "has an invalid entry" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.validate_exclusion/3 36 | msgid "is reserved" 37 | msgstr "" 38 | 39 | ## From Ecto.Changeset.validate_confirmation/3 40 | msgid "does not match confirmation" 41 | msgstr "" 42 | 43 | ## From Ecto.Changeset.no_assoc_constraint/3 44 | msgid "is still associated with this entry" 45 | msgstr "" 46 | 47 | msgid "are still associated with this entry" 48 | msgstr "" 49 | 50 | ## From Ecto.Changeset.validate_length/3 51 | msgid "should be %{count} character(s)" 52 | msgid_plural "should be %{count} character(s)" 53 | msgstr[0] "" 54 | msgstr[1] "" 55 | 56 | msgid "should have %{count} item(s)" 57 | msgid_plural "should have %{count} item(s)" 58 | msgstr[0] "" 59 | msgstr[1] "" 60 | 61 | msgid "should be at least %{count} character(s)" 62 | msgid_plural "should be at least %{count} character(s)" 63 | msgstr[0] "" 64 | msgstr[1] "" 65 | 66 | msgid "should have at least %{count} item(s)" 67 | msgid_plural "should have at least %{count} item(s)" 68 | msgstr[0] "" 69 | msgstr[1] "" 70 | 71 | msgid "should be at most %{count} character(s)" 72 | msgid_plural "should be at most %{count} character(s)" 73 | msgstr[0] "" 74 | msgstr[1] "" 75 | 76 | msgid "should have at most %{count} item(s)" 77 | msgid_plural "should have at most %{count} item(s)" 78 | msgstr[0] "" 79 | msgstr[1] "" 80 | 81 | ## From Ecto.Changeset.validate_number/3 82 | msgid "must be less than %{number}" 83 | msgstr "" 84 | 85 | msgid "must be greater than %{number}" 86 | msgstr "" 87 | 88 | msgid "must be less than or equal to %{number}" 89 | msgstr "" 90 | 91 | msgid "must be greater than or equal to %{number}" 92 | msgstr "" 93 | 94 | msgid "must be equal to %{number}" 95 | msgstr "" 96 | -------------------------------------------------------------------------------- /test/myapp/accounts/accounts_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MyApp.AccountsTest do 2 | use MyApp.DataCase 3 | 4 | alias MyApp.Accounts 5 | 6 | describe "users" do 7 | alias MyApp.Accounts.User 8 | 9 | @valid_attrs %{ 10 | email: "some@email.com", 11 | password: "some password", 12 | password_confirmation: "some password", 13 | password_hash: "some password_hash", 14 | type: User.types().user, 15 | username: "some username" 16 | } 17 | @update_attrs %{ 18 | email: "some@updatedemail.com", 19 | password_hash: "some updated password_hash", 20 | type: User.types().admin, 21 | username: "some updated username" 22 | } 23 | @invalid_attrs %{email: nil, password_hash: nil, type: User.types().admin, username: nil} 24 | 25 | def user_fixture(attrs \\ %{}) do 26 | {:ok, user} = 27 | attrs 28 | |> Enum.into(@valid_attrs) 29 | |> Accounts.create_user() 30 | 31 | user 32 | end 33 | 34 | test "list_users/0 returns all users" do 35 | user = user_fixture() 36 | user_db = Accounts.get_user!(user.id) 37 | assert Accounts.list_users() == [user_db] 38 | end 39 | 40 | test "get_user!/1 returns the user with given id" do 41 | user = user_fixture() 42 | assert Accounts.get_user!(user.id).username == user.username 43 | end 44 | 45 | test "create_user/1 with valid data creates a user" do 46 | assert {:ok, %User{} = user} = Accounts.create_user(@valid_attrs) 47 | assert user.email == "some@email.com" 48 | assert user.password_hash != nil 49 | assert user.type == User.types().user 50 | assert user.username == "some username" 51 | end 52 | 53 | test "create_user/1 with invalid data returns error changeset" do 54 | assert {:error, %Ecto.Changeset{}} = Accounts.create_user(@invalid_attrs) 55 | end 56 | 57 | test "update_user/2 with valid data updates the user" do 58 | user = user_fixture() 59 | assert {:ok, user} = Accounts.update_user(user, @update_attrs) 60 | assert %User{} = user 61 | assert user.email == "some@updatedemail.com" 62 | assert user.type == User.types().admin 63 | assert user.username == "some updated username" 64 | end 65 | 66 | test "update_user/2 with invalid data returns error changeset" do 67 | user = user_fixture() 68 | assert {:error, %Ecto.Changeset{}} = Accounts.update_user(user, @invalid_attrs) 69 | assert user.username == Accounts.get_user!(user.id).username 70 | end 71 | 72 | test "delete_user/1 deletes the user" do 73 | user = user_fixture() 74 | assert {:ok, %User{}} = Accounts.delete_user(user) 75 | assert_raise Ecto.NoResultsError, fn -> Accounts.get_user!(user.id) end 76 | end 77 | 78 | test "change_user/1 returns a user changeset" do 79 | user = user_fixture() 80 | assert %Ecto.Changeset{} = Accounts.change_user(user) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For production, we often load configuration from external 4 | # sources, such as your system environment. For this reason, 5 | # you won't find the :http configuration below, but set inside 6 | # MyAppWeb.Endpoint.init/2 when load_from_system_env is 7 | # true. Any dynamic configuration should be done there. 8 | # 9 | # Don't forget to configure the url host to something meaningful, 10 | # Phoenix uses this information when generating URLs. 11 | # 12 | # Finally, we also include the path to a cache manifest 13 | # containing the digested version of static files. This 14 | # manifest is generated by the mix phx.digest task 15 | # which you typically run after static files are built. 16 | config :myapp, MyAppWeb.Endpoint, 17 | load_from_system_env: true, 18 | url: [ 19 | scheme: "https", 20 | host: "myapp.herokuapp.com", 21 | port: 443 22 | ], 23 | force_ssl: [ 24 | rewrite_on: [:x_forwarded_proto] 25 | ], 26 | cache_static_manifest: "priv/static/cache_manifest.json", 27 | secret_key_base: Map.fetch!(System.get_env(), "SECRET_KEY_BASE") 28 | 29 | config :myapp, MyApp.Repo, 30 | adapter: Ecto.Adapters.Postgres, 31 | url: System.get_env("DATABASE_URL"), 32 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 33 | ssl: true 34 | 35 | config :myapp, MyApp.Mailer, 36 | adapter: Bamboo.MailgunAdapter, 37 | api_key: System.get_env("MAILGUN_API_KEY"), 38 | domain: "mg.myapp.com" 39 | 40 | # Do not print debug messages in production 41 | config :logger, level: :info 42 | 43 | config :myapp, MyAppWeb.Guardian, 44 | allower_algos: ["HS512"], 45 | verify_module: Guardian.JWT, 46 | issuer: "myapp", 47 | ttl: {30, :days}, 48 | allowed_drift: 2000, 49 | verify_issuer: true, 50 | secret_key: System.get_env("GUARDIAN_SECRET") 51 | 52 | # ## SSL Support 53 | # 54 | # To get SSL working, you will need to add the `https` key 55 | # to the previous section and set your `:url` port to 443: 56 | # 57 | # config :myapp, MyAppWeb.Endpoint, 58 | # ... 59 | # url: [host: "example.com", port: 443], 60 | # https: [:inet6, 61 | # port: 443, 62 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 63 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")] 64 | # 65 | # Where those two env variables return an absolute path to 66 | # the key and cert in disk or a relative path inside priv, 67 | # for example "priv/ssl/server.key". 68 | # 69 | # We also recommend setting `force_ssl`, ensuring no data is 70 | # ever sent via http, always redirecting to https: 71 | # 72 | # config :myapp, MyAppWeb.Endpoint, 73 | # force_ssl: [hsts: true] 74 | # 75 | # Check `Plug.SSL` for all available options in `force_ssl`. 76 | 77 | # ## Using releases 78 | # 79 | # If you are doing OTP releases, you need to instruct Phoenix 80 | # to start the server for all endpoints: 81 | # 82 | # config :phoenix, :serve_endpoints, true 83 | # 84 | # Alternatively, you can configure exactly which server to 85 | # start per endpoint: 86 | # 87 | # config :myapp, MyAppWeb.Endpoint, server: true 88 | # 89 | 90 | # Finally import the config/prod.secret.exs 91 | # which should be versioned separately. 92 | # import_config "prod.secret.exs" 93 | -------------------------------------------------------------------------------- /lib/myapp_web/controllers/user_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.UserController do 2 | use MyAppWeb, :controller 3 | 4 | alias MyApp.Accounts 5 | alias MyApp.Accounts.User 6 | alias MyApp.Mailer 7 | alias MyApp.Email 8 | 9 | plug(:authenticate_user when action in [:edit, :update, :delete]) 10 | 11 | def index(conn, _params) do 12 | users = Accounts.list_users() 13 | render(conn, "index.html", users: users) 14 | end 15 | 16 | def new(conn, _params) do 17 | changeset = Accounts.change_user(%User{}) 18 | render(conn, "new.html", changeset: changeset) 19 | end 20 | 21 | def create(conn, %{"user" => user_params}) do 22 | new_params = 23 | user_params 24 | |> Map.take(["username", "email", "password", "password_confirmation"]) 25 | |> Map.merge(%{"type" => User.types().user, "activation_token" => UUID.uuid4(), "activated" => false}) 26 | 27 | case Accounts.create_user(new_params) do 28 | {:ok, user} -> 29 | conn 30 | |> Email.activation_email(user) 31 | |> Mailer.deliver_now() 32 | 33 | conn 34 | |> MyAppWeb.Auth.login(user) 35 | |> redirect(to: account_path(conn, :show_activation_status, user.id)) 36 | 37 | {:error, changeset} -> 38 | render(conn, "new.html", changeset: changeset) 39 | end 40 | end 41 | 42 | def show(conn, %{"id" => id}) do 43 | user = Accounts.get_user!(id) 44 | render(conn, "show.html", user: user) 45 | end 46 | 47 | def edit(conn, %{"id" => id}) do 48 | user = Accounts.get_user!(id) 49 | 50 | if can_modify_user?(conn, user) do 51 | changeset = Accounts.change_user(user) 52 | render(conn, "edit.html", user: user, changeset: changeset) 53 | else 54 | unauthorized(conn) 55 | end 56 | end 57 | 58 | def update(conn, %{"id" => id, "user" => user_params}) do 59 | user = Accounts.get_user!(id) 60 | 61 | if can_modify_user?(conn, user) do 62 | case Accounts.edit_user(user, user_params) do 63 | {:ok, user} -> 64 | conn 65 | |> put_flash(:info, "User updated successfully.") 66 | |> redirect(to: user_path(conn, :show, user)) 67 | 68 | {:error, %Ecto.Changeset{} = changeset} -> 69 | render(conn, "edit.html", user: user, changeset: changeset) 70 | end 71 | else 72 | unauthorized(conn) 73 | end 74 | end 75 | 76 | def delete(conn, %{"id" => id}) do 77 | user = Accounts.get_user!(id) 78 | 79 | if can_modify_user?(conn, user) do 80 | {:ok, _user} = Accounts.delete_user(user) 81 | 82 | conn 83 | |> put_flash(:info, "User deleted successfully.") 84 | |> redirect(to: user_path(conn, :index)) 85 | else 86 | unauthorized(conn) 87 | end 88 | end 89 | 90 | def unauthorized(conn) do 91 | conn 92 | |> put_flash(:error, "You are not authorized to do that!") 93 | |> redirect(to: page_path(conn, :index)) 94 | end 95 | 96 | def can_modify_user?(conn, user) do 97 | cond do 98 | conn.assigns.current_user.id == user.id -> 99 | true 100 | 101 | conn.assigns.current_user.type == User.types().admin -> 102 | true 103 | 104 | true -> 105 | false 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/myapp/accounts/accounts.ex: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Accounts do 2 | @moduledoc """ 3 | The Accounts context. 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | alias MyApp.Repo 8 | 9 | alias MyApp.Accounts.User 10 | 11 | @doc """ 12 | Returns the list of users. 13 | 14 | ## Examples 15 | 16 | iex> list_users() 17 | [%User{}, ...] 18 | 19 | """ 20 | def list_users do 21 | Repo.all(User) 22 | end 23 | 24 | @doc """ 25 | Gets a single user. 26 | 27 | Raises `Ecto.NoResultsError` if the User does not exist. 28 | 29 | ## Examples 30 | 31 | iex> get_user!(123) 32 | %User{} 33 | 34 | iex> get_user!(456) 35 | ** (Ecto.NoResultsError) 36 | 37 | """ 38 | def get_user!(id), do: Repo.get!(User, id) 39 | 40 | def get_user_by_email(email), do: Repo.get_by(User, email: email) 41 | def get_user_by_email!(email), do: Repo.get_by!(User, email: email) 42 | 43 | @doc """ 44 | Creates a user. 45 | 46 | ## Examples 47 | 48 | iex> create_user(%{field: value}) 49 | {:ok, %User{}} 50 | 51 | iex> create_user(%{field: bad_value}) 52 | {:error, %Ecto.Changeset{}} 53 | 54 | """ 55 | def create_user(attrs \\ %{}) do 56 | %User{} 57 | |> User.changeset(attrs) 58 | |> User.registration_changeset(attrs) 59 | |> Repo.insert() 60 | end 61 | 62 | @doc """ 63 | Changes a user's password 64 | 65 | ## Examples 66 | 67 | iex> change_password(user, %{field: value}) 68 | {:ok, %User{}} 69 | 70 | iex> change_password(user, %{field: bad_value}) 71 | {:error, %Ecto.Changeset{}} 72 | 73 | """ 74 | def change_user_password(user, attrs) do 75 | user 76 | |> User.change_password_changeset(attrs) 77 | |> Repo.update() 78 | end 79 | 80 | @doc """ 81 | Updates a user. 82 | 83 | ## Examples 84 | 85 | iex> update_user(user, %{field: new_value}) 86 | {:ok, %User{}} 87 | 88 | iex> update_user(user, %{field: bad_value}) 89 | {:error, %Ecto.Changeset{}} 90 | 91 | """ 92 | def update_user(%User{} = user, attrs) do 93 | user 94 | |> User.changeset(attrs) 95 | |> Repo.update() 96 | end 97 | 98 | @doc """ 99 | Updates a user. 100 | 101 | ## Examples 102 | 103 | iex> update_user!(user, %{field: new_value}) 104 | %User{} 105 | 106 | iex> update_user!(user, %{field: bad_value}) 107 | raise error 108 | 109 | """ 110 | def update_user!(%User{} = user, attrs) do 111 | user 112 | |> User.changeset(attrs) 113 | |> Repo.update!() 114 | end 115 | 116 | def edit_user(%User{} = user, attrs) do 117 | user 118 | |> User.update_changeset(attrs) 119 | |> Repo.update() 120 | end 121 | 122 | @doc """ 123 | Deletes a User. 124 | 125 | ## Examples 126 | 127 | iex> delete_user(user) 128 | {:ok, %User{}} 129 | 130 | iex> delete_user(user) 131 | {:error, %Ecto.Changeset{}} 132 | 133 | """ 134 | def delete_user(%User{} = user) do 135 | Repo.delete(user) 136 | end 137 | 138 | @doc """ 139 | Returns an `%Ecto.Changeset{}` for tracking user changes. 140 | 141 | ## Examples 142 | 143 | iex> change_user(user) 144 | %Ecto.Changeset{source: %User{}} 145 | 146 | """ 147 | def change_user(%User{} = user) do 148 | User.changeset(user, %{}) 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Phoenix Starter 2 | =============== 3 | 4 | ![Screenshot](docs/assets/home.png) 5 | 6 | 7 | Open source starter project for Phoenix framework. This is a slightly modified default Phoenix setup with the addition of webpack and yarn for frontend asset management rather than brunch. It also includes a basic user registration module covering common cases like user activation and forgotten passwords. 8 | 9 | ### Libraries included 10 | 11 | #### Elixir: 12 | - [Phoenix Framework](http://phoenixframework.org/) 13 | - [HTTPoison](https://github.com/edgurgel/httpoison) 14 | - [UUID](https://github.com/zyro/elixir-uuid) 15 | - [Timex](https://github.com/bitwalker/timex) 16 | - [Credo](https://github.com/rrrene/credo) 17 | - [Bamboo](https://github.com/thoughtbot/bamboo) 18 | - [Comeonin](https://github.com/riverrun/comeonin) 19 | - [Bcrypt Elixir](https://github.com/riverrun/bcrypt_elixir) 20 | - [Guardian](https://github.com/ueberauth/guardian) 21 | 22 | Front end: 23 | - [Bulma](http://bulma.io/) 24 | - [Yarn](https://yarnpkg.com/en/) 25 | - [Webpack](https://webpack.js.org/) 26 | - [Babel](https://babeljs.io/) 27 | - [PostCSS](https://github.com/postcss/postcss) 28 | - [SASS](http://sass-lang.com/) 29 | 30 | ### Variations 31 | 32 | The [turbolinks](https://github.com/awestbro/phoenix-starter/tree/turbolinks) branch contains a setup for turbolinks and stimiulusjs. 33 | 34 | ### Installation 35 | 36 | Tested with: 37 | - Erlang/OTP `21` 38 | - Elixir `1.6.6` 39 | - Yarn `1.7.0` 40 | 41 | To start your Phoenix server: 42 | 43 | * Install dependencies with `mix deps.get` 44 | * Create and migrate your database with `mix ecto.create && mix ecto.migrate` 45 | * Install Node.js dependencies with `cd assets && yarn install` 46 | * Start Phoenix endpoint with `mix phx.server` 47 | 48 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 49 | 50 | ### Make it your own! 51 | 52 | To make this app your own, do a case-sensitive find/ replace in this folder for MyApp => NewName and myapp => newname. You will also have to replace two folder structures under `lib/myapp*` and `test/myapp*` 53 | 54 | ### Development 55 | 56 | I love development in Elixir and Phoenix. Well thought out libraries help you iterate on ideas quickly and safely with beautiful helper functions. 57 | 58 | One of my favorite tools is the Bamboo development plugin which lets you send and view emails on a development server without actually sending the emails: 59 | 60 | ![Email workflow](docs/assets/dev_email.png) 61 | 62 | ### Deployment 63 | 64 | I personally use [Gitlab](https://gitlab.com/) for deploying personal private projects. Therefore there's an included `gitlab-ci.yml` file with an example of how to test/ build/ deploy in a CI/CD environment. If you're using CircleCI/ Travis/ whatever just copy install steps from there into their format. 65 | 66 | This project does rely on some environment variables for api keys etc. So you will need to supply your application with the following environment variables at runtime: 67 | 68 | - DATABASE_URL 69 | - GUARDIAN_SECRET 70 | - MAILGUN_API_KEY 71 | - POOL_SIZE 72 | - SECRET_KEY_BASE 73 | 74 | Check the `config/prod.exs` 75 | 76 | I use heroku at the moment for deployment, for other deployment options [check out phoenix deployment guides](http://www.phoenixframework.org/docs/deployment). 77 | 78 | ### Learn more about Phoenix 79 | 80 | * Official website: http://www.phoenixframework.org/ 81 | * Guides: http://phoenixframework.org/docs/overview 82 | * Docs: https://hexdocs.pm/phoenix 83 | * Mailing list: http://groups.google.com/group/phoenix-talk 84 | * Source: https://github.com/phoenixframework/phoenix 85 | -------------------------------------------------------------------------------- /test/myapp_web/controllers/user_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.UserControllerTest do 2 | use MyAppWeb.ConnCase 3 | use Bamboo.Test 4 | 5 | alias MyApp.Accounts 6 | alias MyApp.Accounts.User 7 | 8 | @create_attrs %{ 9 | email: "some@email.com", 10 | password_hash: "some password_hash", 11 | password: "test", 12 | password_confirmation: "test", 13 | type: User.types().user, 14 | username: "some username", 15 | activated: true 16 | } 17 | @invalid_attrs %{email: nil, password_hash: nil, type: User.types().user, username: nil} 18 | 19 | def fixture(:user) do 20 | {:ok, user} = Accounts.create_user(@create_attrs) 21 | user 22 | end 23 | 24 | describe "index" do 25 | test "lists all users", %{conn: conn} do 26 | conn = get(conn, user_path(conn, :index)) 27 | assert html_response(conn, 200) =~ "Listing Users" 28 | end 29 | end 30 | 31 | describe "new user" do 32 | test "renders form", %{conn: conn} do 33 | conn = get(conn, user_path(conn, :new)) 34 | assert html_response(conn, 200) =~ "New User" 35 | end 36 | end 37 | 38 | describe "create user" do 39 | test "redirects to activation status when data is valid", %{conn: conn} do 40 | conn = post(conn, user_path(conn, :create), user: @create_attrs) 41 | id = conn.assigns.current_user.id 42 | assert redirected_to(conn) == account_path(conn, :show_activation_status, id) 43 | end 44 | 45 | test "assigns activation token to user", %{conn: conn} do 46 | post(conn, user_path(conn, :create), user: @create_attrs) 47 | user = Accounts.get_user_by_email!(@create_attrs.email) 48 | assert user.activation_token != nil 49 | assert user.activated == false 50 | end 51 | 52 | # TODO: Reimplement in activation test 53 | test "attaches set-cookie to the response and assigns a user to the connection", %{conn: conn} do 54 | conn = post(conn, user_path(conn, :create), user: @create_attrs) 55 | assert conn.assigns.current_user 56 | assert conn.assigns.current_user.activated == false 57 | assert Map.has_key?(conn.resp_cookies, "_myapp_key") 58 | end 59 | 60 | test "sends an activation email", %{conn: conn} do 61 | conn = post(conn, user_path(conn, :create), user: @create_attrs) 62 | user = Accounts.get_user_by_email!(@create_attrs.email) 63 | assert_delivered_email(MyApp.Email.activation_email(conn, user)) 64 | end 65 | 66 | test "renders errors when data is invalid", %{conn: conn} do 67 | conn = post(conn, user_path(conn, :create), user: @invalid_attrs) 68 | assert html_response(conn, 200) =~ "New User" 69 | end 70 | 71 | test "renders errors when passwords do not match", %{conn: conn} do 72 | conn = 73 | post( 74 | conn, 75 | user_path(conn, :create), 76 | user: Map.merge(@create_attrs, %{password: "test", password_confirmation: "testwoops"}) 77 | ) 78 | 79 | assert html_response(conn, 200) =~ "New User" 80 | end 81 | end 82 | 83 | describe "edit user" do 84 | setup [:create_user] 85 | 86 | test "renders form for editing chosen user", %{user: user} do 87 | conn = authenticated_connection(user) 88 | conn = get(conn, user_path(conn, :edit, user)) 89 | assert html_response(conn, 200) =~ "Edit User" 90 | end 91 | end 92 | 93 | describe "delete user" do 94 | setup [:create_user] 95 | 96 | test "deletes chosen user", %{user: user} do 97 | conn = authenticated_connection(user) 98 | conn = delete(conn, user_path(conn, :delete, user)) 99 | assert redirected_to(conn) == user_path(conn, :index) 100 | 101 | assert_error_sent(404, fn -> 102 | get(conn, user_path(conn, :show, user)) 103 | end) 104 | end 105 | 106 | test "fails when conn is not the user", %{user: user} do 107 | {:ok, bad_user} = 108 | Accounts.create_user( 109 | Map.merge(@create_attrs, %{ 110 | email: "baduser@test.com", 111 | username: "ayy", 112 | password: "lmaooo", 113 | password_confirmation: "lmaooo" 114 | }) 115 | ) 116 | 117 | conn = authenticated_connection(bad_user) 118 | conn = delete(conn, user_path(conn, :delete, user)) 119 | assert redirected_to(conn) == page_path(conn, :index) 120 | assert Map.get(conn.private, :phoenix_flash) == %{"error" => "You are not authorized to do that!"} 121 | end 122 | end 123 | 124 | defp create_user(_) do 125 | user = fixture(:user) 126 | {:ok, user: user} 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/myapp_web/controllers/account_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.AccountController do 2 | use MyAppWeb, :controller 3 | 4 | alias MyApp.Accounts 5 | alias MyApp.Accounts.User 6 | alias MyApp.Mailer 7 | alias MyApp.Email 8 | 9 | def show_activation_status(conn, %{"id" => id}) do 10 | user = Accounts.get_user!(id) 11 | render(conn, "activation_status.html", user: user) 12 | end 13 | 14 | def resend_activation(conn, %{"id" => id}) do 15 | user = Accounts.get_user!(id) 16 | 17 | cond do 18 | user.activated -> 19 | conn 20 | |> put_flash(:error, "That account is already activated") 21 | |> redirect(to: page_path(conn, :index)) 22 | 23 | user.activation_attempts > Application.get_env(:myapp, :email_activation_max) -> 24 | conn 25 | |> put_flash( 26 | :error, 27 | "Activation email has been sent too many times. Please contact support to resolve this issue." 28 | ) 29 | |> redirect(to: account_path(conn, :show_activation_status, user.id)) 30 | 31 | !user.activated -> 32 | Accounts.update_user(user, %{activation_attempts: user.activation_attempts + 1}) 33 | 34 | conn 35 | |> Email.activation_email(user) 36 | |> Mailer.deliver_now() 37 | 38 | conn 39 | |> put_flash(:info, "Activation email sent!") 40 | |> redirect(to: account_path(conn, :show_activation_status, user.id)) 41 | 42 | true -> 43 | conn 44 | |> redirect(to: page_path(conn, :index)) 45 | end 46 | end 47 | 48 | def activate(conn, %{"id" => id, "activation_token" => activation_token}) do 49 | user = Accounts.get_user!(id) 50 | 51 | if user.activation_token != activation_token do 52 | conn 53 | |> put_flash(:error, "Sorry! We couldn't activate your account with that information") 54 | |> redirect(to: account_path(conn, :show_activation_status, user.id)) 55 | else 56 | Accounts.update_user(user, %{activation_token: nil, activated: true}) 57 | user = Accounts.get_user!(id) 58 | 59 | conn 60 | |> MyAppWeb.Auth.login(user) 61 | |> put_flash(:info, "Welcome to MyApp!") 62 | |> redirect(to: page_path(conn, :index)) 63 | end 64 | end 65 | 66 | def show_reset_password(conn, _params) do 67 | conn 68 | |> render("password_reset_get_email.html") 69 | end 70 | 71 | defp can_reset_password(nil), do: true 72 | 73 | defp can_reset_password(time) do 74 | duration = -Application.get_env(:myapp, :reset_password_interval_ms) 75 | Timex.before?(time, Timex.to_naive_datetime(Timex.shift(Timex.now(), milliseconds: duration))) 76 | end 77 | 78 | def send_password_reset_email(conn, %{"email_params" => %{"email" => email}}) do 79 | user = Accounts.get_user_by_email(email) 80 | 81 | cond do 82 | user && can_reset_password(user.last_password_reset_attempt) -> 83 | user = 84 | Accounts.update_user!(user, %{ 85 | password_reset_token: UUID.uuid4(), 86 | last_password_reset_attempt: NaiveDateTime.utc_now() 87 | }) 88 | 89 | conn 90 | |> MyApp.Email.reset_password_email(user) 91 | |> MyApp.Mailer.deliver_now() 92 | 93 | conn 94 | |> put_flash(:info, "Password reset email sent!") 95 | |> redirect(to: page_path(conn, :index)) 96 | 97 | user -> 98 | conn 99 | |> put_flash( 100 | :error, 101 | "Password reset email was recently sent. Please check your email or contact support if you are experiencing issues" 102 | ) 103 | |> redirect(to: account_path(conn, :show_reset_password)) 104 | 105 | true -> 106 | conn 107 | |> put_flash(:error, "Could not find an account associated with that email") 108 | |> redirect(to: account_path(conn, :show_reset_password)) 109 | end 110 | end 111 | 112 | def show_password_change(conn, %{"id" => id, "reset_token" => reset_token}) do 113 | user = Accounts.get_user!(id) 114 | 115 | if reset_token == user.password_reset_token do 116 | changeset = Accounts.change_user(%User{}) 117 | 118 | conn 119 | |> render("show_password_change.html", changeset: changeset, reset_token: reset_token, user_id: id) 120 | else 121 | conn 122 | |> put_flash( 123 | :error, 124 | "The password reset token you provided does not match our records. Please contact support if you feel like this is an error" 125 | ) 126 | |> redirect(to: page_path(conn, :index)) 127 | end 128 | end 129 | 130 | def reset_password(conn, %{"id" => id, "reset_params" => reset_params}) do 131 | user = Accounts.get_user!(id) 132 | reset_token = reset_params["password_reset_token"] 133 | 134 | cond do 135 | user.password_reset_token == nil -> 136 | conn 137 | |> put_flash( 138 | :error, 139 | "The password reset token you provided does not match our records. Please contact support if you feel like this is an error" 140 | ) 141 | |> redirect(to: page_path(conn, :index)) 142 | 143 | reset_token == user.password_reset_token -> 144 | case Accounts.change_user_password(user, reset_params) do 145 | {:ok, _} -> 146 | Accounts.update_user!(user, %{password_reset_token: nil, last_password_reset_attempt: nil}) 147 | 148 | conn 149 | |> put_flash(:info, "Password successfully changed!") 150 | |> redirect(to: page_path(conn, :index)) 151 | 152 | {:error, changeset} -> 153 | conn 154 | |> render("show_password_change.html", changeset: changeset, reset_token: reset_token, user_id: id) 155 | end 156 | 157 | true -> 158 | conn 159 | |> put_flash( 160 | :error, 161 | "The password reset token you provided does not match our records. Please contact support if you feel like this is an error" 162 | ) 163 | |> redirect(to: page_path(conn, :index)) 164 | end 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | name: "default", 16 | # 17 | # These are the files included in the analysis: 18 | files: %{ 19 | # 20 | # You can give explicit globs or simply directories. 21 | # In the latter case `**/*.{ex,exs}` will be used. 22 | included: ["lib/", "web/"], 23 | excluded: [~r"/_build/", ~r"/deps/"] 24 | }, 25 | # 26 | # If you create your own checks, you must specify the source files for 27 | # them here, so they can be loaded by Credo before running the analysis. 28 | requires: [], 29 | # 30 | # Credo automatically checks for updates, like e.g. Hex does. 31 | # You can disable this behaviour below: 32 | check_for_updates: true, 33 | # 34 | # If you want to enforce a style guide and need a more traditional linting 35 | # experience, you can change `strict` to `true` below: 36 | strict: false, 37 | # 38 | # If you want to use uncolored output by default, you can change `color` 39 | # to `false` below: 40 | color: true, 41 | # 42 | # You can customize the parameters of any check by adding a second element 43 | # to the tuple. 44 | # 45 | # To disable a check put `false` as second element: 46 | # 47 | # {Credo.Check.Design.DuplicatedCode, false} 48 | # 49 | checks: [ 50 | {Credo.Check.Consistency.ExceptionNames}, 51 | {Credo.Check.Consistency.LineEndings}, 52 | {Credo.Check.Consistency.ParameterPatternMatching}, 53 | {Credo.Check.Consistency.SpaceAroundOperators}, 54 | {Credo.Check.Consistency.SpaceInParentheses}, 55 | {Credo.Check.Consistency.TabsOrSpaces}, 56 | 57 | # For some checks, like AliasUsage, you can only customize the priority 58 | # Priority values are: `low, normal, high, higher` 59 | {Credo.Check.Design.AliasUsage, priority: :low}, 60 | 61 | # For others you can set parameters 62 | 63 | # If you don't want the `setup` and `test` macro calls in ExUnit tests 64 | # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just 65 | # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. 66 | {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, 67 | 68 | # You can also customize the exit_status of each check. 69 | # If you don't want TODO comments to cause `mix credo` to fail, just 70 | # set this value to 0 (zero). 71 | {Credo.Check.Design.TagTODO, exit_status: 2}, 72 | {Credo.Check.Design.TagFIXME}, 73 | 74 | {Credo.Check.Readability.FunctionNames}, 75 | {Credo.Check.Readability.LargeNumbers}, 76 | {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 80}, 77 | {Credo.Check.Readability.ModuleAttributeNames}, 78 | {Credo.Check.Readability.ModuleDoc, false}, 79 | {Credo.Check.Readability.ModuleNames}, 80 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, 81 | {Credo.Check.Readability.ParenthesesInCondition}, 82 | {Credo.Check.Readability.PredicateFunctionNames}, 83 | {Credo.Check.Readability.PreferImplicitTry}, 84 | {Credo.Check.Readability.RedundantBlankLines}, 85 | {Credo.Check.Readability.StringSigils}, 86 | {Credo.Check.Readability.TrailingBlankLine}, 87 | {Credo.Check.Readability.TrailingWhiteSpace}, 88 | {Credo.Check.Readability.VariableNames}, 89 | {Credo.Check.Readability.Semicolons}, 90 | {Credo.Check.Readability.SpaceAfterCommas}, 91 | 92 | {Credo.Check.Refactor.DoubleBooleanNegation}, 93 | {Credo.Check.Refactor.CondStatements}, 94 | {Credo.Check.Refactor.CyclomaticComplexity}, 95 | {Credo.Check.Refactor.FunctionArity}, 96 | {Credo.Check.Refactor.MatchInCondition}, 97 | {Credo.Check.Refactor.NegatedConditionsInUnless}, 98 | {Credo.Check.Refactor.NegatedConditionsWithElse}, 99 | {Credo.Check.Refactor.Nesting}, 100 | {Credo.Check.Refactor.PipeChainStart}, 101 | {Credo.Check.Refactor.UnlessWithElse}, 102 | 103 | {Credo.Check.Warning.BoolOperationOnSameValues}, 104 | {Credo.Check.Warning.IExPry}, 105 | {Credo.Check.Warning.IoInspect}, 106 | {Credo.Check.Warning.LazyLogging}, 107 | {Credo.Check.Warning.OperationOnSameValues}, 108 | {Credo.Check.Warning.OperationWithConstantResult}, 109 | {Credo.Check.Warning.UnusedEnumOperation}, 110 | {Credo.Check.Warning.UnusedFileOperation}, 111 | {Credo.Check.Warning.UnusedKeywordOperation}, 112 | {Credo.Check.Warning.UnusedListOperation}, 113 | {Credo.Check.Warning.UnusedPathOperation}, 114 | {Credo.Check.Warning.UnusedRegexOperation}, 115 | {Credo.Check.Warning.UnusedStringOperation}, 116 | {Credo.Check.Warning.UnusedTupleOperation}, 117 | 118 | # Controversial and experimental checks (opt-in, just remove `, false`) 119 | # 120 | {Credo.Check.Refactor.ABCSize, false}, 121 | {Credo.Check.Refactor.AppendSingleItem, false}, 122 | {Credo.Check.Refactor.VariableRebinding, false}, 123 | {Credo.Check.Warning.MapGetUnsafePass, false}, 124 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 125 | 126 | # Deprecated checks (these will be deleted after a grace period) 127 | {Credo.Check.Readability.Specs, false}, 128 | {Credo.Check.Warning.NameRedeclarationByAssignment, false}, 129 | {Credo.Check.Warning.NameRedeclarationByCase, false}, 130 | {Credo.Check.Warning.NameRedeclarationByDef, false}, 131 | {Credo.Check.Warning.NameRedeclarationByFn, false}, 132 | 133 | # Custom checks can be created using `mix credo.gen.check`. 134 | # 135 | ] 136 | } 137 | ] 138 | } 139 | -------------------------------------------------------------------------------- /assets/scss/utils/spacing.scss: -------------------------------------------------------------------------------- 1 | /* Stolen from https://github.com/tachyons-css/tachyons/blob/master/src/_spacing.css */ 2 | 3 | // Converted Variables 4 | 5 | $spacing-none: 0 !default; 6 | $spacing-extra-small: .25rem !default; 7 | $spacing-small: .5rem !default; 8 | $spacing-medium: 1rem !default; 9 | $spacing-large: 2rem !default; 10 | $spacing-extra-large: 4rem !default; 11 | $spacing-extra-extra-large: 8rem !default; 12 | $spacing-extra-extra-extra-large: 16rem !default; 13 | 14 | // Custom Media Query Variables 15 | 16 | 17 | /* Variables */ 18 | 19 | /* 20 | SPACING 21 | Docs: http://tachyons.io/docs/layout/spacing/ 22 | 23 | An eight step powers of two scale ranging from 0 to 16rem. 24 | 25 | Base: 26 | p = padding 27 | m = margin 28 | 29 | Modifiers: 30 | a = all 31 | h = horizontal 32 | v = vertical 33 | t = top 34 | r = right 35 | b = bottom 36 | l = left 37 | 38 | 0 = none 39 | 1 = 1st step in spacing scale 40 | 2 = 2nd step in spacing scale 41 | 3 = 3rd step in spacing scale 42 | 4 = 4th step in spacing scale 43 | 5 = 5th step in spacing scale 44 | 6 = 6th step in spacing scale 45 | 7 = 7th step in spacing scale 46 | 47 | Media Query Extensions: 48 | -ns = not-small 49 | -m = medium 50 | -l = large 51 | 52 | */ 53 | 54 | 55 | .pa0 { padding: $spacing-none; } 56 | .pa1 { padding: $spacing-extra-small; } 57 | .pa2 { padding: $spacing-small; } 58 | .pa3 { padding: $spacing-medium; } 59 | .pa4 { padding: $spacing-large; } 60 | .pa5 { padding: $spacing-extra-large; } 61 | .pa6 { padding: $spacing-extra-extra-large; } 62 | .pa7 { padding: $spacing-extra-extra-extra-large; } 63 | 64 | .pl0 { padding-left: $spacing-none; } 65 | .pl1 { padding-left: $spacing-extra-small; } 66 | .pl2 { padding-left: $spacing-small; } 67 | .pl3 { padding-left: $spacing-medium; } 68 | .pl4 { padding-left: $spacing-large; } 69 | .pl5 { padding-left: $spacing-extra-large; } 70 | .pl6 { padding-left: $spacing-extra-extra-large; } 71 | .pl7 { padding-left: $spacing-extra-extra-extra-large; } 72 | 73 | .pr0 { padding-right: $spacing-none; } 74 | .pr1 { padding-right: $spacing-extra-small; } 75 | .pr2 { padding-right: $spacing-small; } 76 | .pr3 { padding-right: $spacing-medium; } 77 | .pr4 { padding-right: $spacing-large; } 78 | .pr5 { padding-right: $spacing-extra-large; } 79 | .pr6 { padding-right: $spacing-extra-extra-large; } 80 | .pr7 { padding-right: $spacing-extra-extra-extra-large; } 81 | 82 | .pb0 { padding-bottom: $spacing-none; } 83 | .pb1 { padding-bottom: $spacing-extra-small; } 84 | .pb2 { padding-bottom: $spacing-small; } 85 | .pb3 { padding-bottom: $spacing-medium; } 86 | .pb4 { padding-bottom: $spacing-large; } 87 | .pb5 { padding-bottom: $spacing-extra-large; } 88 | .pb6 { padding-bottom: $spacing-extra-extra-large; } 89 | .pb7 { padding-bottom: $spacing-extra-extra-extra-large; } 90 | 91 | .pt0 { padding-top: $spacing-none; } 92 | .pt1 { padding-top: $spacing-extra-small; } 93 | .pt2 { padding-top: $spacing-small; } 94 | .pt3 { padding-top: $spacing-medium; } 95 | .pt4 { padding-top: $spacing-large; } 96 | .pt5 { padding-top: $spacing-extra-large; } 97 | .pt6 { padding-top: $spacing-extra-extra-large; } 98 | .pt7 { padding-top: $spacing-extra-extra-extra-large; } 99 | 100 | .pv0 { 101 | padding-top: $spacing-none; 102 | padding-bottom: $spacing-none; 103 | } 104 | .pv1 { 105 | padding-top: $spacing-extra-small; 106 | padding-bottom: $spacing-extra-small; 107 | } 108 | .pv2 { 109 | padding-top: $spacing-small; 110 | padding-bottom: $spacing-small; 111 | } 112 | .pv3 { 113 | padding-top: $spacing-medium; 114 | padding-bottom: $spacing-medium; 115 | } 116 | .pv4 { 117 | padding-top: $spacing-large; 118 | padding-bottom: $spacing-large; 119 | } 120 | .pv5 { 121 | padding-top: $spacing-extra-large; 122 | padding-bottom: $spacing-extra-large; 123 | } 124 | .pv6 { 125 | padding-top: $spacing-extra-extra-large; 126 | padding-bottom: $spacing-extra-extra-large; 127 | } 128 | 129 | .pv7 { 130 | padding-top: $spacing-extra-extra-extra-large; 131 | padding-bottom: $spacing-extra-extra-extra-large; 132 | } 133 | 134 | .ph0 { 135 | padding-left: $spacing-none; 136 | padding-right: $spacing-none; 137 | } 138 | 139 | .ph1 { 140 | padding-left: $spacing-extra-small; 141 | padding-right: $spacing-extra-small; 142 | } 143 | 144 | .ph2 { 145 | padding-left: $spacing-small; 146 | padding-right: $spacing-small; 147 | } 148 | 149 | .ph3 { 150 | padding-left: $spacing-medium; 151 | padding-right: $spacing-medium; 152 | } 153 | 154 | .ph4 { 155 | padding-left: $spacing-large; 156 | padding-right: $spacing-large; 157 | } 158 | 159 | .ph5 { 160 | padding-left: $spacing-extra-large; 161 | padding-right: $spacing-extra-large; 162 | } 163 | 164 | .ph6 { 165 | padding-left: $spacing-extra-extra-large; 166 | padding-right: $spacing-extra-extra-large; 167 | } 168 | 169 | .ph7 { 170 | padding-left: $spacing-extra-extra-extra-large; 171 | padding-right: $spacing-extra-extra-extra-large; 172 | } 173 | 174 | .ma0 { margin: $spacing-none; } 175 | .ma1 { margin: $spacing-extra-small; } 176 | .ma2 { margin: $spacing-small; } 177 | .ma3 { margin: $spacing-medium; } 178 | .ma4 { margin: $spacing-large; } 179 | .ma5 { margin: $spacing-extra-large; } 180 | .ma6 { margin: $spacing-extra-extra-large; } 181 | .ma7 { margin: $spacing-extra-extra-extra-large; } 182 | 183 | .ml0 { margin-left: $spacing-none; } 184 | .ml1 { margin-left: $spacing-extra-small; } 185 | .ml2 { margin-left: $spacing-small; } 186 | .ml3 { margin-left: $spacing-medium; } 187 | .ml4 { margin-left: $spacing-large; } 188 | .ml5 { margin-left: $spacing-extra-large; } 189 | .ml6 { margin-left: $spacing-extra-extra-large; } 190 | .ml7 { margin-left: $spacing-extra-extra-extra-large; } 191 | 192 | .mr0 { margin-right: $spacing-none; } 193 | .mr1 { margin-right: $spacing-extra-small; } 194 | .mr2 { margin-right: $spacing-small; } 195 | .mr3 { margin-right: $spacing-medium; } 196 | .mr4 { margin-right: $spacing-large; } 197 | .mr5 { margin-right: $spacing-extra-large; } 198 | .mr6 { margin-right: $spacing-extra-extra-large; } 199 | .mr7 { margin-right: $spacing-extra-extra-extra-large; } 200 | 201 | .mb0 { margin-bottom: $spacing-none; } 202 | .mb1 { margin-bottom: $spacing-extra-small; } 203 | .mb2 { margin-bottom: $spacing-small; } 204 | .mb3 { margin-bottom: $spacing-medium; } 205 | .mb4 { margin-bottom: $spacing-large; } 206 | .mb5 { margin-bottom: $spacing-extra-large; } 207 | .mb6 { margin-bottom: $spacing-extra-extra-large; } 208 | .mb7 { margin-bottom: $spacing-extra-extra-extra-large; } 209 | 210 | .mt0 { margin-top: $spacing-none; } 211 | .mt1 { margin-top: $spacing-extra-small; } 212 | .mt2 { margin-top: $spacing-small; } 213 | .mt3 { margin-top: $spacing-medium; } 214 | .mt4 { margin-top: $spacing-large; } 215 | .mt5 { margin-top: $spacing-extra-large; } 216 | .mt6 { margin-top: $spacing-extra-extra-large; } 217 | .mt7 { margin-top: $spacing-extra-extra-extra-large; } 218 | 219 | .mv0 { 220 | margin-top: $spacing-none; 221 | margin-bottom: $spacing-none; 222 | } 223 | .mv1 { 224 | margin-top: $spacing-extra-small; 225 | margin-bottom: $spacing-extra-small; 226 | } 227 | .mv2 { 228 | margin-top: $spacing-small; 229 | margin-bottom: $spacing-small; 230 | } 231 | .mv3 { 232 | margin-top: $spacing-medium; 233 | margin-bottom: $spacing-medium; 234 | } 235 | .mv4 { 236 | margin-top: $spacing-large; 237 | margin-bottom: $spacing-large; 238 | } 239 | .mv5 { 240 | margin-top: $spacing-extra-large; 241 | margin-bottom: $spacing-extra-large; 242 | } 243 | .mv6 { 244 | margin-top: $spacing-extra-extra-large; 245 | margin-bottom: $spacing-extra-extra-large; 246 | } 247 | .mv7 { 248 | margin-top: $spacing-extra-extra-extra-large; 249 | margin-bottom: $spacing-extra-extra-extra-large; 250 | } 251 | 252 | .mh0 { 253 | margin-left: $spacing-none; 254 | margin-right: $spacing-none; 255 | } 256 | .mh1 { 257 | margin-left: $spacing-extra-small; 258 | margin-right: $spacing-extra-small; 259 | } 260 | .mh2 { 261 | margin-left: $spacing-small; 262 | margin-right: $spacing-small; 263 | } 264 | .mh3 { 265 | margin-left: $spacing-medium; 266 | margin-right: $spacing-medium; 267 | } 268 | .mh4 { 269 | margin-left: $spacing-large; 270 | margin-right: $spacing-large; 271 | } 272 | .mh5 { 273 | margin-left: $spacing-extra-large; 274 | margin-right: $spacing-extra-large; 275 | } 276 | .mh6 { 277 | margin-left: $spacing-extra-extra-large; 278 | margin-right: $spacing-extra-extra-large; 279 | } 280 | .mh7 { 281 | margin-left: $spacing-extra-extra-extra-large; 282 | margin-right: $spacing-extra-extra-extra-large; 283 | } 284 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bamboo": {:hex, :bamboo, "1.0.0", "446525f74eb59022ef58bc82f6c91c8e4c5a1469ab42a7f9b37c17262f872ef0", [:mix], [{:hackney, "~> 1.12.1", [hex: :hackney, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, 4 | "bcrypt_elixir": {:hex, :bcrypt_elixir, "1.0.9", "cdd8de5c7d69cd7fad2816f4f6379686710f3944dfbde447828860c825e3479d", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, 6 | "certifi": {:hex, :certifi, "2.3.1", "d0f424232390bf47d82da8478022301c561cf6445b5b5fb6a84d49a9e76d2639", [:rebar3], [{:parse_trans, "3.2.0", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"}, 8 | "comeonin": {:hex, :comeonin, "4.1.1", "c7304fc29b45b897b34142a91122bc72757bc0c295e9e824999d5179ffc08416", [:mix], [{:argon2_elixir, "~> 1.2", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:bcrypt_elixir, "~> 0.12.1 or ~> 1.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: true]}, {:pbkdf2_elixir, "~> 0.12", [hex: :pbkdf2_elixir, repo: "hexpm", optional: true]}], "hexpm"}, 9 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, 10 | "cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, 11 | "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], [], "hexpm"}, 12 | "credo": {:hex, :credo, "0.9.3", "76fa3e9e497ab282e0cf64b98a624aa11da702854c52c82db1bf24e54ab7c97a", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "db_connection": {:hex, :db_connection, "1.1.3", "89b30ca1ef0a3b469b1c779579590688561d586694a3ce8792985d4d7e575a61", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, 14 | "decimal": {:hex, :decimal, "1.5.0", "b0433a36d0e2430e3d50291b1c65f53c37d56f83665b43d79963684865beab68", [:mix], [], "hexpm"}, 15 | "ecto": {:hex, :ecto, "2.2.11", "4bb8f11718b72ba97a2696f65d247a379e739a0ecabf6a13ad1face79844791c", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, 16 | "elixir_make": {:hex, :elixir_make, "0.4.2", "332c649d08c18bc1ecc73b1befc68c647136de4f340b548844efc796405743bf", [:mix], [], "hexpm"}, 17 | "ex_machina": {:hex, :ex_machina, "2.0.0", "ec284c6f57233729cea9319e083f66e613e82549f78eccdb2059aeba5d0df9f3", [], [{:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, 18 | "file_system": {:hex, :file_system, "0.2.6", "fd4dc3af89b9ab1dc8ccbcc214a0e60c41f34be251d9307920748a14bf41f1d3", [:mix], [], "hexpm"}, 19 | "floki": {:hex, :floki, "0.18.0", "643d5e4bb325905328d250760ea622faebac4f7e1521f770d35fbb43d8dd4f5f", [], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, 20 | "gettext": {:hex, :gettext, "0.15.0", "40a2b8ce33a80ced7727e36768499fc9286881c43ebafccae6bab731e2b2b8ce", [:mix], [], "hexpm"}, 21 | "guardian": {:hex, :guardian, "1.1.1", "be14c4007eaf05268251ae114030cb7237ed9a9631c260022f020164ff4ed733", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.0 or ~> 1.2 or ~> 1.3", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, 22 | "hackney": {:hex, :hackney, "1.12.1", "8bf2d0e11e722e533903fe126e14d6e7e94d9b7983ced595b75f532e04b7fdc7", [:rebar3], [{:certifi, "2.3.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 23 | "httpoison": {:hex, :httpoison, "1.2.0", "2702ed3da5fd7a8130fc34b11965c8cfa21ade2f232c00b42d96d4967c39a3a3", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 24 | "idna": {:hex, :idna, "5.1.1", "cbc3b2fa1645113267cc59c760bafa64b2ea0334635ef06dbac8801e42f7279c", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 25 | "jose": {:hex, :jose, "1.8.4", "7946d1e5c03a76ac9ef42a6e6a20001d35987afd68c2107bcd8f01a84e75aa73", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"}, 26 | "meck": {:hex, :meck, "0.8.7", "ebad16ca23f685b07aed3bc011efff65fbaf28881a8adf925428ef5472d390ee", [], [], "hexpm"}, 27 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 28 | "mime": {:hex, :mime, "1.3.0", "5e8d45a39e95c650900d03f897fbf99ae04f60ab1daa4a34c7a20a5151b7a5fe", [:mix], [], "hexpm"}, 29 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, 30 | "mochiweb": {:hex, :mochiweb, "2.15.0", "e1daac474df07651e5d17cc1e642c4069c7850dc4508d3db7263a0651330aacc", [], [], "hexpm"}, 31 | "mock": {:hex, :mock, "0.3.0", "91523bc43d8c654891284b89b8ad0410212c0ebea332bc719d7167108ea8117d", [], [{:meck, "~> 0.8.7", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, 32 | "parse_trans": {:hex, :parse_trans, "3.2.0", "2adfa4daf80c14dc36f522cf190eb5c4ee3e28008fc6394397c16f62a26258c2", [:rebar3], [], "hexpm"}, 33 | "phoenix": {:hex, :phoenix, "1.3.4", "aaa1b55e5523083a877bcbe9886d9ee180bf2c8754905323493c2ac325903dc5", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, 34 | "phoenix_ecto": {:hex, :phoenix_ecto, "3.3.0", "702f6e164512853d29f9d20763493f2b3bcfcb44f118af2bc37bb95d0801b480", [:mix], [{:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 35 | "phoenix_html": {:hex, :phoenix_html, "2.11.2", "86ebd768258ba60a27f5578bec83095bdb93485d646fc4111db8844c316602d6", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 36 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.1.7", "425fff579085f7eacaf009e71940be07338c8d8b78d16e307c50c7d82a381497", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.0 or ~> 1.2 or ~> 1.3 or ~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm"}, 37 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.1", "6668d787e602981f24f17a5fbb69cc98f8ab085114ebfac6cc36e10a90c8e93c", [:mix], [], "hexpm"}, 38 | "plug": {:hex, :plug, "1.7.1", "8516d565fb84a6a8b2ca722e74e2cd25ca0fc9d64f364ec9dbec09d33eb78ccd", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"}, 39 | "plug_cowboy": {:hex, :plug_cowboy, "1.0.0", "2e2a7d3409746d335f451218b8bb0858301c3de6d668c3052716c909936eb57a", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 40 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, 41 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 42 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"}, 43 | "postgrex": {:hex, :postgrex, "0.13.5", "3d931aba29363e1443da167a4b12f06dcd171103c424de15e5f3fc2ba3e6d9c5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"}, 44 | "pre_commit": {:hex, :pre_commit, "0.3.4", "e2850f80be8090d50ad8019ef2426039307ff5dfbe70c736ad0d4d401facf304", [:mix], [], "hexpm"}, 45 | "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"}, 46 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, 47 | "timex": {:hex, :timex, "3.3.0", "e0695aa0ddb37d460d93a2db34d332c2c95a40c27edf22fbfea22eb8910a9c8d", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, 48 | "tzdata": {:hex, :tzdata, "0.5.19", "7962a3997bf06303b7d1772988ede22260f3dae1bf897408ebdac2b4435f4e6a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 49 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"}, 50 | "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm"}, 51 | } 52 | -------------------------------------------------------------------------------- /test/myapp_web/controllers/account_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MyAppWeb.AccountControllerTest do 2 | use MyAppWeb.ConnCase 3 | use Bamboo.Test 4 | 5 | alias MyApp.Accounts 6 | alias MyApp.Accounts.User 7 | 8 | @create_attrs %{ 9 | email: "some@email.com", 10 | password_hash: Comeonin.Bcrypt.hashpwsalt("test"), 11 | password: "test", 12 | password_confirmation: "test", 13 | type: User.types().user, 14 | username: "some username", 15 | activated: true 16 | } 17 | 18 | describe "activate" do 19 | test "activates account and logs in user on success", %{conn: conn} do 20 | conn = post(conn, user_path(conn, :create), user: @create_attrs) 21 | user = Accounts.get_user_by_email!(@create_attrs.email) 22 | conn = get(conn, account_path(conn, :activate, user.id, user.activation_token)) 23 | assert conn.assigns.current_user.activated 24 | assert redirected_to(conn) == page_path(conn, :index) 25 | end 26 | 27 | test "returns error on wrong token", %{conn: conn} do 28 | conn = post(conn, user_path(conn, :create), user: @create_attrs) 29 | user = Accounts.get_user_by_email!(@create_attrs.email) 30 | conn = get(conn, account_path(conn, :activate, user.id, "wrong_token_lol")) 31 | assert conn.assigns.current_user.activated == false 32 | assert redirected_to(conn) == account_path(conn, :show_activation_status, user.id) 33 | end 34 | end 35 | 36 | describe "resend_activation" do 37 | test "redirects to index if account is already valid", %{conn: conn} do 38 | conn = post(conn, user_path(conn, :create), user: @create_attrs) 39 | user = Accounts.get_user_by_email!(@create_attrs.email) 40 | conn = get(conn, account_path(conn, :activate, user.id, user.activation_token)) 41 | conn = get(conn, account_path(conn, :resend_activation, user.id)) 42 | assert redirected_to(conn) == page_path(conn, :index) 43 | assert conn.assigns.current_user.activated == true 44 | end 45 | 46 | test "errors if the account has too many activation attempts", %{conn: conn} do 47 | max_attempts = Application.get_env(:myapp, :email_activation_max) 48 | conn = post(conn, user_path(conn, :create), user: @create_attrs) 49 | user = Accounts.get_user_by_email!(@create_attrs.email) 50 | for _ <- 1..(max_attempts + 1), do: get(conn, account_path(conn, :resend_activation, user.id)) 51 | conn = get(conn, account_path(conn, :resend_activation, user.id)) 52 | assert redirected_to(conn) == account_path(conn, :show_activation_status, user.id) 53 | assert get_flash(conn, :error) =~ "too many" 54 | end 55 | 56 | test "sends the activation if user is not activated", %{conn: conn} do 57 | conn = post(conn, user_path(conn, :create), user: @create_attrs) 58 | user = Accounts.get_user_by_email!(@create_attrs.email) 59 | conn = get(conn, account_path(conn, :resend_activation, user.id)) 60 | user = Accounts.get_user_by_email!(@create_attrs.email) 61 | assert redirected_to(conn) == account_path(conn, :show_activation_status, user.id) 62 | assert_delivered_email(MyApp.Email.activation_email(conn, user)) 63 | end 64 | end 65 | 66 | describe "show_activation_status" do 67 | test "shows email has been sent if not activated", %{conn: conn} do 68 | conn = post(conn, user_path(conn, :create), user: @create_attrs) 69 | user = Accounts.get_user_by_email!(@create_attrs.email) 70 | conn = get(conn, account_path(conn, :show_activation_status, user.id)) 71 | assert html_response(conn, 200) =~ "An activation email has been sent" 72 | end 73 | 74 | test "shows account is already activated", %{conn: conn} do 75 | conn = post(conn, user_path(conn, :create), user: @create_attrs) 76 | user = Accounts.get_user_by_email!(@create_attrs.email) 77 | conn = get(conn, account_path(conn, :activate, user.id, user.activation_token)) 78 | conn = get(conn, account_path(conn, :show_activation_status, user.id)) 79 | assert html_response(conn, 200) =~ "already active" 80 | end 81 | end 82 | 83 | describe "show_reset_password" do 84 | test "should display an html form", %{conn: conn} do 85 | conn = get(conn, account_path(conn, :show_reset_password)) 86 | assert html_response(conn, 200) =~ "Forgotten Password" 87 | end 88 | end 89 | 90 | describe "send_password_reset_email" do 91 | setup [:create_user] 92 | 93 | test "should send a reset email with valid credentials", %{conn: conn, user: user} do 94 | conn = post(conn, account_path(conn, :send_password_reset_email, %{"email_params" => %{"email" => user.email}})) 95 | user = Accounts.get_user_by_email!(user.email) 96 | assert redirected_to(conn) == page_path(conn, :index) 97 | assert get_flash(conn, :info) =~ "reset email sent!" 98 | assert_delivered_email(MyApp.Email.reset_password_email(conn, user)) 99 | end 100 | 101 | test "should show error if no email matches", %{conn: conn} do 102 | conn = 103 | post( 104 | conn, 105 | account_path(conn, :send_password_reset_email, %{"email_params" => %{"email" => "notme@heckoff.com"}}) 106 | ) 107 | 108 | assert redirected_to(conn) == account_path(conn, :show_reset_password) 109 | assert get_flash(conn, :error) =~ "Could not find" 110 | end 111 | 112 | test "should error if an email has been sent recently", %{conn: conn, user: user} do 113 | conn = post(conn, account_path(conn, :send_password_reset_email, %{"email_params" => %{"email" => user.email}})) 114 | conn = post(conn, account_path(conn, :send_password_reset_email, %{"email_params" => %{"email" => user.email}})) 115 | assert redirected_to(conn) == account_path(conn, :show_reset_password) 116 | assert get_flash(conn, :error) =~ "Password reset email was recently sent" 117 | end 118 | 119 | test "should allow reset email to be sent after wait period", %{conn: conn, user: user} do 120 | conn = post(conn, account_path(conn, :send_password_reset_email, %{"email_params" => %{"email" => user.email}})) 121 | wait_time = Application.get_env(:myapp, :reset_password_interval_ms) 122 | :timer.sleep(wait_time) 123 | conn = post(conn, account_path(conn, :send_password_reset_email, %{"email_params" => %{"email" => user.email}})) 124 | user = Accounts.get_user_by_email!(user.email) 125 | assert redirected_to(conn) == page_path(conn, :index) 126 | assert get_flash(conn, :info) =~ "reset email sent!" 127 | assert_delivered_email(MyApp.Email.reset_password_email(conn, user)) 128 | end 129 | end 130 | 131 | describe "show_password_change" do 132 | setup [:create_user] 133 | 134 | test "should display the reset form if reset tokens match", %{conn: conn, user: user} do 135 | conn = post(conn, account_path(conn, :send_password_reset_email, %{"email_params" => %{"email" => user.email}})) 136 | user = Accounts.get_user_by_email!(user.email) 137 | conn = get(conn, account_path(conn, :show_password_change, user.id, user.password_reset_token)) 138 | assert html_response(conn, 200) =~ "Reset Password" 139 | end 140 | 141 | test "should error if wrong token passed in", %{conn: conn, user: user} do 142 | conn = post(conn, account_path(conn, :send_password_reset_email, %{"email_params" => %{"email" => user.email}})) 143 | user = Accounts.get_user_by_email!(user.email) 144 | conn = get(conn, account_path(conn, :show_password_change, user.id, UUID.uuid4())) 145 | assert redirected_to(conn) == page_path(conn, :index) 146 | assert get_flash(conn, :error) =~ "reset token" 147 | end 148 | end 149 | 150 | describe "reset_password" do 151 | setup [:create_user] 152 | 153 | test "should reset password on valid attempt", %{conn: conn, user: user} do 154 | conn = post(conn, account_path(conn, :send_password_reset_email, %{"email_params" => %{"email" => user.email}})) 155 | user = Accounts.get_user_by_email!(user.email) 156 | new_pw = "newvalidpw" 157 | 158 | conn = 159 | post( 160 | conn, 161 | account_path(conn, :reset_password, user.id, %{ 162 | "reset_params" => %{ 163 | "password_reset_token" => user.password_reset_token, 164 | "password" => new_pw, 165 | "password_confirmation" => new_pw 166 | } 167 | }) 168 | ) 169 | 170 | user = Accounts.get_user_by_email!(user.email) 171 | assert redirected_to(conn) == page_path(conn, :index) 172 | assert get_flash(conn, :info) =~ "Password successfully changed!" 173 | assert Comeonin.Bcrypt.checkpw(new_pw, user.password_hash) 174 | assert user.password_reset_token == nil 175 | assert user.last_password_reset_attempt == nil 176 | end 177 | 178 | test "should display errors if form fields are invalid", %{conn: conn, user: user} do 179 | conn = post(conn, account_path(conn, :send_password_reset_email, %{"email_params" => %{"email" => user.email}})) 180 | user = Accounts.get_user_by_email!(user.email) 181 | new_pw = "newvalidpw" 182 | bad_bw_confirm = "asdfsadfaf" 183 | 184 | conn = 185 | post( 186 | conn, 187 | account_path(conn, :reset_password, user.id, %{ 188 | "reset_params" => %{ 189 | "password_reset_token" => user.password_reset_token, 190 | "password" => new_pw, 191 | "password_confirmation" => bad_bw_confirm 192 | } 193 | }) 194 | ) 195 | 196 | user = Accounts.get_user_by_email!(user.email) 197 | assert html_response(conn, 200) =~ "not match" 198 | assert Comeonin.Bcrypt.checkpw(@create_attrs.password, user.password_hash) 199 | end 200 | 201 | test "should error if token does not match", %{conn: conn, user: user} do 202 | conn = post(conn, account_path(conn, :send_password_reset_email, %{"email_params" => %{"email" => user.email}})) 203 | user = Accounts.get_user_by_email!(user.email) 204 | new_pw = "newvalidpw" 205 | 206 | conn = 207 | post( 208 | conn, 209 | account_path(conn, :reset_password, user.id, %{ 210 | "reset_params" => %{"reset_token" => UUID.uuid4(), "password" => new_pw, "password_confirmation" => new_pw} 211 | }) 212 | ) 213 | 214 | user = Accounts.get_user_by_email!(user.email) 215 | assert redirected_to(conn) == page_path(conn, :index) 216 | assert get_flash(conn, :error) =~ "reset token you provided does not match" 217 | assert Comeonin.Bcrypt.checkpw(@create_attrs.password, user.password_hash) 218 | end 219 | end 220 | 221 | defp create_user(_) do 222 | {:ok, user} = Accounts.create_user(@create_attrs) 223 | {:ok, user: user} 224 | end 225 | end 226 | --------------------------------------------------------------------------------