├── .dialyzer_ignore.exs ├── .starter-version ├── assets ├── global.d.ts ├── .babelrc ├── static │ ├── favicon.ico │ └── robots.txt ├── .prettierrc.js ├── postcss.config.js ├── css │ ├── app.css │ └── phoenix.css ├── tsconfig.json ├── tailwind.config.js ├── .stylelintrc.js ├── .eslintrc.js ├── js │ ├── uploads.ts │ └── app.ts ├── package.json └── webpack.config.js ├── lib ├── phoenix_starter_web │ ├── live │ │ ├── page_live.html.heex │ │ ├── page_live.ex │ │ └── user_settings │ │ │ ├── index.html.heex │ │ │ ├── index.ex │ │ │ ├── password_component.ex │ │ │ ├── email_component.ex │ │ │ └── profile_component.ex │ ├── views │ │ ├── user_email_view.ex │ │ ├── user_session_view.ex │ │ ├── user_settings_view.ex │ │ ├── user_confirmation_view.ex │ │ ├── user_registration_view.ex │ │ ├── user_reset_password_view.ex │ │ ├── error_view.ex │ │ ├── error_helpers.ex │ │ └── layout_view.ex │ ├── templates │ │ ├── layout │ │ │ ├── email.html.heex │ │ │ ├── app.html.heex │ │ │ ├── live.html.heex │ │ │ ├── email.text.heex │ │ │ ├── _user_menu.html.heex │ │ │ └── root.html.heex │ │ ├── user_email │ │ │ ├── update_email_instructions.text.eex │ │ │ ├── reset_password_instructions.text.eex │ │ │ ├── confirmation_instructions.text.eex │ │ │ ├── update_email_instructions.html.heex │ │ │ ├── reset_password_instructions.html.heex │ │ │ └── confirmation_instructions.html.heex │ │ ├── user_reset_password │ │ │ ├── new.html.heex │ │ │ └── edit.html.heex │ │ ├── user_confirmation │ │ │ └── new.html.heex │ │ ├── user_registration │ │ │ └── new.html.heex │ │ └── user_session │ │ │ └── new.html.heex │ ├── live_helpers.ex │ ├── gettext.ex │ ├── controllers │ │ ├── user_session_controller.ex │ │ ├── user_registration_controller.ex │ │ ├── user_settings_controller.ex │ │ ├── user_confirmation_controller.ex │ │ ├── user_reset_password_controller.ex │ │ └── user_auth.ex │ ├── channels │ │ └── user_socket.ex │ ├── plugs │ │ ├── require_user_permission.ex │ │ └── content_security_policy.ex │ ├── endpoint.ex │ ├── telemetry.ex │ └── router.ex ├── phoenix_starter │ ├── mailer.ex │ ├── schema.ex │ ├── repo.ex │ ├── email.ex │ ├── workers │ │ ├── error_reporter.ex │ │ ├── reportable.ex │ │ └── user_email_worker.ex │ ├── users │ │ ├── user_email.ex │ │ ├── user_notifier.ex │ │ ├── user_role.ex │ │ ├── user_token.ex │ │ └── user.ex │ ├── application.ex │ ├── release_tasks.ex │ └── uploads.ex ├── phoenix_starter.ex └── phoenix_starter_web.ex ├── bin ├── env.sh ├── utils.sh ├── preflight.sh ├── starter-version-info.sh └── init.sh ├── .tool-versions ├── priv ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ ├── 20201107013258_add_role_to_users.exs │ │ ├── 20210121172804_add_profile_image_to_users.exs │ │ ├── 20201104222052_enable_pgcrypto.exs │ │ ├── 20201104031938_enable_pg_stat_statements.exs │ │ ├── 20201110040943_add_oban_jobs_table.exs │ │ └── 20201105040540_create_users_auth_tables.exs │ └── seeds.exs ├── templates │ └── phx.gen.schema │ │ └── migration.exs └── gettext │ ├── en │ └── LC_MESSAGES │ │ └── errors.po │ ├── errors.pot │ └── default.pot ├── test ├── test_helper.exs ├── support │ ├── profile-image.jpg │ ├── fixtures │ │ └── users_fixtures.ex │ ├── channel_case.ex │ ├── data_case.ex │ └── conn_case.ex ├── phoenix_starter │ ├── email_test.exs │ ├── users │ │ ├── user_role_test.exs │ │ └── user_email_test.exs │ ├── workers │ │ └── user_email_worker_test.exs │ └── uploads_test.exs └── phoenix_starter_web │ ├── live │ ├── page_live_test.exs │ └── user_settings │ │ ├── index_test.exs │ │ ├── email_component_test.exs │ │ ├── password_component_test.exs │ │ └── profile_component_test.exs │ ├── plugs │ ├── content_security_policy_test.exs │ └── require_user_permission_test.exs │ ├── views │ ├── error_view_test.exs │ └── layout_view_test.exs │ └── controllers │ ├── user_registration_controller_test.exs │ ├── user_session_controller_test.exs │ ├── user_settings_controller_test.exs │ ├── user_confirmation_controller_test.exs │ └── user_reset_password_controller_test.exs ├── .editorconfig ├── .dockerignore ├── .formatter.exs ├── .sobelow-conf ├── .sobelow-skips ├── rel ├── overlays │ └── docker-entrypoint.sh └── env.sh.eex ├── .github ├── dependabot.yml ├── pull_request_template.md └── workflows │ └── ci.yml ├── config ├── prod.exs ├── test.exs ├── config.exs ├── dev.exs └── runtime.exs ├── .gitignore ├── LICENSE ├── Dockerfile ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md ├── mix.exs ├── README_starter.md ├── README.md └── .credo.exs /.dialyzer_ignore.exs: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /.starter-version: -------------------------------------------------------------------------------- 1 | v2.0 2 | -------------------------------------------------------------------------------- /assets/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*" 2 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/live/page_live.html.heex: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /bin/env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | APP_NAME=phoenix-starter 4 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 24.2.1 2 | elixir 1.13.2-otp-24 3 | nodejs 16.13.2 4 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(PhoenixStarter.Repo, :manual) 3 | -------------------------------------------------------------------------------- /assets/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newaperio/phoenix_starter/HEAD/assets/static/favicon.ico -------------------------------------------------------------------------------- /test/support/profile-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newaperio/phoenix_starter/HEAD/test/support/profile-image.jpg -------------------------------------------------------------------------------- /lib/phoenix_starter_web/views/user_email_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserEmailView do 2 | use PhoenixStarterWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /assets/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | semi: false, 4 | singleQuote: false, 5 | trailingComma: "all", 6 | } 7 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/views/user_session_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserSessionView do 2 | use PhoenixStarterWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/views/user_settings_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserSettingsView do 2 | use PhoenixStarterWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | indent_size = 2 4 | indent_style = space 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/views/user_confirmation_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserConfirmationView do 2 | use PhoenixStarterWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/views/user_registration_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserRegistrationView do 2 | use PhoenixStarterWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/views/user_reset_password_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserResetPasswordView do 2 | use PhoenixStarterWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/templates/layout/email.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= @inner_content %> 6 | 7 | 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | _build/ 2 | .git/ 3 | .elixir_ls/ 4 | assets/node_modules/ 5 | deps/ 6 | doc/ 7 | priv/static 8 | test 9 | README.md 10 | Dockerfile 11 | .* 12 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/templates/layout/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 | <%= alert(@conn, :info) %> 3 | <%= alert(@conn, :error) %> 4 | <%= @inner_content %> 5 |
6 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :phoenix], 3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: ["priv/*/migrations"] 5 | ] 6 | -------------------------------------------------------------------------------- /lib/phoenix_starter/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Mailer do 2 | @moduledoc """ 3 | Mailer to send emails with `Swoosh.Mailer`. 4 | """ 5 | use Swoosh.Mailer, otp_app: :phoenix_starter 6 | end 7 | -------------------------------------------------------------------------------- /assets/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require("postcss-nested"), 4 | require("postcss-import"), 5 | require("tailwindcss"), 6 | require("autoprefixer"), 7 | ], 8 | } 9 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/live/page_live.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.PageLive do 2 | use PhoenixStarterWeb, :live_view 3 | 4 | @impl true 5 | def mount(_params, _session, socket) do 6 | {:ok, socket} 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/templates/layout/live.html.heex: -------------------------------------------------------------------------------- 1 |
2 |

Welcome to Phoenix!

3 | 4 | <%= alert(@flash, :info) %> 5 | <%= alert(@flash, :error) %> 6 | 7 | <%= @inner_content %> 8 |
9 | -------------------------------------------------------------------------------- /assets/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /.sobelow-conf: -------------------------------------------------------------------------------- 1 | [ 2 | verbose: true, 3 | private: false, 4 | skip: true, 5 | router: "", 6 | exit: "low", 7 | format: "txt", 8 | out: "", 9 | threshold: "low", 10 | ignore: ["Config.Headers"], 11 | ignore_files: [""] 12 | ] 13 | -------------------------------------------------------------------------------- /.sobelow-skips: -------------------------------------------------------------------------------- 1 | 2 | 069E806D8BE93D75D920CD95462D769B 3 | 4 | 6DDDF2D9FE551CB47AE61325739A1692 5 | 2A7BBB248A3CA394D351C494CED56BF3 6 | 99C3480E85C25DD13B07BDFC9D1F2493 7 | BC7A1191BA841649BE716F5F4621F5FA 8 | 60617754596B2F06D9F3F251381FBD22 9 | F9D338ACDA49850FD60210BE0FF25C87 -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "../node_modules/nprogress/nprogress.css"; 4 | @import "phoenix.css"; 5 | @import "tailwindcss/utilities"; 6 | 7 | @layer components { 8 | /* Add custom styles here */ 9 | } 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20201107013258_add_role_to_users.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Repo.Migrations.AddRoleToUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :role, :string, null: false, default: "user" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /assets/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "downlevelIteration": true, 4 | "esModuleInterop": true, 5 | "jsx": "react", 6 | "module": "es6", 7 | "moduleResolution": "node", 8 | "noImplicitAny": true, 9 | "strict": true, 10 | "target": "es5" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210121172804_add_profile_image_to_users.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Repo.Migrations.AddProfileImageToUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add(:profile_image, :map, default: "[]") 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /rel/overlays/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | case $1 in 6 | migrate | migrations | rollback | seeds) 7 | /opt/app/bin/phoenix_starter eval "PhoenixStarter.ReleaseTasks.$1" 8 | ;; 9 | 10 | *) 11 | eval /opt/app/bin/phoenix_starter "$@" 12 | ;; 13 | esac 14 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/templates/user_email/update_email_instructions.text.eex: -------------------------------------------------------------------------------- 1 | <%= gettext("Hi %{user_email},", user_email: @user.email) %> 2 | 3 | <%= gettext("You can change your email by visiting the URL below:") %> 4 | 5 | <%= @url %> 6 | 7 | <%= gettext("If you didn't request this change, please ignore this.") %> 8 | -------------------------------------------------------------------------------- /test/phoenix_starter/email_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.EmailTest do 2 | use PhoenixStarter.DataCase 3 | 4 | alias PhoenixStarter.Email 5 | 6 | test "default_from/0 returns formatted address" do 7 | assert Email.default_from() == {"PhoenixStarter", "notifications@example.com"} 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/phoenix_starter.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter do 2 | @moduledoc """ 3 | PhoenixStarter 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 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/templates/user_email/reset_password_instructions.text.eex: -------------------------------------------------------------------------------- 1 | <%= gettext("Hi %{user_email},", user_email: @user.email) %> 2 | 3 | <%= gettext("You can reset your password by visiting the URL below:") %> 4 | 5 | <%= @url %> 6 | 7 | <%= gettext("If you didn't request this change, please ignore this.") %> 8 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/templates/user_email/confirmation_instructions.text.eex: -------------------------------------------------------------------------------- 1 | <%= gettext("Hi %{user_email},", user_email: @user.email) %> 2 | 3 | <%= gettext("You can confirm your account by visiting the URL below:") %> 4 | 5 | <%= @url %> 6 | 7 | <%= gettext("If you didn't create an account with us, please ignore this.") %> 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20201104222052_enable_pgcrypto.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Repo.Migrations.EnablePgcrypto do 2 | use Ecto.Migration 3 | 4 | def up do 5 | execute("CREATE EXTENSION IF NOT EXISTS pgcrypto") 6 | end 7 | 8 | def down do 9 | execute("DROP EXTENSION IF EXISTS pgcrypto") 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/templates/layout/email.text.heex: -------------------------------------------------------------------------------- 1 | A message from PhoenixStarter: 2 | ------------------------------ 3 | 4 | <%= @inner_content %> 5 | 6 | ============================== 7 | 8 | Have questions? Contact support: support@example.com. 9 | 10 | You’re receiving this email because you have an account with PhoenixStarter. 11 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: [ 3 | "../**/*.html.eex", 4 | "../**/*.html.leex", 5 | "../**/views/**/*.ex", 6 | "../**/live/**/*.ex", 7 | "./js/**/*.js", 8 | "./js/**/*.ts", 9 | "./js/**/*.tsx", 10 | ], 11 | theme: { 12 | extend: {}, 13 | }, 14 | variants: {}, 15 | plugins: [], 16 | } 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20201104031938_enable_pg_stat_statements.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Repo.Migrations.EnablePgStatStatements do 2 | use Ecto.Migration 3 | 4 | def up do 5 | execute("CREATE EXTENSION IF NOT EXISTS pg_stat_statements") 6 | end 7 | 8 | def down do 9 | execute("DROP EXTENSION IF EXISTS pg_stat_statements") 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/templates/user_email/update_email_instructions.html.heex: -------------------------------------------------------------------------------- 1 | <%= content_tag(:p, gettext("Hi %{user_email},", user_email: @user.email)) %> 2 | 3 | <%= content_tag(:p, gettext("You can change your email by visiting the URL below:")) %> 4 | 5 | <%= content_tag(:p, @url) %> 6 | 7 | <%= content_tag(:p, gettext("If you didn't request this change, please ignore this.")) %> 8 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/templates/user_email/reset_password_instructions.html.heex: -------------------------------------------------------------------------------- 1 | <%= content_tag(:p, gettext("Hi %{user_email},", user_email: @user.email)) %> 2 | 3 | <%= content_tag(:p, gettext("You can reset your password by visiting the URL below:")) %> 4 | 5 | <%= content_tag(:p, @url) %> 6 | 7 | <%= content_tag(:p, gettext("If you didn't request this change, please ignore this.")) %> 8 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/templates/user_email/confirmation_instructions.html.heex: -------------------------------------------------------------------------------- 1 | <%= content_tag(:p, gettext("Hi %{user_email},", user_email: @user.email)) %> 2 | 3 | <%= content_tag(:p, gettext("You can confirm your account by visiting the URL below:")) %> 4 | 5 | <%= content_tag(:p, @url) %> 6 | 7 | <%= content_tag(:p, gettext("If you didn't create an account with us, please ignore this.")) %> 8 | -------------------------------------------------------------------------------- /assets/css/phoenix.css: -------------------------------------------------------------------------------- 1 | /* LiveView specific classes for your customizations */ 2 | .phx-no-feedback.invalid-feedback, 3 | .phx-no-feedback .invalid-feedback { 4 | @apply hidden; 5 | } 6 | 7 | .phx-click-loading { 8 | @apply opacity-50 transition-opacity duration-1000 ease-out; 9 | } 10 | 11 | .phx-disconnected { 12 | @apply cursor-wait; 13 | } 14 | 15 | .phx-disconnected * { 16 | @apply pointer-events-none; 17 | } 18 | -------------------------------------------------------------------------------- /test/phoenix_starter_web/live/page_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.PageLiveTest do 2 | use PhoenixStarterWeb.ConnCase 3 | 4 | import Phoenix.LiveViewTest 5 | 6 | test "disconnected and connected render", %{conn: conn} do 7 | {:ok, page_live, disconnected_html} = live(conn, "/") 8 | assert disconnected_html =~ "Welcome to Phoenix!" 9 | assert render(page_live) =~ "Welcome to Phoenix!" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "mix" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | day: "friday" 8 | - package-ecosystem: "docker" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | day: "friday" 13 | - package-ecosystem: "npm" 14 | directory: "/assets" 15 | schedule: 16 | interval: "weekly" 17 | day: "friday" 18 | -------------------------------------------------------------------------------- /priv/repo/migrations/20201110040943_add_oban_jobs_table.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Repo.Migrations.AddObanJobsTable do 2 | use Ecto.Migration 3 | 4 | def up do 5 | Oban.Migrations.up() 6 | end 7 | 8 | # We specify `version: 1` in `down`, ensuring that we'll roll all the way back down if 9 | # necessary, regardless of which version we've migrated `up` to. 10 | def down do 11 | Oban.Migrations.down(version: 1) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /assets/.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: "stylelint-config-standard", 3 | plugins: ["stylelint-order"], 4 | rules: { 5 | "at-rule-no-unknown": [ 6 | true, 7 | { 8 | ignoreAtRules: ["define-mixin", "mixin", "layer"], 9 | }, 10 | ], 11 | "declaration-colon-newline-after": "always-multi-line", 12 | "declaration-empty-line-before": "never", 13 | "order/properties-alphabetical-order": true, 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/live_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.LiveHelpers do 2 | @moduledoc """ 3 | Helper functions for dealing with `Phoenix.LiveView`. 4 | """ 5 | import Phoenix.LiveView, only: [assign_new: 3] 6 | 7 | alias PhoenixStarter.Users 8 | 9 | def assign_defaults(socket, _params, session) do 10 | assign_new(socket, :current_user, fn -> 11 | Users.get_user_by_session_token(session["user_token"]) 12 | end) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/phoenix_starter/users/user_role_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Users.UserRoleTest do 2 | use PhoenixStarter.DataCase 3 | 4 | alias PhoenixStarter.Users.UserRole 5 | 6 | test "roles/0" do 7 | assert UserRole.roles() == [:admin, :ops_admin, :user] 8 | end 9 | 10 | test "role/1" do 11 | assert %UserRole{} = UserRole.role(:admin) 12 | 13 | assert_raise ArgumentError, fn -> 14 | UserRole.role(:notarole) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/phoenix_starter/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Schema do 2 | @moduledoc """ 3 | Base module `use`d by `Ecto.Schema` modules to set app defaults. 4 | """ 5 | 6 | defmacro __using__(_opts) do 7 | quote do 8 | use Ecto.Schema 9 | 10 | @type t :: %__MODULE__{} 11 | 12 | @primary_key {:id, :binary_id, autogenerate: true} 13 | @foreign_key_type :binary_id 14 | @timestamps_opts [type: :utc_datetime] 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/phoenix_starter_web/plugs/content_security_policy_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.ContentSecurityPolicyTest do 2 | use PhoenixStarterWeb.ConnCase 3 | 4 | test "adds content-security-policy header" do 5 | conn = 6 | build_conn() 7 | |> bypass_through(PhoenixStarterWeb.Router, [:browser]) 8 | |> get("/") 9 | 10 | header = get_resp_header(conn, "content-security-policy") 11 | 12 | assert is_list(header) 13 | assert length(header) == 1 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/templates/user_reset_password/new.html.heex: -------------------------------------------------------------------------------- 1 |

Forgot your password?

2 | 3 | <%= form_for :user, Routes.user_reset_password_path(@conn, :create), fn f -> %> 4 | <%= label f, :email %> 5 | <%= email_input f, :email, required: true %> 6 | 7 |
8 | <%= submit "Send instructions to reset password" %> 9 |
10 | <% end %> 11 | 12 |

13 | <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | 14 | <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> 15 |

16 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/templates/user_confirmation/new.html.heex: -------------------------------------------------------------------------------- 1 |

Resend confirmation instructions

2 | 3 | <%= form_for :user, Routes.user_confirmation_path(@conn, :create), fn f -> %> 4 | <%= label f, :email %> 5 | <%= email_input f, :email, required: true %> 6 | 7 |
8 | <%= submit "Resend confirmation instructions" %> 9 |
10 | <% end %> 11 | 12 |

13 | <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | 14 | <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> 15 |

16 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :phoenix_starter, PhoenixStarter.Repo, 4 | ssl: true, 5 | start_apps_before_migration: [:ssl] 6 | 7 | config :phoenix_starter, PhoenixStarterWeb.Endpoint, 8 | cache_static_manifest: "priv/static/cache_manifest.json", 9 | force_ssl: [hsts: true, rewrite_on: [:x_forwarded_proto]], 10 | server: true 11 | 12 | # Do not print debug messages in production 13 | config :logger, level: :info, backends: [:console, Sentry.LoggerBackend] 14 | 15 | # Configures Sentry 16 | config :sentry, environment_name: "prod" 17 | -------------------------------------------------------------------------------- /lib/phoenix_starter/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Repo do 2 | use Ecto.Repo, 3 | otp_app: :phoenix_starter, 4 | adapter: Ecto.Adapters.Postgres 5 | 6 | @typedoc """ 7 | Represents what can be expected as the result of an Ecto operation on a 8 | changeset. 9 | """ 10 | @type result() :: 11 | {:ok, Ecto.Schema.t()} 12 | | {:ok, %{required(Ecto.Multi.name()) => Ecto.Schema.t()}} 13 | | {:error, Ecto.Changeset.t()} 14 | | {:error, Ecto.Multi.name(), any(), %{required(Ecto.Multi.name()) => any()}} 15 | end 16 | -------------------------------------------------------------------------------- /test/phoenix_starter_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.ErrorViewTest do 2 | use PhoenixStarterWeb.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(PhoenixStarterWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(PhoenixStarterWeb.ErrorView, "500.html", []) == 13 | "Internal Server Error" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /bin/utils.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | print_header() { 4 | echo 5 | echo -e "\033[36m== $1 ==\033[m" 6 | } 7 | 8 | print_step() { 9 | echo 10 | echo "==> $1" 11 | } 12 | 13 | print_info() { 14 | echo -e "\033[9;38;5;93m$1: \033[m $2" 15 | } 16 | 17 | print_success() { 18 | echo -e "\033[32m> Success:\033[0m $1" 19 | } 20 | 21 | print_error() { 22 | echo -e "\033[31m! Error:\033[0m $1" 23 | } 24 | 25 | check_executable() { 26 | if ! [ -x "$(command -v "$1")" ]; then 27 | print_error "Could not find \`$1\` exec" 28 | exit 1 29 | fi 30 | } 31 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.ErrorView do 2 | use PhoenixStarterWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /rel/env.sh.eex: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | case $RELEASE_COMMAND in 4 | start*|daemon*) 5 | ELIXIR_ERL_OPTIONS="-kernel inet_dist_listen_min $BEAM_PORT inet_dist_listen_max $BEAM_PORT" 6 | export ELIXIR_ERL_OPTIONS 7 | ;; 8 | *) 9 | ;; 10 | esac 11 | 12 | # Set the release to work across nodes. If using the long name format like 13 | # the one below (my_app@127.0.0.1), you need to also uncomment the 14 | # RELEASE_DISTRIBUTION variable below. Must be "sname", "name" or "none". 15 | export RELEASE_DISTRIBUTION=name 16 | export RELEASE_NODE=${APP_NAME:-<%= @release.name %>}@$(hostname -i) 17 | export RELEASE_COOKIE=$ERLANG_COOKIE 18 | 19 | echo "Release Node: $RELEASE_NODE" 20 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/templates/layout/_user_menu.html.heex: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /assets/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | }, 5 | extends: [ 6 | "eslint:recommended", 7 | "plugin:import/recommended", 8 | "plugin:prettier/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | ], 11 | parser: "@typescript-eslint/parser", 12 | parserOptions: { 13 | ecmaVersion: 6, 14 | sourceType: "module", 15 | }, 16 | plugins: ["prettier", "@typescript-eslint"], 17 | root: true, 18 | rules: { 19 | "prefer-const": "error", 20 | "prettier/prettier": "error", 21 | }, 22 | settings: { 23 | "import/resolver": { 24 | node: { 25 | extensions: [".ts"], 26 | }, 27 | }, 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/templates/layout/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= csrf_meta_tag() %> 8 | <%= live_title_tag assigns[:page_title] || "PhoenixStarter", suffix: " · Phoenix Framework" %> 9 | 10 | 11 | 12 | 13 | <%= render "_user_menu.html", assigns %> 14 | <%= @inner_content %> 15 | 16 | 17 | -------------------------------------------------------------------------------- /lib/phoenix_starter/email.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Email do 2 | @moduledoc """ 3 | Base Email module that includes helpers and other shared code. 4 | """ 5 | 6 | @typedoc """ 7 | A Swoosh-compatible recipient, being either: 8 | 9 | - A string representing an email address, like `foo.bar@example.com` 10 | - Or a two-element tuple `{name, address}`, where `name` is `nil` or a string 11 | and `address` is a string 12 | 13 | """ 14 | @type recipient() :: String.t() | {String.t() | nil, String.t()} 15 | 16 | @doc """ 17 | Returns a `t:recipient/0` that is the default from address for the app. 18 | """ 19 | @spec default_from :: recipient 20 | def default_from do 21 | Application.get_env(:phoenix_starter, __MODULE__)[:default_from] 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/phoenix_starter/workers/user_email_worker_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Workers.UserEmailWorkerTest do 2 | use PhoenixStarter.DataCase 3 | 4 | import PhoenixStarter.UsersFixtures 5 | 6 | alias PhoenixStarter.Workers.UserEmailWorker 7 | 8 | test "sends email" do 9 | user = user_fixture() 10 | 11 | assert {:ok, _} = 12 | UserEmailWorker.perform(%Oban.Job{ 13 | args: %{ 14 | "email" => "confirmation_instructions", 15 | "user_id" => user.id, 16 | "url" => "https://example.com" 17 | } 18 | }) 19 | end 20 | 21 | test "discards job if email isn't available" do 22 | assert {:discard, :invalid_email} = 23 | UserEmailWorker.perform(%Oban.Job{args: %{"email" => "foobar"}}) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.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 PhoenixStarterWeb.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: :phoenix_starter 24 | end 25 | -------------------------------------------------------------------------------- /test/support/fixtures/users_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.UsersFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `PhoenixStarter.Users` context. 5 | """ 6 | 7 | def unique_user_email, do: "user#{System.unique_integer()}@example.com" 8 | def valid_user_password, do: "hello world!" 9 | 10 | def user_fixture(attrs \\ %{}) do 11 | {:ok, user} = 12 | attrs 13 | |> Enum.into(%{ 14 | email: unique_user_email(), 15 | password: valid_user_password(), 16 | role: :user 17 | }) 18 | |> PhoenixStarter.Users.register_user() 19 | 20 | user 21 | end 22 | 23 | def extract_user_token(fun) do 24 | {:ok, captured} = fun.(&"[TOKEN]#{&1}[TOKEN]") 25 | [_, token, _] = String.split(captured.text_body, "[TOKEN]") 26 | token 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/controllers/user_session_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserSessionController do 2 | use PhoenixStarterWeb, :controller 3 | 4 | alias PhoenixStarter.Users 5 | alias PhoenixStarterWeb.UserAuth 6 | 7 | def new(conn, _params) do 8 | render(conn, "new.html", error_message: nil) 9 | end 10 | 11 | def create(conn, %{"user" => user_params}) do 12 | %{"email" => email, "password" => password} = user_params 13 | 14 | if user = Users.get_user_by_email_and_password(email, password) do 15 | UserAuth.log_in_user(conn, user, user_params) 16 | else 17 | render(conn, "new.html", error_message: "Invalid email or password") 18 | end 19 | end 20 | 21 | def delete(conn, _params) do 22 | conn 23 | |> put_flash(:info, "Logged out successfully.") 24 | |> UserAuth.log_out_user() 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/templates/user_registration/new.html.heex: -------------------------------------------------------------------------------- 1 |

Register

2 | 3 | <%= form_for @changeset, Routes.user_registration_path(@conn, :create), fn f -> %> 4 | <%= if @changeset.action do %> 5 |
6 |

Oops, something went wrong! Please check the errors below.

7 |
8 | <% end %> 9 | 10 | <%= label f, :email %> 11 | <%= email_input f, :email, required: true %> 12 | <%= error_tag f, :email %> 13 | 14 | <%= label f, :password %> 15 | <%= password_input f, :password, required: true %> 16 | <%= error_tag f, :password %> 17 | 18 |
19 | <%= submit "Register" %> 20 |
21 | <% end %> 22 | 23 |

24 | <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> | 25 | <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %> 26 |

27 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/templates/user_session/new.html.heex: -------------------------------------------------------------------------------- 1 |

Log in

2 | 3 | <%= form_for @conn, Routes.user_session_path(@conn, :create), [as: :user], fn f -> %> 4 | <%= if @error_message do %> 5 |
6 |

<%= @error_message %>

7 |
8 | <% end %> 9 | 10 | <%= label f, :email %> 11 | <%= email_input f, :email, required: true %> 12 | 13 | <%= label f, :password %> 14 | <%= password_input f, :password, required: true %> 15 | 16 | <%= label f, :remember_me, "Keep me logged in for 60 days" %> 17 | <%= checkbox f, :remember_me %> 18 | 19 |
20 | <%= submit "Log in" %> 21 |
22 | <% end %> 23 | 24 |

25 | <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | 26 | <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %> 27 |

28 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | - [ ] Look over your code one last time. 2 | - [ ] Test all functionality you expect to be QA'd. Don’t forget to QA user interface changes in the browser. 3 | - [ ] Describe what changes this PR includes. 4 | - [ ] Post screenshots for design review if applicable. 5 | - [ ] Fill in QA Notes listing clear, ordered steps for testers. 6 | 7 | ## Description 8 | 9 | [Fill in description here] 10 | 11 | Closes [sc-xxx]. 12 | 13 | ## Screenshots 14 | 15 | [add screenshots here] 16 | 17 | ## QA Notes 18 | 19 | PR Author Browser Info: 20 | 21 | ``` 22 | **QA Tester Browser Info:** 23 | 24 | ## Environment Setup 25 | 26 | - [ ] [Add necessary environment setup, e.g. whether to update dependencies, OR specify none] 27 | 28 | ## Testing Steps 29 | 30 | - [ ] [Add QA steps here] 31 | 32 | ## Notes 33 | 34 | - [ ] [Tester should add any comments, questions, or concerns that pop up during QA] 35 | ``` 36 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/live/user_settings/index.html.heex: -------------------------------------------------------------------------------- 1 |

Settings

2 | 3 | 8 | 9 | <%= case @live_action do %> 10 | 11 | <% :profile -> %> 12 | <%= live_component PhoenixStarterWeb.UserSettingsLive.ProfileComponent, 13 | id: :update_profile, 14 | current_user: @current_user %> 15 | 16 | <% :email -> %> 17 | <%= live_component PhoenixStarterWeb.UserSettingsLive.EmailComponent, 18 | id: :update_email, 19 | current_user: @current_user %> 20 | 21 | <% :password -> %> 22 | <%= live_component PhoenixStarterWeb.UserSettingsLive.PasswordComponent, 23 | id: :update_password, 24 | current_user: @current_user %> 25 | 26 | <% end %> 27 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.schema/migration.exs: -------------------------------------------------------------------------------- 1 | defmodule <%= inspect schema.repo %>.Migrations.Create<%= Macro.camelize(schema.table) %> do 2 | use <%= inspect schema.migration_module %> 3 | 4 | def change do 5 | create table(:<%= schema.table %><%= if schema.binary_id do %>, primary_key: false<% end %>) do 6 | <%= if schema.binary_id do %> add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()") 7 | <% end %><%= for {k, v} <- schema.attrs do %> add <%= inspect k %>, <%= inspect v %><%= schema.migration_defaults[k] %> 8 | <% end %><%= for {_, i, _, s} <- schema.assocs do %> add <%= inspect(i) %>, references(<%= inspect(s) %>, on_delete: :nothing<%= if schema.binary_id do %>, type: :binary_id<% end %>) 9 | <% end %> 10 | timestamps default: fragment("now()") 11 | end 12 | <%= if Enum.any?(schema.indexes) do %><%= for index <- schema.indexes do %> 13 | <%= index %><% end %> 14 | <% end %> end 15 | end 16 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/templates/user_reset_password/edit.html.heex: -------------------------------------------------------------------------------- 1 |

Reset password

2 | 3 | <%= form_for @changeset, Routes.user_reset_password_path(@conn, :update, @token), fn f -> %> 4 | <%= if @changeset.action do %> 5 |
6 |

Oops, something went wrong! Please check the errors below.

7 |
8 | <% end %> 9 | 10 | <%= label f, :password, "New password" %> 11 | <%= password_input f, :password, required: true %> 12 | <%= error_tag f, :password %> 13 | 14 | <%= label f, :password_confirmation, "Confirm new password" %> 15 | <%= password_input f, :password_confirmation, required: true %> 16 | <%= error_tag f, :password_confirmation %> 17 | 18 |
19 | <%= submit "Reset password" %> 20 |
21 | <% end %> 22 | 23 |

24 | <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | 25 | <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> 26 |

27 | -------------------------------------------------------------------------------- /test/phoenix_starter_web/live/user_settings/index_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserSettingsLive.IndexTest do 2 | use PhoenixStarterWeb.ConnCase 3 | 4 | import Phoenix.LiveViewTest 5 | 6 | setup :register_and_log_in_user 7 | 8 | test "renders profile component", %{conn: conn} do 9 | {:ok, _, html} = live(conn, Routes.user_settings_path(conn, :profile)) 10 | 11 | assert html =~ ~r/Update Profile/ 12 | assert html =~ "Update Profile" 13 | end 14 | 15 | test "renders email component", %{conn: conn} do 16 | {:ok, _, html} = live(conn, Routes.user_settings_path(conn, :email)) 17 | 18 | assert html =~ ~r/Update Email/ 19 | assert html =~ "Update Email" 20 | end 21 | 22 | test "renders password component", %{conn: conn} do 23 | {:ok, _, html} = live(conn, Routes.user_settings_path(conn, :password)) 24 | 25 | assert html =~ ~r/Update Password/ 26 | assert html =~ "Update Password" 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/live/user_settings/index.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserSettingsLive.Index do 2 | use PhoenixStarterWeb, :live_view 3 | 4 | @impl true 5 | def mount(params, session, socket) do 6 | socket = assign_defaults(socket, params, session) 7 | 8 | {:ok, socket} 9 | end 10 | 11 | @impl true 12 | def handle_params(params, _url, socket) do 13 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} 14 | end 15 | 16 | defp apply_action(socket, :profile, _params) do 17 | assign(socket, :page_title, "Update Profile") 18 | end 19 | 20 | defp apply_action(socket, :email, _params) do 21 | assign(socket, :page_title, "Update Email") 22 | end 23 | 24 | defp apply_action(socket, :password, _params) do 25 | assign(socket, :page_title, "Update Password") 26 | end 27 | 28 | @impl true 29 | def handle_info({:flash, key, message}, socket) do 30 | {:noreply, put_flash(socket, key, message)} 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/controllers/user_registration_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserRegistrationController do 2 | use PhoenixStarterWeb, :controller 3 | 4 | alias PhoenixStarter.Users 5 | alias PhoenixStarter.Users.User 6 | alias PhoenixStarterWeb.UserAuth 7 | 8 | def new(conn, _params) do 9 | changeset = Users.change_user_registration(%User{}) 10 | render(conn, "new.html", changeset: changeset) 11 | end 12 | 13 | def create(conn, %{"user" => user_params}) do 14 | case Users.register_user(user_params) do 15 | {:ok, user} -> 16 | {:ok, _} = 17 | Users.deliver_user_confirmation_instructions( 18 | user, 19 | &Routes.user_confirmation_url(conn, :confirm, &1) 20 | ) 21 | 22 | conn 23 | |> put_flash(:info, "User created successfully.") 24 | |> UserAuth.log_in_user(user) 25 | 26 | {:error, %Ecto.Changeset{} = changeset} -> 27 | render(conn, "new.html", changeset: changeset) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /bin/preflight.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | check_failed=0 6 | 7 | check_executable() { 8 | if ! [ -x "$(command -v "$1")" ]; then 9 | print_error "Could not find \`$1\` exec" 10 | check_failed=$((check_failed + 1)) 11 | fi 12 | } 13 | 14 | print_step() { 15 | echo 16 | echo "==> $1" 17 | } 18 | 19 | print_error() { 20 | echo -e "\033[31m! Error:\033[0m $1" 21 | } 22 | 23 | print_success() { 24 | echo -e "\033[32m> Success:\033[0m $1" 25 | } 26 | 27 | print_step "Checking prerequisites" 28 | 29 | check_executable autoconf # Erlang prereq 30 | check_executable gpg # Node prereq 31 | check_executable asdf 32 | check_executable docker 33 | check_executable psql 34 | 35 | if [[ $check_failed = 0 ]]; then 36 | print_success "All prerequisites installed" 37 | 38 | print_step "Installing asdf plugins..." 39 | asdf plugin-add erlang 40 | asdf plugin-add elixir 41 | asdf plugin-add nodejs 42 | 43 | print_step "Running asdf..." 44 | asdf install 45 | 46 | print_success "Preflight finished" 47 | else 48 | exit 1 49 | fi 50 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Only in tests, remove the complexity from the password hashing algorithm 4 | config :bcrypt_elixir, :log_rounds, 1 5 | 6 | # Configure your database 7 | config :phoenix_starter, PhoenixStarter.Repo, 8 | database: "phoenix_starter_test#{System.get_env("MIX_TEST_PARTITION")}", 9 | hostname: "localhost", 10 | pool: Ecto.Adapters.SQL.Sandbox 11 | 12 | # We don't run a server during test. If one is required, 13 | # you can enable the server option below. 14 | config :phoenix_starter, PhoenixStarterWeb.Endpoint, 15 | http: [port: 4002], 16 | server: false 17 | 18 | # Print only warnings and errors during test 19 | config :logger, level: :warn 20 | 21 | # Configures Swoosh 22 | config :phoenix_starter, PhoenixStarter.Mailer, adapter: Swoosh.Adapters.Test 23 | 24 | # Configures Oban 25 | config :phoenix_starter, Oban, crontab: false, queues: false, plugins: false 26 | 27 | # Configures Sentry 28 | config :sentry, environment_name: "test" 29 | 30 | # Configures ExAWS 31 | config :ex_aws, 32 | access_key_id: "AKIATEST", 33 | secret_access_key: "TESTKEY" 34 | -------------------------------------------------------------------------------- /bin/starter-version-info.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | source $(dirname $0)/utils.sh 6 | 7 | print_header "Phoenix Starter" 8 | 9 | if [ ! -f ".starter-version" ]; then 10 | print_error ".starter-version file not found" 11 | exit 1 12 | fi 13 | 14 | check_executable jq 15 | 16 | current_version=$(/bin/cat .starter-version) 17 | latest_version=$( 18 | curl \ 19 | -s -H "Accept: application/vnd.github.v3+json" \ 20 | https://api.github.com/repos/newaperio/phoenix_starter/releases/latest | 21 | jq -r ".tag_name" 22 | ) 23 | 24 | print_info "Current version" "$current_version" 25 | print_info "Latest version" "$latest_version" 26 | 27 | if [ $current_version == $latest_version ]; then 28 | print_success "Already at latest version!" 29 | exit 30 | fi 31 | 32 | print_info "Compare view" \ 33 | "https://github.com/newaperio/phoenix_starter/compare/$current_version...$latest_version" 34 | print_info "Changelog" \ 35 | "https://github.com/newaperio/phoenix_starter/blob/$latest_version/CHANGELOG.md" 36 | print_success "Make sure to update .starter-version after update" 37 | -------------------------------------------------------------------------------- /lib/phoenix_starter/workers/error_reporter.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Workers.ErrorReporter do 2 | @moduledoc """ 3 | Receives exception events in `Oban.Worker` for error reporting. 4 | """ 5 | alias PhoenixStarter.Workers.Reportable 6 | 7 | @spec handle_event( 8 | :telemetry.event_name(), 9 | :telemetry.event_measurements(), 10 | :telemetry.event_metadata(), 11 | :telemetry.handler_config() 12 | ) :: any() 13 | def handle_event( 14 | [:oban, :job, :exception], 15 | measure, 16 | %{attempt: attempt, worker: worker} = meta, 17 | _ 18 | ) do 19 | if Reportable.reportable?(worker, attempt) do 20 | extra = 21 | meta 22 | |> Map.take([:id, :args, :queue, :worker]) 23 | |> Map.merge(measure) 24 | 25 | Sentry.capture_exception(meta.error, stacktrace: meta.stacktrace, extra: extra) 26 | end 27 | end 28 | 29 | def handle_event([:oban, :circuit, :trip], _measure, meta, _) do 30 | Sentry.capture_exception(meta.error, stacktrace: meta.stacktrace, extra: meta) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | phoenix_starter-*.tar 24 | 25 | # If NPM crashes, it generates a log, let's ignore it too. 26 | npm-debug.log 27 | 28 | # The directory NPM downloads your dependencies sources to. 29 | /assets/node_modules/ 30 | 31 | # Since we are building assets from assets/, 32 | # we ignore priv/static. You may want to comment 33 | # this depending on your deployment strategy. 34 | /priv/static/ 35 | 36 | # Ignore Dialyzer PLTs 37 | /priv/plts/*.plt 38 | /priv/plts/*.plt.hash 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 NewAperio, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /priv/repo/migrations/20201105040540_create_users_auth_tables.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Repo.Migrations.CreateUsersAuthTables do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute "CREATE EXTENSION IF NOT EXISTS citext", "" 6 | 7 | create table(:users, primary_key: false) do 8 | add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()") 9 | add :email, :citext, null: false 10 | add :hashed_password, :string, null: false 11 | add :confirmed_at, :naive_datetime 12 | timestamps default: fragment("now()") 13 | end 14 | 15 | create unique_index(:users, [:email]) 16 | 17 | create table(:users_tokens, primary_key: false) do 18 | add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()") 19 | add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false 20 | add :token, :binary, null: false 21 | add :context, :string, null: false 22 | add :sent_to, :string 23 | timestamps updated_at: false, default: fragment("now()") 24 | end 25 | 26 | create index(:users_tokens, [:user_id]) 27 | create unique_index(:users_tokens, [:context, :token]) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /assets/js/uploads.ts: -------------------------------------------------------------------------------- 1 | interface LiveViewUploadEntry { 2 | error: () => void 3 | file: File 4 | meta: { method: string; url: string; fields: { [key: string]: string } } 5 | progress: (percent: number) => void 6 | } 7 | 8 | const s3Uploader = ( 9 | entries: [LiveViewUploadEntry], 10 | onViewError: (callback: () => void) => void, 11 | ): void => { 12 | entries.forEach((entry) => { 13 | const { method, url, fields } = entry.meta 14 | 15 | const formData = new FormData() 16 | Object.entries(fields).forEach(([key, val]) => formData.append(key, val)) 17 | formData.append("file", entry.file) 18 | 19 | const xhr = new XMLHttpRequest() 20 | onViewError(() => xhr.abort()) 21 | xhr.onload = () => 22 | xhr.status === 204 ? entry.progress(100) : entry.error() 23 | xhr.onerror = () => entry.error() 24 | xhr.upload.addEventListener("progress", (event) => { 25 | if (event.lengthComputable) { 26 | const percent = Math.round((event.loaded / event.total) * 100) 27 | if (percent < 100) { 28 | entry.progress(percent) 29 | } 30 | } 31 | }) 32 | 33 | xhr.open(method, url, true) 34 | xhr.send(formData) 35 | }) 36 | } 37 | 38 | export { s3Uploader } 39 | -------------------------------------------------------------------------------- /test/phoenix_starter/users/user_email_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Users.UserEmailTest do 2 | use PhoenixStarter.DataCase 3 | 4 | import PhoenixStarter.UsersFixtures 5 | 6 | alias PhoenixStarter.Users.UserEmail 7 | 8 | setup do 9 | %{user: user_fixture()} 10 | end 11 | 12 | test "confirmation_instructions/2", %{user: user} do 13 | url = "http://example.com/confirm" 14 | 15 | email = UserEmail.confirmation_instructions(user, url) 16 | 17 | assert email.to == [{"", user.email}] 18 | assert email.html_body =~ url 19 | assert email.text_body =~ url 20 | end 21 | 22 | test "reset_password_instructions/2", %{user: user} do 23 | url = "http://example.com/reset" 24 | 25 | email = UserEmail.reset_password_instructions(user, url) 26 | 27 | assert email.to == [{"", user.email}] 28 | assert email.html_body =~ url 29 | assert email.text_body =~ url 30 | end 31 | 32 | test "update_email_instructions/2", %{user: user} do 33 | url = "http://example.com/update" 34 | 35 | email = UserEmail.update_email_instructions(user, url) 36 | 37 | assert email.to == [{"", user.email}] 38 | assert email.html_body =~ url 39 | assert email.text_body =~ url 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/phoenix_starter/workers/reportable.ex: -------------------------------------------------------------------------------- 1 | defprotocol PhoenixStarter.Workers.Reportable do 2 | @moduledoc """ 3 | Determines if errors encountered by `Oban.Worker` should be reported. 4 | 5 | By default all errors are reported. However some workers have an 6 | expectation of transient errors, such as those that send email, because of 7 | flaky third-party providers. By implementing this protocol for a given 8 | worker, you can customize error reporting. 9 | 10 | ## Example 11 | 12 | defmodule PhoenixStarter.Workers.EmailWorker do 13 | use Oban.Worker 14 | 15 | defimpl PhoenixStarter.Workers.Reportable do 16 | @threshold 3 17 | 18 | # Will only report the error after 3 attempts 19 | def reportable?(_worker, attempt), do: attempt > @threshold 20 | end 21 | 22 | @impl true 23 | def perform(%{args: %{"email" => email}}) do 24 | PhoenixStarter.Email.deliver(email) 25 | end 26 | end 27 | 28 | """ 29 | @fallback_to_any true 30 | @spec reportable?(Oban.Worker.t(), integer) :: boolean 31 | def reportable?(worker, attempt) 32 | end 33 | 34 | defimpl PhoenixStarter.Workers.Reportable, for: Any do 35 | def reportable?(_worker, _attempt), do: true 36 | end 37 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | # channel "room:*", PhoenixStarterWeb.RoomChannel 6 | 7 | # Socket params are passed from the client and can 8 | # be used to verify and authenticate a user. After 9 | # verification, you can put default assigns into 10 | # the socket that will be set for all channels, ie 11 | # 12 | # {:ok, assign(socket, :user_id, verified_user_id)} 13 | # 14 | # To deny connection, return `:error`. 15 | # 16 | # See `Phoenix.Token` documentation for examples in 17 | # performing token verification on connect. 18 | @impl true 19 | def connect(_params, socket, _connect_info) do 20 | {:ok, socket} 21 | end 22 | 23 | # Socket id's are topics that allow you to identify all sockets for a given user: 24 | # 25 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 26 | # 27 | # Would allow you to broadcast a "disconnect" event and terminate 28 | # all active sockets and channels for a given user: 29 | # 30 | # PhoenixStarterWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 31 | # 32 | # Returning `nil` makes this socket anonymous. 33 | @impl true 34 | def id(_socket), do: nil 35 | end 36 | -------------------------------------------------------------------------------- /test/phoenix_starter_web/plugs/require_user_permission_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.RequireUserPermissionTest do 2 | use PhoenixStarterWeb.ConnCase 3 | 4 | import PhoenixStarter.UsersFixtures 5 | 6 | alias PhoenixStarterWeb.RequireUserPermission 7 | 8 | test "init raises without permission option" do 9 | assert_raise ArgumentError, fn -> 10 | RequireUserPermission.init([]) 11 | end 12 | end 13 | 14 | test "redirects if permission not given" do 15 | user = user_fixture() 16 | 17 | conn = 18 | build_conn() 19 | |> log_in_user(user) 20 | |> bypass_through(PhoenixStarterWeb.Router, [:browser]) 21 | |> get("/") 22 | |> RequireUserPermission.call(permission: "ops.dashboard") 23 | 24 | assert conn.halted 25 | assert redirected_to(conn) == "/" 26 | assert get_flash(conn, :error) == "You are not authorized to perform this action." 27 | end 28 | 29 | test "doesn't redirect if permission given" do 30 | user = user_fixture(%{role: :ops_admin}) 31 | 32 | conn = 33 | build_conn() 34 | |> log_in_user(user) 35 | |> bypass_through(PhoenixStarterWeb.Router, [:browser]) 36 | |> get("/") 37 | 38 | assert RequireUserPermission.call(conn, permission: "ops.dashboard") == conn 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/plugs/require_user_permission.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.RequireUserPermission do 2 | @moduledoc """ 3 | This `Plug` requires the `current_user` to have the correct permissions and redirects otherwise. 4 | 5 | Permissions map to `PhoenixStarter.Users.UserRole`. 6 | 7 | ## Usage 8 | 9 | ``` 10 | plug RequireUserPermission, permission: "me.update_profile" 11 | ``` 12 | """ 13 | import PhoenixStarterWeb.Gettext 14 | import Plug.Conn 15 | import Phoenix.Controller 16 | 17 | alias PhoenixStarter.Users 18 | 19 | def init(opts) do 20 | permission = Keyword.get(opts, :permission, nil) 21 | 22 | if !is_binary(permission) do 23 | raise ArgumentError, """ 24 | PhoenixStarterWeb.RequireUserPermission must have a `permission` option. 25 | For example: 26 | 27 | plug RequireUserPermission, permission: "me.update_profile" 28 | """ 29 | end 30 | end 31 | 32 | def call(%Plug.Conn{assigns: %{current_user: user}} = conn, permission: permission) do 33 | if Users.permitted?(user, permission) do 34 | conn 35 | else 36 | conn 37 | |> put_flash(:error, gettext("You are not authorized to perform this action.")) 38 | |> redirect(to: "/") 39 | |> halt() 40 | end 41 | end 42 | 43 | def call(conn, _), do: conn 44 | end 45 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.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 data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use PhoenixStarterWeb.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import PhoenixStarterWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint PhoenixStarterWeb.Endpoint 28 | end 29 | end 30 | 31 | setup tags do 32 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(PhoenixStarter.Repo) 33 | 34 | unless tags[:async] do 35 | Ecto.Adapters.SQL.Sandbox.mode(PhoenixStarter.Repo, {:shared, self()}) 36 | end 37 | 38 | :ok 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /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 | # PhoenixStarter.Repo.insert!(%PhoenixStarter.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | 13 | alias PhoenixStarter.Repo 14 | alias PhoenixStarter.Users 15 | 16 | valid_password = "password1234" 17 | 18 | defmodule SeedHelpers do 19 | def confirm_user({:ok, user} = result) do 20 | user |> Users.User.confirm_changeset() |> Repo.update!() 21 | result 22 | end 23 | 24 | def confirm_user(result), do: result 25 | end 26 | 27 | {:ok, _} = 28 | %{ 29 | email: "ops_admin@example.com", 30 | password: valid_password, 31 | role: :ops_admin 32 | } 33 | |> Users.register_user() 34 | |> SeedHelpers.confirm_user() 35 | 36 | for i <- 0..9 do 37 | {:ok, _} = 38 | %{email: "admin_#{i}@example.com", password: valid_password, role: :admin} 39 | |> Users.register_user() 40 | |> SeedHelpers.confirm_user() 41 | end 42 | 43 | for i <- 0..9 do 44 | {:ok, _} = 45 | %{email: "user_#{i}@example.com", password: valid_password, role: :user} 46 | |> Users.register_user() 47 | |> SeedHelpers.confirm_user() 48 | end 49 | -------------------------------------------------------------------------------- /lib/phoenix_starter/workers/user_email_worker.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Workers.UserEmailWorker do 2 | @moduledoc """ 3 | Schedules an email from `PhoenixStarter.Users.UserEmail` to be sent. 4 | """ 5 | use Oban.Worker, queue: :emails, tags: ["email", "user-email"] 6 | 7 | alias PhoenixStarter.Users 8 | alias PhoenixStarter.Users.UserEmail 9 | alias PhoenixStarter.Mailer 10 | 11 | @impl true 12 | @spec perform(Oban.Job.t()) :: Oban.Worker.result() 13 | def perform(%Oban.Job{args: %{"email" => email} = args}) do 14 | if email in emails() do 15 | perform_email(args) 16 | else 17 | {:discard, :invalid_email} 18 | end 19 | end 20 | 21 | defp emails do 22 | :functions 23 | |> UserEmail.__info__() 24 | |> Keyword.keys() 25 | |> Enum.reject(fn f -> f == :render end) 26 | |> Enum.map(&Atom.to_string/1) 27 | end 28 | 29 | defp perform_email(args) do 30 | {email, args} = Map.pop(args, "email") 31 | 32 | %{"user_id" => user_id, "url" => url} = args 33 | user = Users.get_user!(user_id) 34 | 35 | email = apply(UserEmail, String.to_existing_atom(email), [user, url]) 36 | Mailer.deliver(email) 37 | {:ok, email} 38 | end 39 | 40 | defimpl PhoenixStarter.Workers.Reportable do 41 | @threshold 3 42 | 43 | @spec reportable?(Oban.Worker.t(), integer) :: boolean 44 | def reportable?(_worker, attempt), do: attempt > @threshold 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/phoenix_starter/users/user_email.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Users.UserEmail do 2 | @moduledoc """ 3 | Emails various notifications to `PhoenixStarter.Users.User`s. 4 | """ 5 | use Phoenix.Swoosh, view: PhoenixStarterWeb.UserEmailView 6 | 7 | import PhoenixStarterWeb.Gettext 8 | 9 | alias PhoenixStarter.Users.User 10 | 11 | @spec confirmation_instructions(User.t(), String.t()) :: Swoosh.Email.t() 12 | def confirmation_instructions(user, url) do 13 | user 14 | |> base_email() 15 | |> subject(gettext("Confirm your account")) 16 | |> assign(:user, user) 17 | |> assign(:url, url) 18 | |> render_body(:confirmation_instructions) 19 | end 20 | 21 | @spec reset_password_instructions(User.t(), String.t()) :: Swoosh.Email.t() 22 | def reset_password_instructions(user, url) do 23 | user 24 | |> base_email() 25 | |> subject(gettext("Reset your password")) 26 | |> assign(:user, user) 27 | |> assign(:url, url) 28 | |> render_body(:reset_password_instructions) 29 | end 30 | 31 | @spec update_email_instructions(User.t(), String.t()) :: Swoosh.Email.t() 32 | def update_email_instructions(user, url) do 33 | user 34 | |> base_email() 35 | |> subject(gettext("Confirm email change")) 36 | |> assign(:user, user) 37 | |> assign(:url, url) 38 | |> render_body(:update_email_instructions) 39 | end 40 | 41 | defp base_email(admin) do 42 | new() 43 | |> from(PhoenixStarter.Email.default_from()) 44 | |> to(admin) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/phoenix_starter_web/live/user_settings/email_component_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserSettingsLive.EmailComponentTest do 2 | use PhoenixStarterWeb.ConnCase 3 | 4 | import Phoenix.LiveViewTest 5 | import PhoenixStarter.UsersFixtures 6 | 7 | alias PhoenixStarter.Users 8 | 9 | @form_id "#form__update-email" 10 | 11 | setup [:register_and_log_in_user] 12 | 13 | test "validates", %{conn: conn, user: user} do 14 | {:ok, live, _html} = live(conn, Routes.user_settings_path(conn, :email)) 15 | 16 | error_html = 17 | assert live 18 | |> form(@form_id, user: %{email: user.email}, current_password: "") 19 | |> render_change() 20 | 21 | assert error_html =~ "did not change" 22 | assert error_html =~ "is not valid" 23 | end 24 | 25 | test "saves", %{conn: conn, user: user} do 26 | {:ok, live, _html} = live(conn, Routes.user_settings_path(conn, :email)) 27 | 28 | # Must render change first to persist changeset 29 | _html = 30 | live 31 | |> form(@form_id, 32 | user: %{email: "skywalker@example.com"}, 33 | current_password: valid_user_password() 34 | ) 35 | |> render_change() 36 | 37 | html = 38 | live 39 | |> form(@form_id, 40 | user: %{email: "skywalker@example.com"}, 41 | current_password: valid_user_password() 42 | ) 43 | |> render_submit() 44 | 45 | user = Users.get_user_by_email(user.email) 46 | assert user.email != "skywalker@example.com" 47 | 48 | assert html =~ "A link to confirm your e-mail change has been sent to the new address." 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "description": " ", 4 | "license": "MIT", 5 | "scripts": { 6 | "deploy": "webpack --mode production", 7 | "watch": "webpack --mode development --watch" 8 | }, 9 | "dependencies": { 10 | "alpinejs": "^2.8.2", 11 | "nprogress": "^0.2.0", 12 | "phoenix": "file:../deps/phoenix", 13 | "phoenix_html": "file:../deps/phoenix_html", 14 | "phoenix_live_view": "file:../deps/phoenix_live_view", 15 | "tailwindcss": "^1.9.6" 16 | }, 17 | "devDependencies": { 18 | "@babel/core": "^7.14.3", 19 | "@babel/preset-env": "^7.14.2", 20 | "@types/nprogress": "^0.2.0", 21 | "@types/phoenix": "^1.5.1", 22 | "@types/phoenix_live_view": "^0.15.0", 23 | "@typescript-eslint/eslint-plugin": "^4.25.0", 24 | "@typescript-eslint/parser": "^4.25.0", 25 | "babel-loader": "^8.2.2", 26 | "copy-webpack-plugin": "^6.4.1", 27 | "css-loader": "^5.2.6", 28 | "eslint": "^7.27.0", 29 | "eslint-config-prettier": "^8.3.0", 30 | "eslint-plugin-import": "^2.23.3", 31 | "eslint-plugin-prettier": "^3.4.0", 32 | "hard-source-webpack-plugin": "^0.13.1", 33 | "mini-css-extract-plugin": "^1.6.0", 34 | "optimize-css-assets-webpack-plugin": "^6.0.0", 35 | "postcss": "^8.3.0", 36 | "postcss-import": "^14.0.2", 37 | "postcss-loader": "^4.3.0", 38 | "postcss-nested": "^5.0.5", 39 | "prettier": "2.3.0", 40 | "stylelint": "^13.13.1", 41 | "stylelint-config-standard": "^22.0.0", 42 | "stylelint-order": "^4.1.0", 43 | "terser-webpack-plugin": "^4.2.3", 44 | "ts-loader": "^8.3.0", 45 | "typescript": "^4.2.4", 46 | "webpack": "4.46.0", 47 | "webpack-cli": "^4.7.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ELIXIR_VERSION=1.11.0 2 | ARG ERLANG_VERSION=23.1.1 3 | ARG ALPINE_VERSION=3.12.0 4 | ARG NODE_VERSION=14.13.1 5 | 6 | # OTP env 7 | # -------- 8 | 9 | FROM hexpm/elixir:${ELIXIR_VERSION}-erlang-${ERLANG_VERSION}-alpine-${ALPINE_VERSION} AS otp-build 10 | 11 | RUN apk --no-cache --update add \ 12 | build-base \ 13 | git && \ 14 | mix local.rebar --force && \ 15 | mix local.hex --force 16 | 17 | # Deps builder 18 | # ------------ 19 | 20 | FROM otp-build AS deps 21 | 22 | ENV MIX_ENV=prod 23 | 24 | WORKDIR /opt/app 25 | 26 | COPY config config 27 | COPY mix.* ./ 28 | RUN mix do deps.get --only=$MIX_ENV, deps.compile 29 | 30 | # Assets builder 31 | # --------------- 32 | 33 | FROM node:${NODE_VERSION}-alpine AS assets-build 34 | 35 | ENV NODE_ENV=prod 36 | 37 | WORKDIR /opt/app 38 | 39 | COPY --from=deps /opt/app/deps deps 40 | COPY assets assets 41 | RUN npm --prefix assets ci && npm run --prefix assets deploy 42 | 43 | # Release builder 44 | # --------------- 45 | 46 | FROM deps AS release 47 | 48 | ENV MIX_ENV=prod 49 | 50 | WORKDIR /opt/app 51 | 52 | COPY --from=assets-build /opt/app/priv/static priv/static 53 | RUN mix phx.digest 54 | 55 | COPY . . 56 | RUN mix do compile, deps.compile sentry --force, release --quiet 57 | 58 | # App final 59 | # --------- 60 | 61 | FROM alpine:${ALPINE_VERSION} AS app 62 | 63 | ARG PORT=4000 64 | ENV HOME=/opt/app 65 | ENV PORT=${PORT} 66 | 67 | RUN apk --no-cache --update add \ 68 | bash \ 69 | openssl 70 | 71 | WORKDIR /opt/app 72 | 73 | COPY --from=release /opt/app/_build/prod/rel/phoenix_starter ./ 74 | RUN chown -R nobody: /opt/app 75 | USER nobody 76 | 77 | EXPOSE ${PORT} 78 | 79 | ENTRYPOINT ["/opt/app/docker-entrypoint.sh"] 80 | CMD ["start"] 81 | -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const glob = require("glob") 3 | const HardSourceWebpackPlugin = require("hard-source-webpack-plugin") 4 | const MiniCssExtractPlugin = require("mini-css-extract-plugin") 5 | const TerserPlugin = require("terser-webpack-plugin") 6 | const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin") 7 | const CopyWebpackPlugin = require("copy-webpack-plugin") 8 | 9 | module.exports = (env, options) => { 10 | const devMode = options.mode !== "production" 11 | 12 | return { 13 | optimization: { 14 | minimizer: [ 15 | new TerserPlugin({ cache: true, parallel: true, sourceMap: devMode }), 16 | new OptimizeCSSAssetsPlugin({}), 17 | ], 18 | }, 19 | entry: { 20 | app: glob.sync("./vendor/**/*.js").concat(["./js/app.ts"]), 21 | }, 22 | output: { 23 | filename: "[name].js", 24 | path: path.resolve(__dirname, "../priv/static/js"), 25 | publicPath: "/js/", 26 | }, 27 | devtool: devMode ? "eval-cheap-module-source-map" : undefined, 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.ts$/, 32 | exclude: /node_modules/, 33 | use: { 34 | loader: "ts-loader", 35 | }, 36 | }, 37 | { 38 | test: /\.css$/, 39 | use: [MiniCssExtractPlugin.loader, "css-loader", "postcss-loader"], 40 | }, 41 | ], 42 | }, 43 | resolve: { 44 | extensions: [".ts"], 45 | }, 46 | plugins: [ 47 | new MiniCssExtractPlugin({ filename: "../css/app.css" }), 48 | new CopyWebpackPlugin({ patterns: [{ from: "static/", to: "../" }] }), 49 | ].concat(devMode ? [new HardSourceWebpackPlugin()] : []), 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/controllers/user_settings_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserSettingsController do 2 | use PhoenixStarterWeb, :controller 3 | 4 | alias PhoenixStarter.Users 5 | alias PhoenixStarterWeb.UserAuth 6 | 7 | plug :assign_email_and_password_changesets 8 | 9 | def update_password(conn, %{"current_password" => password, "user" => user_params}) do 10 | user = conn.assigns.current_user 11 | 12 | case Users.update_user_password(user, password, user_params) do 13 | {:ok, user} -> 14 | conn 15 | |> put_flash(:info, "Password updated successfully.") 16 | |> put_session(:user_return_to, Routes.user_settings_path(conn, :password)) 17 | |> UserAuth.log_in_user(user) 18 | 19 | _ -> 20 | conn 21 | |> put_flash(:error, "We were unable to update your password. Please try again.") 22 | |> redirect(to: Routes.user_settings_path(conn, :password)) 23 | end 24 | end 25 | 26 | def confirm_email(conn, %{"token" => token}) do 27 | case Users.update_user_email(conn.assigns.current_user, token) do 28 | :ok -> 29 | conn 30 | |> put_flash(:info, "Email changed successfully.") 31 | |> redirect(to: Routes.user_settings_path(conn, :email)) 32 | 33 | :error -> 34 | conn 35 | |> put_flash(:error, "Email change link is invalid or it has expired.") 36 | |> redirect(to: Routes.user_settings_path(conn, :email)) 37 | end 38 | end 39 | 40 | defp assign_email_and_password_changesets(conn, _opts) do 41 | user = conn.assigns.current_user 42 | 43 | conn 44 | |> assign(:email_changeset, Users.change_user_email(user)) 45 | |> assign(:password_changeset, Users.change_user_password(user)) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/phoenix_starter_web/live/user_settings/password_component_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserSettingsLive.PasswordComponentTest do 2 | use PhoenixStarterWeb.ConnCase 3 | 4 | import Phoenix.LiveViewTest 5 | import PhoenixStarter.UsersFixtures 6 | 7 | @form_id "#form__update-password" 8 | 9 | setup [:register_and_log_in_user] 10 | 11 | test "validates", %{conn: conn} do 12 | {:ok, live, _html} = live(conn, Routes.user_settings_path(conn, :password)) 13 | 14 | error_html = 15 | assert live 16 | |> form(@form_id, 17 | user: %{password: "password", password_confirmation: "new password"}, 18 | current_password: "" 19 | ) 20 | |> render_change() 21 | 22 | assert error_html =~ "does not match password" 23 | assert error_html =~ "is not valid" 24 | end 25 | 26 | test "saves", %{conn: conn} do 27 | {:ok, live, _html} = live(conn, Routes.user_settings_path(conn, :password)) 28 | 29 | # Must render change first to persist changeset 30 | _html = 31 | live 32 | |> form(@form_id, 33 | user: %{password: "new password", password_confirmation: "new password"}, 34 | current_password: valid_user_password() 35 | ) 36 | |> render_change() 37 | 38 | form = 39 | form(live, @form_id, 40 | user: %{password: "new password", password_confirmation: "new password"}, 41 | current_password: valid_user_password(), 42 | _method: "put" 43 | ) 44 | 45 | assert render_submit(form) =~ ~r/phx-trigger-action/ 46 | 47 | conn = follow_trigger_action(form, conn) 48 | 49 | assert redirected_to(conn) =~ Routes.user_settings_path(conn, :password) 50 | assert get_flash(conn, :info) =~ "Password updated successfully." 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/phoenix_starter_web/controllers/user_registration_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserRegistrationControllerTest do 2 | use PhoenixStarterWeb.ConnCase, async: true 3 | 4 | import PhoenixStarter.UsersFixtures 5 | 6 | describe "GET /users/register" do 7 | test "renders registration page", %{conn: conn} do 8 | conn = get(conn, Routes.user_registration_path(conn, :new)) 9 | response = html_response(conn, 200) 10 | assert response =~ "

Register

" 11 | end 12 | 13 | test "redirects if already logged in", %{conn: conn} do 14 | conn = conn |> log_in_user(user_fixture()) |> get(Routes.user_registration_path(conn, :new)) 15 | assert redirected_to(conn) == "/" 16 | end 17 | end 18 | 19 | describe "POST /users/register" do 20 | @tag :capture_log 21 | test "creates account and logs the user in", %{conn: conn} do 22 | email = unique_user_email() 23 | 24 | conn = 25 | post(conn, Routes.user_registration_path(conn, :create), %{ 26 | "user" => %{"email" => email, "password" => valid_user_password()} 27 | }) 28 | 29 | assert get_session(conn, :user_token) 30 | assert redirected_to(conn) =~ "/" 31 | 32 | # Now do a logged in request and assert on the menu 33 | conn = get(conn, "/") 34 | assert html_response(conn, 200) 35 | end 36 | 37 | test "render errors for invalid data", %{conn: conn} do 38 | conn = 39 | post(conn, Routes.user_registration_path(conn, :create), %{ 40 | "user" => %{"email" => "with spaces", "password" => "too short"} 41 | }) 42 | 43 | response = html_response(conn, 200) 44 | assert response =~ "

Register

" 45 | assert response =~ "must have the @ sign and no spaces" 46 | assert response =~ "should be at least 12 character" 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # General application configuration 2 | import Config 3 | 4 | config :phoenix_starter, 5 | ecto_repos: [PhoenixStarter.Repo], 6 | generators: [binary_id: true] 7 | 8 | # Configures the endpoint 9 | config :phoenix_starter, PhoenixStarterWeb.Endpoint, 10 | url: [host: "localhost"], 11 | secret_key_base: "7nP1poUpni9iUuIk8xM3pAmbRgwYGZjBfUdh5NgjRX92w/20ndnn7s6x69rFzBxB", 12 | render_errors: [view: PhoenixStarterWeb.ErrorView, accepts: ~w(html json), layout: false], 13 | pubsub_server: PhoenixStarter.PubSub, 14 | live_view: [signing_salt: "uqk1W4Ui"] 15 | 16 | # Configures Elixir's Logger 17 | config :logger, :console, 18 | format: "$time $metadata[$level] $message\n", 19 | metadata: [:request_id] 20 | 21 | # Use Jason for JSON parsing in Phoenix 22 | config :phoenix, :json_library, Jason 23 | 24 | # Configures Bamboo 25 | # Note: by default this reads from the IAM task or instance role 26 | config :phoenix_starter, PhoenixStarter.Email, 27 | default_from: {"PhoenixStarter", "notifications@example.com"} 28 | 29 | # Configures Oban 30 | config :phoenix_starter, Oban, 31 | repo: PhoenixStarter.Repo, 32 | plugins: [Oban.Plugins.Pruner], 33 | queues: [default: 10, emails: 10] 34 | 35 | # Configures Sentry 36 | config :sentry, 37 | enable_source_code_context: true, 38 | included_environments: ~w(prod stage), 39 | release: Mix.Project.config()[:version], 40 | root_source_code_path: File.cwd!() 41 | 42 | # Configures ExAWS 43 | # Note: by default this reads credentials from ENV vars then task role 44 | config :ex_aws, 45 | region: "us-east-1" 46 | 47 | # Configures Uploads 48 | config :phoenix_starter, PhoenixStarter.Uploads, 49 | bucket_name: "#{config_env()}-phoenix-starter-uploads" 50 | 51 | # Import environment specific config. This must remain at the bottom 52 | # of this file so it overrides the configuration defined above. 53 | import_config "#{config_env()}.exs" 54 | -------------------------------------------------------------------------------- /lib/phoenix_starter/application.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | attach_telemetry_handlers() 11 | 12 | children = [ 13 | # Start the Ecto repository 14 | PhoenixStarter.Repo, 15 | # Start the Telemetry supervisor 16 | PhoenixStarterWeb.Telemetry, 17 | # Start the PubSub system 18 | {Phoenix.PubSub, name: PhoenixStarter.PubSub}, 19 | # Start the Endpoint (http/https) 20 | PhoenixStarterWeb.Endpoint, 21 | # Start a worker by calling: PhoenixStarter.Worker.start_link(arg) 22 | # {PhoenixStarter.Worker, arg} 23 | {Oban, oban_config()}, 24 | {Cluster.Supervisor, [cluster_config(), [name: PhoenixStarter.ClusterSupervisor]]} 25 | ] 26 | 27 | # See https://hexdocs.pm/elixir/Supervisor.html 28 | # for other strategies and supported options 29 | opts = [strategy: :one_for_one, name: PhoenixStarter.Supervisor] 30 | Supervisor.start_link(children, opts) 31 | end 32 | 33 | # Tell Phoenix to update the endpoint configuration 34 | # whenever the application is updated. 35 | @impl true 36 | def config_change(changed, _new, removed) do 37 | PhoenixStarterWeb.Endpoint.config_change(changed, removed) 38 | :ok 39 | end 40 | 41 | defp attach_telemetry_handlers do 42 | :telemetry.attach_many( 43 | "oban-errors", 44 | [[:oban, :job, :exception], [:oban, :circuit, :trip]], 45 | &PhoenixStarter.Workers.ErrorReporter.handle_event/4, 46 | %{} 47 | ) 48 | end 49 | 50 | defp oban_config do 51 | Application.get_env(:phoenix_starter, Oban) 52 | end 53 | 54 | defp cluster_config do 55 | Application.get_env(:phoenix_starter, PhoenixStarter.ClusterSupervisor, []) 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/controllers/user_confirmation_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserConfirmationController do 2 | use PhoenixStarterWeb, :controller 3 | 4 | alias PhoenixStarter.Users 5 | 6 | def new(conn, _params) do 7 | render(conn, "new.html") 8 | end 9 | 10 | def create(conn, %{"user" => %{"email" => email}}) do 11 | if user = Users.get_user_by_email(email) do 12 | Users.deliver_user_confirmation_instructions( 13 | user, 14 | &Routes.user_confirmation_url(conn, :confirm, &1) 15 | ) 16 | end 17 | 18 | # Regardless of the outcome, show an impartial success/error message. 19 | conn 20 | |> put_flash( 21 | :info, 22 | "If your email is in our system and it has not been confirmed yet, " <> 23 | "you will receive an email with instructions shortly." 24 | ) 25 | |> redirect(to: "/") 26 | end 27 | 28 | # Do not log in the user after confirmation to avoid a 29 | # leaked token giving the user access to the account. 30 | def confirm(conn, %{"token" => token}) do 31 | case Users.confirm_user(token) do 32 | {:ok, _} -> 33 | conn 34 | |> put_flash(:info, "Account confirmed successfully.") 35 | |> redirect(to: "/") 36 | 37 | :error -> 38 | # If there is a current user and the account was already confirmed, 39 | # then odds are that the confirmation link was already visited, either 40 | # by some automation or by the user themselves, so we redirect without 41 | # a warning message. 42 | 43 | case conn.assigns do 44 | %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> 45 | redirect(conn, to: "/") 46 | 47 | %{} -> 48 | conn 49 | |> put_flash(:error, "Account confirmation link is invalid or it has expired.") 50 | |> redirect(to: "/") 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.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 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use PhoenixStarter.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | use Oban.Testing, repo: PhoenixStarter.Repo 22 | 23 | alias PhoenixStarter.Repo 24 | 25 | import Ecto 26 | import Ecto.Changeset 27 | import Ecto.Query 28 | import PhoenixStarter.DataCase 29 | end 30 | end 31 | 32 | setup tags do 33 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(PhoenixStarter.Repo) 34 | 35 | unless tags[:async] do 36 | Ecto.Adapters.SQL.Sandbox.mode(PhoenixStarter.Repo, {:shared, self()}) 37 | end 38 | 39 | :ok 40 | end 41 | 42 | @doc """ 43 | A helper that transforms changeset errors into a map of messages. 44 | 45 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 46 | assert "password is too short" in errors_on(changeset).password 47 | assert %{password: ["password is too short"]} = errors_on(changeset) 48 | 49 | """ 50 | @spec errors_on(Ecto.Changeset.t()) :: %{atom() => list(String.t())} 51 | def errors_on(changeset) do 52 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 53 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 54 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 55 | end) 56 | end) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :phoenix_starter, PhoenixStarter.Repo, 5 | database: "phoenix_starter_dev", 6 | hostname: "localhost", 7 | show_sensitive_data_on_connection_error: true, 8 | pool_size: 10 9 | 10 | web_port = 4000 11 | 12 | # For development, we disable any cache and enable 13 | # debugging and code reloading. 14 | config :phoenix_starter, PhoenixStarterWeb.Endpoint, 15 | http: [port: web_port], 16 | debug_errors: true, 17 | code_reloader: true, 18 | check_origin: false, 19 | watchers: [ 20 | node: [ 21 | "node_modules/webpack/bin/webpack.js", 22 | "--mode", 23 | "development", 24 | "--watch", 25 | cd: Path.expand("../assets", __DIR__) 26 | ] 27 | ] 28 | 29 | # Watch static and templates for browser reloading. 30 | config :phoenix_starter, PhoenixStarterWeb.Endpoint, 31 | live_reload: [ 32 | patterns: [ 33 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 34 | ~r"priv/gettext/.*(po)$", 35 | ~r"lib/phoenix_starter_web/(live|views)/.*(ex)$", 36 | ~r"lib/phoenix_starter_web/templates/.*(heex)$" 37 | ] 38 | ] 39 | 40 | # Do not include metadata nor timestamps in development logs 41 | config :logger, :console, format: "[$level] $message\n" 42 | 43 | # Set a higher stacktrace during development. Avoid configuring such 44 | # in production as building large stacktraces may be expensive. 45 | config :phoenix, :stacktrace_depth, 20 46 | 47 | # Initialize plugs at runtime for faster development compilation 48 | config :phoenix, :plug_init_mode, :runtime 49 | 50 | # Configures Swoosh 51 | config :phoenix_starter, PhoenixStarter.Mailer, adapter: Swoosh.Adapters.Local 52 | 53 | # Configures Sentry 54 | config :sentry, environment_name: "dev" 55 | 56 | # Config Content Security Policy 57 | config :phoenix_starter, PhoenixStarterWeb.ContentSecurityPolicy, 58 | allow_unsafe: true, 59 | app_host: "localhost:#{web_port}" 60 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.Endpoint do 2 | use Sentry.PlugCapture 3 | use Phoenix.Endpoint, otp_app: :phoenix_starter 4 | 5 | # The session will be stored in the cookie and signed, 6 | # this means its contents can be read but not tampered with. 7 | # Set :encryption_salt if you would also like to encrypt it. 8 | @session_options [ 9 | store: :cookie, 10 | key: "_phoenix_starter_key", 11 | signing_salt: "F13uOVCv" 12 | ] 13 | 14 | socket "/socket", PhoenixStarterWeb.UserSocket, 15 | websocket: true, 16 | longpoll: false 17 | 18 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 19 | 20 | # Serve at "/" the static files from "priv/static" directory. 21 | # 22 | # You should set gzip to true if you are running phx.digest 23 | # when deploying your static files in production. 24 | plug Plug.Static, 25 | at: "/", 26 | from: :phoenix_starter, 27 | gzip: false, 28 | only: ~w(css fonts images js favicon.ico robots.txt) 29 | 30 | # Code reloading can be explicitly enabled under the 31 | # :code_reloader configuration of your endpoint. 32 | if code_reloading? do 33 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 34 | plug Phoenix.LiveReloader 35 | plug Phoenix.CodeReloader 36 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :phoenix_starter 37 | end 38 | 39 | plug Phoenix.LiveDashboard.RequestLogger, 40 | param_key: "request_logger", 41 | cookie_key: "request_logger" 42 | 43 | plug Plug.RequestId 44 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 45 | 46 | plug Plug.Parsers, 47 | parsers: [:urlencoded, :multipart, :json], 48 | pass: ["*/*"], 49 | json_decoder: Phoenix.json_library() 50 | 51 | plug Sentry.PlugContext 52 | plug Plug.MethodOverride 53 | plug Plug.Head 54 | plug Plug.Session, @session_options 55 | plug PhoenixStarterWeb.Router 56 | end 57 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | alias Phoenix.HTML 9 | 10 | @doc """ 11 | Generates tag for inlined form input errors. 12 | """ 13 | @spec error_tag(HTML.Form.t(), Keyword.key()) :: [HTML.safe()] 14 | def error_tag(form, field) do 15 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 16 | content_tag(:span, translate_error(error), 17 | class: "text-red-800 block -mt-4 mb-8 invalid-feedback", 18 | phx_feedback_for: input_id(form, field) 19 | ) 20 | end) 21 | end 22 | 23 | @doc """ 24 | Translates an error message using gettext. 25 | """ 26 | @spec translate_error({binary, Gettext.bindings()}) :: binary 27 | def translate_error({msg, opts}) do 28 | # When using gettext, we typically pass the strings we want 29 | # to translate as a static argument: 30 | # 31 | # # Translate "is invalid" in the "errors" domain 32 | # dgettext("errors", "is invalid") 33 | # 34 | # # Translate the number of files with plural rules 35 | # dngettext("errors", "1 file", "%{count} files", count) 36 | # 37 | # Because the error messages we show in our forms and APIs 38 | # are defined inside Ecto, we need to translate them dynamically. 39 | # This requires us to call the Gettext module passing our gettext 40 | # backend as first argument. 41 | # 42 | # Note we use the "errors" domain, which means translations 43 | # should be written to the errors.po file. The :count option is 44 | # set by Ecto and indicates we should also apply plural rules. 45 | if count = opts[:count] do 46 | Gettext.dngettext(PhoenixStarterWeb.Gettext, "errors", msg, msg, count, opts) 47 | else 48 | Gettext.dgettext(PhoenixStarterWeb.Gettext, "errors", msg, opts) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/controllers/user_reset_password_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserResetPasswordController do 2 | use PhoenixStarterWeb, :controller 3 | 4 | alias PhoenixStarter.Users 5 | 6 | plug :get_user_by_reset_password_token when action in [:edit, :update] 7 | 8 | def new(conn, _params) do 9 | render(conn, "new.html") 10 | end 11 | 12 | def create(conn, %{"user" => %{"email" => email}}) do 13 | if user = Users.get_user_by_email(email) do 14 | Users.deliver_user_reset_password_instructions( 15 | user, 16 | &Routes.user_reset_password_url(conn, :edit, &1) 17 | ) 18 | end 19 | 20 | # Regardless of the outcome, show an impartial success/error message. 21 | conn 22 | |> put_flash( 23 | :info, 24 | "If your email is in our system, you will receive instructions to reset your password shortly." 25 | ) 26 | |> redirect(to: "/") 27 | end 28 | 29 | def edit(conn, _params) do 30 | render(conn, "edit.html", changeset: Users.change_user_password(conn.assigns.user)) 31 | end 32 | 33 | # Do not log in the user after reset password to avoid a 34 | # leaked token giving the user access to the account. 35 | def update(conn, %{"user" => user_params}) do 36 | case Users.reset_user_password(conn.assigns.user, user_params) do 37 | {:ok, _} -> 38 | conn 39 | |> put_flash(:info, "Password reset successfully.") 40 | |> redirect(to: Routes.user_session_path(conn, :new)) 41 | 42 | {:error, changeset} -> 43 | render(conn, "edit.html", changeset: changeset) 44 | end 45 | end 46 | 47 | defp get_user_by_reset_password_token(conn, _opts) do 48 | %{"token" => token} = conn.params 49 | 50 | if user = Users.get_user_by_reset_password_token(token) do 51 | conn |> assign(:user, user) |> assign(:token, token) 52 | else 53 | conn 54 | |> put_flash(:error, "Reset password link is invalid or it has expired.") 55 | |> redirect(to: "/") 56 | |> halt() 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.LayoutView do 2 | use PhoenixStarterWeb, :view 3 | 4 | alias Phoenix.HTML 5 | alias PhoenixStarter.Users 6 | 7 | @type flash_key() :: :info | :warning | :error 8 | 9 | @doc """ 10 | Returns HTML for an alert banner based on a `%Plug.Conn{}` struct or a 11 | LiveView flash assign. 12 | 13 | The outermost tag is assigned a set of CSS utility `class`es that style the 14 | alert appropriately based on the given `flash_key`. 15 | """ 16 | @spec alert(Plug.Conn.t() | map(), flash_key()) :: HTML.safe() 17 | def alert(%Plug.Conn{} = conn, flash_key) do 18 | case get_flash(conn, flash_key) do 19 | msg when is_binary(msg) -> 20 | content_tag(:p, msg, role: "alert", class: alert_class(flash_key)) 21 | 22 | _ -> 23 | nil 24 | end 25 | end 26 | 27 | def alert(flash, flash_key) do 28 | case live_flash(flash, flash_key) do 29 | msg when is_binary(msg) -> lv_alert(flash_key, msg) 30 | _ -> nil 31 | end 32 | end 33 | 34 | @spec lv_alert(flash_key(), String.t()) :: HTML.safe() 35 | # sobelow_skip ["XSS.Raw"] 36 | defp lv_alert(flash_key, msg) do 37 | safe_msg = 38 | msg 39 | |> HTML.html_escape() 40 | |> safe_to_string() 41 | 42 | HTML.raw(""" 43 | 50 | """) 51 | end 52 | 53 | @alert_class "border border-transparent rounded mb-5 p-4" 54 | 55 | @spec alert_class(flash_key()) :: String.t() 56 | defp alert_class(:info), do: @alert_class <> " bg-blue-100 border-blue-200 text-blue-600" 57 | 58 | defp alert_class(:warning), 59 | do: @alert_class <> " bg-yellow-100 border-yellow-200 text-yellow-700" 60 | 61 | defp alert_class(:error), do: @alert_class <> " bg-red-200 border-red-300 text-red-800" 62 | 63 | @spec permitted?(Users.User.t(), String.t()) :: boolean 64 | def permitted?(user, permission) do 65 | Users.permitted?(user, permission) 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /bin/init.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | print_step() { 6 | echo 7 | echo "==> $1" 8 | } 9 | 10 | print_success() { 11 | echo 12 | echo -e "\033[32m> Success:\033[0m $1" 13 | } 14 | 15 | print_error() { 16 | echo -e "\033[31m! Error:\033[0m $1" 17 | } 18 | 19 | print_usage() { 20 | echo 21 | echo "Usage: $(basename "$0") pascal snake" 22 | echo " pascal: PascalCase version of new project name, ex: StormWeb" 23 | echo " snake: snake_case version of new project name, ex: storm_web" 24 | echo 25 | } 26 | 27 | rename() { 28 | ack -l "$1" --ignore-file=is:init.sh --ignore-file=match:/[.]beam$/ | \ 29 | xargs sed -i '' -e "s/$1/$2/g" 30 | } 31 | 32 | pascal_case_old="PhoenixStarter" 33 | snake_case_old="phoenix_starter" 34 | kebab_case_old="phoenix-starter" 35 | 36 | pascal_case_new=$1 37 | snake_case_new=$2 38 | kebab_case_new=${snake_case_new/_/-} 39 | 40 | if [[ ! -f "mix.lock" ]]; then 41 | print_error "Must be run from Phoenix root" 42 | exit 1 43 | fi 44 | 45 | if [[ -z $pascal_case_new ]]; then 46 | print_error "Missing \`pascal\` argument" 47 | print_usage 48 | exit 1 49 | fi 50 | 51 | if [[ -z $snake_case_new ]]; then 52 | print_error "Missing \`snake\` argument" 53 | print_usage 54 | exit 1 55 | fi 56 | 57 | echo -e "\033[47m\033[36m < NewAperio > \033[0m" 58 | echo "Phoenix Starter Initialization" 59 | echo "------------------------------" 60 | 61 | print_step "Renaming files" 62 | rename "$pascal_case_old" "$pascal_case_new" 63 | rename "$snake_case_old" "$snake_case_new" 64 | rename "$kebab_case_old" "$kebab_case_new" 65 | 66 | mv lib/"$snake_case_old" lib/"$snake_case_new" 67 | mv lib/"${snake_case_old}.ex" lib/"${snake_case_new}.ex" 68 | mv lib/"${snake_case_old}_web" lib/"${snake_case_new}_web" 69 | mv lib/"${snake_case_old}_web.ex" lib/"${snake_case_new}_web.ex" 70 | mv test/"${snake_case_old}_web" test/"${snake_case_new}_web" 71 | 72 | print_step "Copying blank README" 73 | mv README_starter.md README.md 74 | 75 | print_step "Removing starter specific files" 76 | rm CHANGELOG.md 77 | rm LICENSE 78 | 79 | print_step "Removing this script" 80 | rm bin/init.sh 81 | 82 | print_success "Complete!" 83 | -------------------------------------------------------------------------------- /assets/js/app.ts: -------------------------------------------------------------------------------- 1 | // We need to import the CSS so that webpack will load it. 2 | // The MiniCssExtractPlugin is used to separate it out into 3 | // its own CSS file. 4 | import "../css/app.css" 5 | 6 | // webpack automatically bundles all modules in your 7 | // entry points. Those entry points can be configured 8 | // in "webpack.config.js". 9 | // 10 | // Import deps with the dep name or local files with a relative path, for example: 11 | // 12 | // import {Socket} from "phoenix" 13 | // import socket from "./socket" 14 | // 15 | import "phoenix_html" 16 | import "alpinejs" 17 | import { Socket } from "phoenix" 18 | import NProgress from "nprogress" 19 | import { LiveSocket } from "phoenix_live_view" 20 | import { s3Uploader } from "./uploads" 21 | 22 | declare global { 23 | interface Window { 24 | Alpine: Alpine 25 | liveSocket: LiveSocket 26 | } 27 | } 28 | 29 | interface Alpine { 30 | clone: (from?: unknown, to?: HTMLElement) => unknown 31 | } 32 | 33 | interface AlpineHTMLElement { 34 | __x?: unknown 35 | } 36 | 37 | const csrfTokenTag = document.querySelector("meta[name='csrf-token']") 38 | const csrfToken = csrfTokenTag ? csrfTokenTag.getAttribute("content") : "" 39 | const liveSocket = new LiveSocket("/live", Socket, { 40 | dom: { 41 | onBeforeElUpdated(from, to) { 42 | if ((from as AlpineHTMLElement).__x) { 43 | window.Alpine.clone((from as AlpineHTMLElement).__x, to) 44 | return false 45 | } 46 | 47 | return false 48 | }, 49 | }, 50 | params: { _csrf_token: csrfToken }, 51 | uploaders: { S3: s3Uploader }, 52 | }) 53 | 54 | // Show progress bar on live navigation and form submits 55 | window.addEventListener("phx:page-loading-start", () => NProgress.start()) 56 | window.addEventListener("phx:page-loading-stop", () => NProgress.done()) 57 | 58 | // connect if there are any LiveViews on the page 59 | liveSocket.connect() 60 | 61 | // expose liveSocket on window for web console debug logs and latency simulation: 62 | // >> liveSocket.enableDebug() 63 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 64 | // >> liveSocket.disableLatencySim() 65 | window.liveSocket = liveSocket 66 | -------------------------------------------------------------------------------- /test/phoenix_starter_web/live/user_settings/profile_component_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserSettingsLive.ProfileComponentTest do 2 | use PhoenixStarterWeb.ConnCase 3 | 4 | import Phoenix.LiveViewTest 5 | 6 | @form_id "#form__update-profile" 7 | 8 | setup :register_and_log_in_user 9 | 10 | test "validates", %{conn: conn} do 11 | {:ok, live, _html} = live(conn, Routes.user_settings_path(conn, :profile)) 12 | 13 | profile_image = 14 | file_input(live, @form_id, :profile_image, [ 15 | %{ 16 | last_modified: 1_551_913_980, 17 | name: "profile-image.jpg", 18 | content: File.read!("./test/support/profile-image.jpg"), 19 | size: 100 * 1024 * 1024, 20 | type: "image/gif" 21 | } 22 | ]) 23 | 24 | assert {:error, [[_, :too_large]]} = preflight_upload(profile_image) 25 | 26 | assert live 27 | |> form(@form_id, user: %{}) 28 | |> render_change(profile_image) =~ "must be smaller than 25mb" 29 | 30 | profile_image = 31 | file_input(live, @form_id, :profile_image, [ 32 | %{ 33 | last_modified: 1_551_913_980, 34 | name: "profile-image.jpg", 35 | content: File.read!("./test/support/profile-image.jpg"), 36 | size: 2_169_900, 37 | type: "image/jpeg" 38 | } 39 | ]) 40 | 41 | assert {:ok, %{ref: _ref, config: %{chunk_size: _}}} = preflight_upload(profile_image) 42 | end 43 | 44 | test "saves", %{conn: conn} do 45 | {:ok, live, _html} = live(conn, Routes.user_settings_path(conn, :profile)) 46 | 47 | profile_image = 48 | file_input(live, @form_id, :profile_image, [ 49 | %{ 50 | last_modified: 1_551_913_980, 51 | name: "profile-image.jpg", 52 | content: File.read!("./test/support/profile-image.jpg"), 53 | size: 2_169_900, 54 | type: "image/jpeg" 55 | } 56 | ]) 57 | 58 | assert live 59 | |> form(@form_id, user: %{}) 60 | |> render_change(profile_image) =~ "profile-image.jpg - 0%" 61 | 62 | assert render_upload(profile_image, "profile-image.jpg") =~ "100%" 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.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 data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use PhoenixStarterWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with connections 23 | import Plug.Conn 24 | import Phoenix.ConnTest 25 | import PhoenixStarterWeb.ConnCase 26 | 27 | alias PhoenixStarterWeb.Router.Helpers, as: Routes 28 | 29 | # The default endpoint for testing 30 | @endpoint PhoenixStarterWeb.Endpoint 31 | end 32 | end 33 | 34 | setup tags do 35 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(PhoenixStarter.Repo) 36 | 37 | unless tags[:async] do 38 | Ecto.Adapters.SQL.Sandbox.mode(PhoenixStarter.Repo, {:shared, self()}) 39 | end 40 | 41 | {:ok, conn: Phoenix.ConnTest.build_conn()} 42 | end 43 | 44 | @doc """ 45 | Setup helper that registers and logs in users. 46 | 47 | setup :register_and_log_in_user 48 | 49 | It stores an updated connection and a registered user in the 50 | test context. 51 | """ 52 | def register_and_log_in_user(%{conn: conn}) do 53 | user = PhoenixStarter.UsersFixtures.user_fixture() 54 | %{conn: log_in_user(conn, user), user: user} 55 | end 56 | 57 | @doc """ 58 | Logs the given `user` into the `conn`. 59 | 60 | It returns an updated `conn`. 61 | """ 62 | def log_in_user(conn, user) do 63 | token = PhoenixStarter.Users.generate_user_session_token(user) 64 | 65 | conn 66 | |> Phoenix.ConnTest.init_test_session(%{}) 67 | |> Plug.Conn.put_session(:user_token, token) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/phoenix_starter/release_tasks.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.ReleaseTasks do 2 | @moduledoc """ 3 | Server tasks to be run inside the production release container. 4 | """ 5 | @app :phoenix_starter 6 | 7 | require Logger 8 | 9 | @spec migrate :: [any] 10 | def migrate do 11 | load_app() 12 | 13 | for repo <- repos() do 14 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 15 | end 16 | end 17 | 18 | @spec migrations :: [any] 19 | def migrations do 20 | load_app() 21 | 22 | for repo <- repos() do 23 | {:ok, migrations, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.migrations(&1)) 24 | migrations |> format_migrations(repo) |> Logger.info() 25 | end 26 | end 27 | 28 | @spec rollback :: [any] 29 | def rollback do 30 | load_app() 31 | 32 | for repo <- repos() do 33 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, step: 1)) 34 | end 35 | end 36 | 37 | @spec seeds :: [any] 38 | # sobelow_skip ["RCE.CodeModule"] 39 | def seeds do 40 | load_app() 41 | 42 | for repo <- repos() do 43 | {:ok, _, _} = 44 | Ecto.Migrator.with_repo(repo, fn repo -> 45 | seeds_path = Ecto.Migrator.migrations_path(repo, "/seeds.exs") 46 | 47 | if File.exists?(seeds_path) do 48 | Logger.info("Running seeds for #{repo}: #{seeds_path}") 49 | Code.eval_file(seeds_path) 50 | end 51 | end) 52 | end 53 | end 54 | 55 | defp repos do 56 | Application.fetch_env!(@app, :ecto_repos) 57 | end 58 | 59 | defp load_app do 60 | Application.load(@app) 61 | Application.ensure_all_started(:ssl) 62 | end 63 | 64 | defp format_migrations(migrations, repo) do 65 | # Borrowed from mix ecto.migrations 66 | """ 67 | 68 | Repo: #{inspect(repo)} 69 | 70 | Status Migration ID Migration Name 71 | -------------------------------------------------- 72 | """ <> 73 | Enum.map_join(migrations, "\n", fn {status, number, description} -> 74 | " #{format(status, 10)}#{format(number, 16)}#{description}" 75 | end) 76 | end 77 | 78 | defp format(content, pad) do 79 | content 80 | |> to_string 81 | |> String.pad_trailing(pad) 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.Telemetry do 2 | @moduledoc false 3 | use Supervisor 4 | import Telemetry.Metrics 5 | 6 | def start_link(arg) do 7 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 8 | end 9 | 10 | @impl true 11 | def init(_arg) do 12 | children = [ 13 | # Telemetry poller will execute the given period measurements 14 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 15 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 16 | # Add reporters as children of your supervision tree. 17 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 18 | ] 19 | 20 | Supervisor.init(children, strategy: :one_for_one) 21 | end 22 | 23 | def metrics do 24 | [ 25 | # Phoenix Metrics 26 | summary("phoenix.endpoint.stop.duration", 27 | unit: {:native, :millisecond} 28 | ), 29 | summary("phoenix.router_dispatch.stop.duration", 30 | tags: [:route], 31 | unit: {:native, :millisecond} 32 | ), 33 | 34 | # Database Metrics 35 | summary("phoenix_starter.repo.query.total_time", unit: {:native, :millisecond}), 36 | summary("phoenix_starter.repo.query.decode_time", unit: {:native, :millisecond}), 37 | summary("phoenix_starter.repo.query.query_time", unit: {:native, :millisecond}), 38 | summary("phoenix_starter.repo.query.queue_time", unit: {:native, :millisecond}), 39 | summary("phoenix_starter.repo.query.idle_time", unit: {:native, :millisecond}), 40 | 41 | # VM Metrics 42 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 43 | summary("vm.total_run_queue_lengths.total"), 44 | summary("vm.total_run_queue_lengths.cpu"), 45 | summary("vm.total_run_queue_lengths.io"), 46 | 47 | # Oban Metrics 48 | summary("oban.job.stop.duration", unit: {:native, :millisecond}), 49 | counter("oban.job.stop"), 50 | counter("oban.job.exception") 51 | ] 52 | end 53 | 54 | defp periodic_measurements do 55 | [ 56 | # A module, function and arguments to be invoked periodically. 57 | # This function must call :telemetry.execute/3 and a metric must be added above. 58 | # {PhoenixStarterWeb, :count_users, []} 59 | ] 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/plugs/content_security_policy.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.ContentSecurityPolicy do 2 | @moduledoc """ 3 | This `Plug` adds a `Content-Security-Policy` HTTP header to responses. 4 | 5 | The configured policy are the defaults necessary for the configured stack 6 | included in PhoenixStarter, but can be customized based on application 7 | needs. 8 | """ 9 | 10 | import Phoenix.Controller, only: [put_secure_browser_headers: 2] 11 | 12 | def init(opts), do: opts 13 | 14 | def call(conn, _) do 15 | directives = [ 16 | "default-src #{default_src_directive()}", 17 | "form-action #{form_action_directive()}", 18 | "media-src #{media_src_directive()}", 19 | "img-src #{image_src_directive()}", 20 | "script-src #{script_src_directive()}", 21 | "font-src #{font_src_directive()}", 22 | "connect-src #{connect_src_directive()}", 23 | "style-src #{style_src_directive()}", 24 | "frame-src #{frame_src_directive()}" 25 | ] 26 | 27 | put_secure_browser_headers(conn, %{"content-security-policy" => Enum.join(directives, "; ")}) 28 | end 29 | 30 | defp default_src_directive, do: "'none'" 31 | defp form_action_directive, do: "'self'" 32 | defp media_src_directive, do: "'self'" 33 | defp font_src_directive, do: "'self' data:" 34 | 35 | defp connect_src_directive do 36 | "'self' #{app_host("ws://*.")} #{app_host("wss://*.")} #{upload_host()}" 37 | end 38 | 39 | defp style_src_directive, do: "'self' 'unsafe-inline'" 40 | defp frame_src_directive, do: "'self'" 41 | 42 | defp image_src_directive do 43 | "'self' data: #{upload_host()}" 44 | end 45 | 46 | defp script_src_directive do 47 | # Webpack HMR needs unsafe-inline (dev only) 48 | # Alpine needs unsafe-eval 49 | if Keyword.get(config(), :allow_unsafe_inline, false) do 50 | "'self' 'unsafe-eval' 'unsafe-inline'" 51 | else 52 | "'self' 'unsafe-eval'" 53 | end 54 | end 55 | 56 | defp config do 57 | Application.get_env(:phoenix_starter, __MODULE__, []) 58 | end 59 | 60 | defp app_host(prefix) do 61 | case Keyword.get(config(), :app_host) do 62 | host when is_binary(host) -> 63 | prefix <> host 64 | 65 | _ -> 66 | "" 67 | end 68 | end 69 | 70 | defp upload_host(prefix \\ "https://") do 71 | bucket_name = 72 | :phoenix_starter 73 | |> Application.get_env(PhoenixStarter.Uploads, []) 74 | |> Keyword.get(:bucket_name) 75 | 76 | "#{prefix}#{bucket_name}.s3.amazonaws.com" 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/phoenix_starter/users/user_notifier.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Users.UserNotifier do 2 | @moduledoc """ 3 | Delivers notifications to `PhoenixStarter.Users.User`s. 4 | """ 5 | alias PhoenixStarter.Users.User 6 | alias PhoenixStarter.Users.UserEmail 7 | alias PhoenixStarter.Mailer 8 | alias PhoenixStarter.Workers.UserEmailWorker 9 | 10 | @type notifier_result :: 11 | {:ok, Swoosh.Email} 12 | | {:ok, Oban.Job.t()} 13 | | {:error, Oban.Job.changeset()} 14 | | {:error, term()} 15 | 16 | @doc """ 17 | Deliver instructions to confirm `PhoenixStarter.Users.User`. 18 | """ 19 | @spec deliver_confirmation_instructions(User.t(), String.t(), boolean()) :: notifier_result 20 | def deliver_confirmation_instructions(user, url, async \\ true) 21 | 22 | def deliver_confirmation_instructions(user, url, true) do 23 | %{email: "confirmation_instructions", user_id: user.id, url: url} 24 | |> UserEmailWorker.new() 25 | |> Oban.insert() 26 | end 27 | 28 | def deliver_confirmation_instructions(user, url, false) do 29 | email = UserEmail.confirmation_instructions(user, url) 30 | Mailer.deliver(email) 31 | {:ok, email} 32 | end 33 | 34 | @doc """ 35 | Deliver instructions to reset a `PhoenixStarter.Users.User` password. 36 | """ 37 | @spec deliver_reset_password_instructions(User.t(), String.t(), boolean) :: notifier_result 38 | def deliver_reset_password_instructions(user, url, async \\ true) 39 | 40 | def deliver_reset_password_instructions(user, url, true) do 41 | %{email: "reset_password_instructions", user_id: user.id, url: url} 42 | |> UserEmailWorker.new() 43 | |> Oban.insert() 44 | end 45 | 46 | def deliver_reset_password_instructions(user, url, false) do 47 | email = UserEmail.reset_password_instructions(user, url) 48 | Mailer.deliver(email) 49 | {:ok, email} 50 | end 51 | 52 | @doc """ 53 | Deliver instructions to update a `PhoenixStarter.Users.User` email. 54 | """ 55 | @spec deliver_update_email_instructions(User.t(), String.t(), boolean) :: notifier_result 56 | def deliver_update_email_instructions(user, url, async \\ true) 57 | 58 | def deliver_update_email_instructions(user, url, true) do 59 | %{email: "update_email_instructions", user_id: user.id, url: url} 60 | |> UserEmailWorker.new() 61 | |> Oban.insert() 62 | end 63 | 64 | def deliver_update_email_instructions(user, url, false) do 65 | email = UserEmail.update_email_instructions(user, url) 66 | Mailer.deliver(email) 67 | {:ok, email} 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/phoenix_starter/uploads_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.UploadsTest do 2 | use ExUnit.Case 3 | 4 | alias PhoenixStarter.Uploads 5 | 6 | describe "upload_url/1" do 7 | test "returns a signed URL if source variation present" do 8 | upload = [ 9 | %{"variation" => "thumbnail"}, 10 | %{"variation" => "source", "key" => "foo/test.jpeg"} 11 | ] 12 | 13 | %{host: host, path: path, port: port, query: query} = 14 | upload |> Uploads.upload_url() |> URI.parse() 15 | 16 | assert host == "test-phoenix-starter-uploads.s3.amazonaws.com" 17 | assert path == "/foo/test.jpeg" 18 | assert port == 443 19 | refute is_nil(query) 20 | end 21 | 22 | test "returns nil if source variation not present" do 23 | upload = [ 24 | %{"variation" => "thumbnail"} 25 | ] 26 | 27 | assert is_nil(Uploads.upload_url(upload)) 28 | end 29 | end 30 | 31 | test "create_upload/3 returns a signed upload" do 32 | {:ok, now} = Timex.parse("2019-03-06", "{YYYY}-{0M}-{D}") 33 | 34 | entry = %Phoenix.LiveView.UploadEntry{ 35 | client_name: "skywalker.jpeg", 36 | client_type: "image/jpeg" 37 | } 38 | 39 | {:ok, upload} = Uploads.create_upload(entry, [acl: "private", prefix: "foo/"], now) 40 | 41 | assert upload.method == "post" 42 | assert upload.url == "https://test-phoenix-starter-uploads.s3.amazonaws.com" 43 | assert is_map(upload.fields), "is not a map" 44 | assert upload.fields[:acl] == "private" 45 | assert upload.fields["Content-Type"] == "image/jpeg" 46 | assert upload.fields[:key] =~ ~r/foo\/[A-z0-9-_]+.jpeg/ 47 | assert upload.fields[:policy] == policy_fixture() 48 | assert upload.fields["x-amz-algorithm"] == "AWS4-HMAC-SHA256" 49 | 50 | assert upload.fields["x-amz-credential"] == 51 | "AKIATEST/20190306/us-east-1/s3/aws4_request" 52 | 53 | assert upload.fields["x-amz-date"] == "20190306T000000Z" 54 | 55 | assert upload.fields["x-amz-signature"] == 56 | "fbb059a13abf4af58b1e479c5312ae7ceca6c43d138acf8ef68259aafe8b1ed8" 57 | end 58 | 59 | defp policy_fixture() do 60 | "eyJjb25kaXRpb25zIjpbeyJhY2wiOiJwcml2YXRlIn0seyJidWNrZXQiOiJ0ZXN0LXBob2VuaXgtc3RhcnRlci11cGxvYWRzIn0sWyJjb250ZW50LWxlbmd0aC1yYW5nZSIsMCwyNjIxNDQwMF0seyJDb250ZW50LVR5cGUiOiJpbWFnZS9qcGVnIn0sWyJzdGFydHMtd2l0aCIsIiRrZXkiLCJmb28vIl0seyJ4LWFtei1hbGdvcml0aG0iOiJBV1M0LUhNQUMtU0hBMjU2In0seyJ4LWFtei1jcmVkZW50aWFsIjoiQUtJQVRFU1QvMjAxOTAzMDYvdXMtZWFzdC0xL3MzL2F3czRfcmVxdWVzdCJ9LHsieC1hbXotZGF0ZSI6IjIwMTkwMzA2VDAwMDAwMFoifV0sImV4cGlyYXRpb24iOiIyMDE5LTAzLTA2VDAwOjA1OjAwWiJ9" 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /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 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 has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | ## From Ecto.Changeset.cast/4 11 | msgid "can't be blank" 12 | msgstr "" 13 | 14 | ## From Ecto.Changeset.unique_constraint/3 15 | msgid "has already been taken" 16 | msgstr "" 17 | 18 | ## From Ecto.Changeset.put_change/3 19 | msgid "is invalid" 20 | msgstr "" 21 | 22 | ## From Ecto.Changeset.validate_acceptance/3 23 | msgid "must be accepted" 24 | msgstr "" 25 | 26 | ## From Ecto.Changeset.validate_format/3 27 | msgid "has invalid format" 28 | msgstr "" 29 | 30 | ## From Ecto.Changeset.validate_subset/3 31 | msgid "has an invalid entry" 32 | msgstr "" 33 | 34 | ## From Ecto.Changeset.validate_exclusion/3 35 | msgid "is reserved" 36 | msgstr "" 37 | 38 | ## From Ecto.Changeset.validate_confirmation/3 39 | msgid "does not match confirmation" 40 | msgstr "" 41 | 42 | ## From Ecto.Changeset.no_assoc_constraint/3 43 | msgid "is still associated with this entry" 44 | msgstr "" 45 | 46 | msgid "are still associated with this entry" 47 | msgstr "" 48 | 49 | ## From Ecto.Changeset.validate_length/3 50 | msgid "should be %{count} character(s)" 51 | msgid_plural "should be %{count} character(s)" 52 | msgstr[0] "" 53 | msgstr[1] "" 54 | 55 | msgid "should have %{count} item(s)" 56 | msgid_plural "should have %{count} item(s)" 57 | msgstr[0] "" 58 | msgstr[1] "" 59 | 60 | msgid "should be at least %{count} character(s)" 61 | msgid_plural "should be at least %{count} character(s)" 62 | msgstr[0] "" 63 | msgstr[1] "" 64 | 65 | msgid "should have at least %{count} item(s)" 66 | msgid_plural "should have at least %{count} item(s)" 67 | msgstr[0] "" 68 | msgstr[1] "" 69 | 70 | msgid "should be at most %{count} character(s)" 71 | msgid_plural "should be at most %{count} character(s)" 72 | msgstr[0] "" 73 | msgstr[1] "" 74 | 75 | msgid "should have at most %{count} item(s)" 76 | msgid_plural "should have at most %{count} item(s)" 77 | msgstr[0] "" 78 | msgstr[1] "" 79 | 80 | ## From Ecto.Changeset.validate_number/3 81 | msgid "must be less than %{number}" 82 | msgstr "" 83 | 84 | msgid "must be greater than %{number}" 85 | msgstr "" 86 | 87 | msgid "must be less than or equal to %{number}" 88 | msgstr "" 89 | 90 | msgid "must be greater than or equal to %{number}" 91 | msgstr "" 92 | 93 | msgid "must be equal to %{number}" 94 | msgstr "" 95 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.Router do 2 | use PhoenixStarterWeb, :router 3 | 4 | import PhoenixStarterWeb.UserAuth 5 | import Phoenix.LiveDashboard.Router 6 | 7 | alias PhoenixStarterWeb.RequireUserPermission 8 | 9 | pipeline :browser do 10 | plug :accepts, ["html"] 11 | plug :fetch_session 12 | plug :fetch_live_flash 13 | plug :put_root_layout, {PhoenixStarterWeb.LayoutView, :root} 14 | plug :protect_from_forgery 15 | plug PhoenixStarterWeb.ContentSecurityPolicy 16 | plug :fetch_current_user 17 | end 18 | 19 | pipeline :api do 20 | plug :accepts, ["json"] 21 | end 22 | 23 | pipeline :require_ops_admin do 24 | plug :require_authenticated_user 25 | plug RequireUserPermission, permission: "ops.dashboard" 26 | end 27 | 28 | scope "/", PhoenixStarterWeb do 29 | pipe_through :browser 30 | 31 | live "/", PageLive, :index 32 | end 33 | 34 | scope "/manage" do 35 | pipe_through [:browser, :require_ops_admin] 36 | 37 | live_dashboard "/dashboard", 38 | metrics: PhoenixStarterWeb.Telemetry, 39 | ecto_repos: [PhoenixStarter.Repo], 40 | env_keys: ["APP_ENV", "APP_HOST", "APP_NAME"] 41 | end 42 | 43 | if Mix.env() == :dev do 44 | forward "/mailbox", Plug.Swoosh.MailboxPreview 45 | end 46 | 47 | ## Authentication routes 48 | 49 | scope "/", PhoenixStarterWeb do 50 | pipe_through [:browser, :redirect_if_user_is_authenticated] 51 | 52 | get "/users/register", UserRegistrationController, :new 53 | post "/users/register", UserRegistrationController, :create 54 | get "/users/log_in", UserSessionController, :new 55 | post "/users/log_in", UserSessionController, :create 56 | get "/users/reset_password", UserResetPasswordController, :new 57 | post "/users/reset_password", UserResetPasswordController, :create 58 | get "/users/reset_password/:token", UserResetPasswordController, :edit 59 | put "/users/reset_password/:token", UserResetPasswordController, :update 60 | end 61 | 62 | scope "/", PhoenixStarterWeb do 63 | pipe_through [:browser, :require_authenticated_user] 64 | 65 | live "/users/settings", UserSettingsLive.Index, :profile, as: :user_settings 66 | live "/users/settings/email", UserSettingsLive.Index, :email, as: :user_settings 67 | live "/users/settings/password", UserSettingsLive.Index, :password, as: :user_settings 68 | get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email 69 | put "/users/settings/password", UserSettingsController, :update_password 70 | end 71 | 72 | scope "/", PhoenixStarterWeb do 73 | pipe_through [:browser] 74 | 75 | delete "/users/log_out", UserSessionController, :delete 76 | get "/users/confirm", UserConfirmationController, :new 77 | post "/users/confirm", UserConfirmationController, :create 78 | get "/users/confirm/:token", UserConfirmationController, :confirm 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb 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 PhoenixStarterWeb, :controller 9 | use PhoenixStarterWeb, :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: PhoenixStarterWeb 23 | 24 | import Plug.Conn 25 | import PhoenixStarterWeb.Gettext 26 | alias PhoenixStarterWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/phoenix_starter_web/templates", 34 | namespace: PhoenixStarterWeb 35 | 36 | # Import convenience functions from controllers 37 | import Phoenix.Controller, 38 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 39 | 40 | # Include shared imports and aliases for views 41 | unquote(view_helpers()) 42 | end 43 | end 44 | 45 | def live_view do 46 | quote do 47 | use Phoenix.LiveView, 48 | layout: {PhoenixStarterWeb.LayoutView, "live.html"} 49 | 50 | unquote(view_helpers()) 51 | import PhoenixStarterWeb.LiveHelpers 52 | end 53 | end 54 | 55 | def live_component do 56 | quote do 57 | use Phoenix.LiveComponent 58 | 59 | unquote(view_helpers()) 60 | import PhoenixStarterWeb.LiveHelpers 61 | end 62 | end 63 | 64 | def router do 65 | quote do 66 | use Phoenix.Router 67 | 68 | import Plug.Conn 69 | import Phoenix.Controller 70 | import Phoenix.LiveView.Router 71 | end 72 | end 73 | 74 | def channel do 75 | quote do 76 | use Phoenix.Channel 77 | import PhoenixStarterWeb.Gettext 78 | end 79 | end 80 | 81 | defp view_helpers do 82 | quote do 83 | # Use all HTML functionality (forms, tags, etc) 84 | use Phoenix.HTML 85 | 86 | # Import LiveView helpers (live_render, live_component, live_patch, etc) 87 | import Phoenix.LiveView.Helpers 88 | 89 | # Import basic rendering functionality (render, render_layout, etc) 90 | import Phoenix.View 91 | 92 | import PhoenixStarterWeb.ErrorHelpers 93 | import PhoenixStarterWeb.Gettext 94 | alias PhoenixStarterWeb.Router.Helpers, as: Routes 95 | end 96 | end 97 | 98 | @doc """ 99 | When used, dispatch to the appropriate controller/view/etc. 100 | """ 101 | defmacro __using__(which) when is_atom(which) do 102 | apply(__MODULE__, which, []) 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/live/user_settings/password_component.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserSettingsLive.PasswordComponent do 2 | use PhoenixStarterWeb, :live_component 3 | 4 | alias PhoenixStarter.Users 5 | 6 | @impl true 7 | def render(assigns) do 8 | ~H""" 9 |
10 |

Update password

11 | 12 | <.form let={f} 13 | for={@changeset} 14 | id="form__update-password" 15 | action={Routes.user_settings_path(@socket, :password)} 16 | phx-change="validate" 17 | phx-submit="update" 18 | phx-trigger-action={@trigger_action} 19 | phx-target={@myself} 20 | > 21 | <%= label f, :password, "New password" %> 22 | <%= password_input f, :password, required: true, value: input_value(f, :password), phx_debounce: "blur" %> 23 | <%= error_tag f, :password %> 24 | 25 | <%= label f, :password_confirmation, "Confirm new password" %> 26 | <%= password_input f, :password_confirmation, value: input_value(f, :password_confirmation), required: true, phx_debounce: "blur" %> 27 | <%= error_tag f, :password_confirmation %> 28 | 29 | <%= label f, :current_password %> 30 | <%= password_input f, :current_password, name: "current_password", required: true, phx_debounce: "blur", value: @current_password %> 31 | <%= error_tag f, :current_password %> 32 | 33 | <%= submit "Change password", phx_disable_with: "Saving..." %> 34 | 35 |
36 | """ 37 | end 38 | 39 | @impl true 40 | def update(assigns, socket) do 41 | if socket.assigns[:current_user] do 42 | {:ok, socket} 43 | else 44 | {:ok, 45 | socket 46 | |> assign(:current_user, assigns.current_user) 47 | |> assign(:changeset, Users.change_user_password(assigns.current_user)) 48 | |> assign(:current_password, nil) 49 | |> assign(:trigger_action, false)} 50 | end 51 | end 52 | 53 | @impl true 54 | def handle_event( 55 | "validate", 56 | %{"current_password" => current_password, "user" => user_params}, 57 | socket 58 | ) do 59 | changeset = 60 | Users.change_user_password(socket.assigns.current_user, current_password, user_params) 61 | 62 | socket = 63 | socket 64 | |> assign(:current_password, current_password) 65 | |> assign(:changeset, changeset) 66 | 67 | {:noreply, socket} 68 | end 69 | 70 | @impl true 71 | def handle_event( 72 | "update", 73 | %{"current_password" => current_password, "user" => user_params}, 74 | socket 75 | ) do 76 | socket = assign(socket, :current_password, current_password) 77 | 78 | socket.assigns.current_user 79 | |> Users.apply_user_password(current_password, user_params) 80 | |> case do 81 | {:ok, _} -> 82 | {:noreply, assign(socket, :trigger_action, true)} 83 | 84 | {:error, changeset} -> 85 | {:noreply, assign(socket, :changeset, changeset)} 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/live/user_settings/email_component.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserSettingsLive.EmailComponent do 2 | use PhoenixStarterWeb, :live_component 3 | 4 | alias PhoenixStarter.Users 5 | 6 | @impl true 7 | def render(assigns) do 8 | ~H""" 9 |
10 |

Update email

11 | 12 | <.form let={f} for={@changeset} id="form__update-email" phx-change="validate" phx-submit="save" phx-target={@myself}> 13 | <%= label f, :email %> 14 | <%= email_input f, :email, required: true, phx_debounce: "blur" %> 15 | <%= error_tag f, :email %> 16 | 17 | <%= label f, :current_password %> 18 | <%= password_input f, :current_password, name: "current_password", required: true, phx_debounce: "blur", value: @current_password %> 19 | <%= error_tag f, :current_password %> 20 | 21 | <%= submit "Update e-mail", phx_disable_with: "Saving..." %> 22 | 23 |
24 | """ 25 | end 26 | 27 | @impl true 28 | def update(assigns, socket) do 29 | if socket.assigns[:current_user] do 30 | {:ok, socket} 31 | else 32 | {:ok, 33 | socket 34 | |> assign(:current_user, assigns.current_user) 35 | |> assign(:changeset, Users.change_user_email(assigns.current_user)) 36 | |> assign(:current_password, nil)} 37 | end 38 | end 39 | 40 | @impl true 41 | def handle_event( 42 | "validate", 43 | %{"current_password" => current_password, "user" => user_params}, 44 | socket 45 | ) do 46 | changeset = 47 | socket.assigns.current_user 48 | |> Users.change_user_email(current_password, user_params) 49 | |> Map.put(:action, :validate) 50 | 51 | socket = 52 | socket 53 | |> assign(:current_password, current_password) 54 | |> assign(:changeset, changeset) 55 | 56 | send( 57 | self(), 58 | {:flash, :info, "A link to confirm your e-mail change has been sent to the new address."} 59 | ) 60 | 61 | {:noreply, socket} 62 | end 63 | 64 | @impl true 65 | def handle_event( 66 | "save", 67 | %{"current_password" => current_password, "user" => user_params}, 68 | socket 69 | ) do 70 | case Users.apply_user_email(socket.assigns.current_user, current_password, user_params) do 71 | {:ok, applied_user} -> 72 | _ = 73 | Users.deliver_update_email_instructions( 74 | applied_user, 75 | socket.assigns.current_user.email, 76 | &Routes.user_settings_url(socket, :confirm_email, &1) 77 | ) 78 | 79 | send( 80 | self(), 81 | {:flash, :info, 82 | "A link to confirm your e-mail change has been sent to the new address."} 83 | ) 84 | 85 | {:noreply, assign(socket, :current_password, "")} 86 | 87 | {:error, changeset} -> 88 | socket = 89 | socket 90 | |> assign(:current_password, current_password) 91 | |> assign(:changeset, changeset) 92 | 93 | {:noreply, socket} 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /priv/gettext/default.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 | msgid "" 11 | msgstr "" 12 | 13 | #, elixir-format 14 | #: lib/phoenix_starter/users/user_email.ex:15 15 | msgid "Confirm your account" 16 | msgstr "" 17 | 18 | #, elixir-format 19 | #: lib/phoenix_starter_web/templates/user_email/confirmation_instructions.html.eex:7 20 | #: lib/phoenix_starter_web/templates/user_email/confirmation_instructions.text.eex:7 21 | msgid "If you didn't create an account with us, please ignore this." 22 | msgstr "" 23 | 24 | #, elixir-format 25 | #: lib/phoenix_starter_web/templates/user_email/confirmation_instructions.html.eex:3 26 | #: lib/phoenix_starter_web/templates/user_email/confirmation_instructions.text.eex:3 27 | msgid "You can confirm your account by visiting the URL below:" 28 | msgstr "" 29 | 30 | #, elixir-format 31 | #: lib/phoenix_starter_web/templates/user_email/confirmation_instructions.html.eex:1 32 | #: lib/phoenix_starter_web/templates/user_email/confirmation_instructions.text.eex:1 33 | #: lib/phoenix_starter_web/templates/user_email/reset_password_instructions.html.eex:1 34 | #: lib/phoenix_starter_web/templates/user_email/reset_password_instructions.text.eex:1 35 | #: lib/phoenix_starter_web/templates/user_email/update_email_instructions.html.eex:1 36 | #: lib/phoenix_starter_web/templates/user_email/update_email_instructions.text.eex:1 37 | msgid "Hi %{user_email}," 38 | msgstr "" 39 | 40 | #, elixir-format 41 | #: lib/phoenix_starter/users/user_email.ex:35 42 | msgid "Confirm email change" 43 | msgstr "" 44 | 45 | #, elixir-format 46 | #: lib/phoenix_starter_web/templates/user_email/reset_password_instructions.html.eex:7 47 | #: lib/phoenix_starter_web/templates/user_email/reset_password_instructions.text.eex:7 48 | #: lib/phoenix_starter_web/templates/user_email/update_email_instructions.html.eex:7 49 | #: lib/phoenix_starter_web/templates/user_email/update_email_instructions.text.eex:7 50 | msgid "If you didn't request this change, please ignore this." 51 | msgstr "" 52 | 53 | #, elixir-format 54 | #: lib/phoenix_starter/users/user_email.ex:25 55 | msgid "Reset your password" 56 | msgstr "" 57 | 58 | #, elixir-format 59 | #: lib/phoenix_starter_web/templates/user_email/update_email_instructions.html.eex:3 60 | #: lib/phoenix_starter_web/templates/user_email/update_email_instructions.text.eex:3 61 | msgid "You can change your email by visiting the URL below:" 62 | msgstr "" 63 | 64 | #, elixir-format 65 | #: lib/phoenix_starter_web/templates/user_email/reset_password_instructions.html.eex:3 66 | #: lib/phoenix_starter_web/templates/user_email/reset_password_instructions.text.eex:3 67 | msgid "You can reset your password by visiting the URL below:" 68 | msgstr "" 69 | 70 | #, elixir-format 71 | #: lib/phoenix_starter_web/plugs/require_user_permission.ex:37 72 | msgid "You are not authorized to perform this action." 73 | msgstr "" 74 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if config_env() == :prod do 4 | # Configures Ecto 5 | with {:ok, database_name} <- System.fetch_env("DATABASE_NAME"), 6 | {:ok, database_username} <- System.fetch_env("DATABASE_USER"), 7 | {:ok, database_password} <- System.fetch_env("DATABASE_PASSWORD"), 8 | {:ok, database_hostname} <- System.fetch_env("DATABASE_HOST") do 9 | database_hostname = 10 | if String.contains?(database_hostname, ":") do 11 | database_hostname |> String.split(":") |> List.first() 12 | else 13 | database_hostname 14 | end 15 | 16 | config :phoenix_starter, PhoenixStarter.Repo, 17 | database: database_name, 18 | username: database_username, 19 | password: database_password, 20 | hostname: database_hostname, 21 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") 22 | else 23 | _ -> 24 | raise """ 25 | environment variables for database missing. 26 | Check that all the following are defined: 27 | - DATABASE_NAME 28 | - DATABASE_USER 29 | - DATABASE_PASSWORD 30 | - DATABASE_HOST 31 | """ 32 | end 33 | 34 | secret_key_base = 35 | System.get_env("SECRET_KEY_BASE") || 36 | raise """ 37 | environment variable SECRET_KEY_BASE is missing. 38 | You can generate one by calling: mix phx.gen.secret 39 | """ 40 | 41 | # Configures Phoenix endpoint 42 | config :phoenix_starter, PhoenixStarterWeb.Endpoint, 43 | http: [ 44 | port: String.to_integer(System.get_env("PORT") || "4000"), 45 | transport_options: [socket_opts: [:inet6]] 46 | ], 47 | live_view: [signing_salt: System.fetch_env!("LIVE_VIEW_SALT")], 48 | secret_key_base: secret_key_base, 49 | url: [scheme: "https", host: System.get_env("APP_HOST"), port: 443] 50 | 51 | # Configures libcluster 52 | if cluster_service_discovery_dns_query = System.get_env("SERVICE_DISCOVERY") do 53 | config :phoenix_starter, PhoenixStarter.ClusterSupervisor, 54 | aws_service_discovery_private_dns_namespace: [ 55 | strategy: Cluster.Strategy.DNSPoll, 56 | config: [ 57 | polling_interval: 5_000, 58 | query: cluster_service_discovery_dns_query, 59 | node_basename: System.fetch_env!("APP_NAME") 60 | ] 61 | ] 62 | end 63 | 64 | # Configures Swoosh 65 | # Note: by default this reads from the IAM task or instance role 66 | config :phoenix_starter, PhoenixStarter.Mailer, adapter: Swoosh.Adapters.AmazonSES 67 | 68 | # Configures Sentry 69 | config :sentry, 70 | dsn: System.get_env("SENTRY_DSN"), 71 | environment_name: System.get_env("SENTRY_ENV", Atom.to_string(config_env())) 72 | 73 | # Configures Uploads 74 | config :phoenix_starter, PhoenixStarter.Uploads, bucket_name: System.get_env("AWS_S3_BUCKET") 75 | 76 | # Config Content Security Policy 77 | config :phoenix_starter, PhoenixStarterWeb.ContentSecurityPolicy, 78 | app_host: System.get_env("APP_HOST") 79 | end 80 | 81 | if config_env() == :test && System.get_env("CI") == "true" do 82 | database_url = 83 | System.get_env("DATABASE_URL") || 84 | raise """ 85 | environment variable DATABASE_URL is missing. 86 | For example: ecto://USER:PASS@HOST/DATABASE 87 | """ 88 | 89 | config :phoenix_starter, PhoenixStarter.Repo, url: database_url 90 | end 91 | -------------------------------------------------------------------------------- /test/phoenix_starter_web/controllers/user_session_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserSessionControllerTest do 2 | use PhoenixStarterWeb.ConnCase, async: true 3 | 4 | import PhoenixStarter.UsersFixtures 5 | 6 | setup do 7 | %{user: user_fixture()} 8 | end 9 | 10 | describe "GET /users/log_in" do 11 | test "renders log in page", %{conn: conn} do 12 | conn = get(conn, Routes.user_session_path(conn, :new)) 13 | response = html_response(conn, 200) 14 | assert response =~ "

Log in

" 15 | end 16 | 17 | test "redirects if already logged in", %{conn: conn, user: user} do 18 | conn = conn |> log_in_user(user) |> get(Routes.user_session_path(conn, :new)) 19 | assert redirected_to(conn) == "/" 20 | end 21 | end 22 | 23 | describe "POST /users/log_in" do 24 | test "logs the user in", %{conn: conn, user: user} do 25 | conn = 26 | post(conn, Routes.user_session_path(conn, :create), %{ 27 | "user" => %{"email" => user.email, "password" => valid_user_password()} 28 | }) 29 | 30 | assert get_session(conn, :user_token) 31 | assert redirected_to(conn) =~ "/" 32 | 33 | # Now do a logged in request and assert on the menu 34 | conn = get(conn, "/") 35 | assert html_response(conn, 200) 36 | end 37 | 38 | test "logs the user in with remember me", %{conn: conn, user: user} do 39 | conn = 40 | post(conn, Routes.user_session_path(conn, :create), %{ 41 | "user" => %{ 42 | "email" => user.email, 43 | "password" => valid_user_password(), 44 | "remember_me" => "true" 45 | } 46 | }) 47 | 48 | assert conn.resp_cookies["_phoenix_starter_web_user_remember_me"] 49 | assert redirected_to(conn) =~ "/" 50 | end 51 | 52 | test "logs the user in with return to", %{conn: conn, user: user} do 53 | conn = 54 | conn 55 | |> init_test_session(user_return_to: "/foo/bar") 56 | |> post(Routes.user_session_path(conn, :create), %{ 57 | "user" => %{ 58 | "email" => user.email, 59 | "password" => valid_user_password() 60 | } 61 | }) 62 | 63 | assert redirected_to(conn) == "/foo/bar" 64 | end 65 | 66 | test "emits error message with invalid credentials", %{conn: conn, user: user} do 67 | conn = 68 | post(conn, Routes.user_session_path(conn, :create), %{ 69 | "user" => %{"email" => user.email, "password" => "invalid_password"} 70 | }) 71 | 72 | response = html_response(conn, 200) 73 | assert response =~ "

Log in

" 74 | assert response =~ "Invalid email or password" 75 | end 76 | end 77 | 78 | describe "DELETE /users/log_out" do 79 | test "logs the user out", %{conn: conn, user: user} do 80 | conn = conn |> log_in_user(user) |> delete(Routes.user_session_path(conn, :delete)) 81 | assert redirected_to(conn) == "/" 82 | refute get_session(conn, :user_token) 83 | assert get_flash(conn, :info) =~ "Logged out successfully" 84 | end 85 | 86 | test "succeeds even if the user is not logged in", %{conn: conn} do 87 | conn = delete(conn, Routes.user_session_path(conn, :delete)) 88 | assert redirected_to(conn) == "/" 89 | refute get_session(conn, :user_token) 90 | assert get_flash(conn, :info) =~ "Logged out successfully" 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/phoenix_starter_web/controllers/user_settings_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserSettingsControllerTest do 2 | use PhoenixStarterWeb.ConnCase, async: true 3 | 4 | alias PhoenixStarter.Users 5 | import PhoenixStarter.UsersFixtures 6 | 7 | setup :register_and_log_in_user 8 | 9 | describe "PUT /users/settings (change password form)" do 10 | test "updates the user password and resets tokens", %{conn: conn, user: user} do 11 | new_password_conn = 12 | put(conn, Routes.user_settings_path(conn, :update_password), %{ 13 | "current_password" => valid_user_password(), 14 | "user" => %{ 15 | "password" => "new valid password", 16 | "password_confirmation" => "new valid password" 17 | } 18 | }) 19 | 20 | assert redirected_to(new_password_conn) == Routes.user_settings_path(conn, :password) 21 | assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) 22 | assert get_flash(new_password_conn, :info) =~ "Password updated successfully" 23 | assert Users.get_user_by_email_and_password(user.email, "new valid password") 24 | end 25 | 26 | test "does not update password on invalid data", %{conn: conn} do 27 | old_password_conn = 28 | put(conn, Routes.user_settings_path(conn, :update_password), %{ 29 | "current_password" => "invalid", 30 | "user" => %{ 31 | "password" => "too short", 32 | "password_confirmation" => "does not match" 33 | } 34 | }) 35 | 36 | assert redirected_to(old_password_conn) == "/users/settings/password" 37 | 38 | assert get_flash(old_password_conn, :error) =~ 39 | "We were unable to update your password. Please try again." 40 | 41 | assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token) 42 | end 43 | end 44 | 45 | describe "GET /users/settings/confirm_email/:token" do 46 | setup %{user: user} do 47 | email = unique_user_email() 48 | 49 | token = 50 | extract_user_token(fn url -> 51 | Users.deliver_update_email_instructions(%{user | email: email}, user.email, url, false) 52 | end) 53 | 54 | %{token: token, email: email} 55 | end 56 | 57 | test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do 58 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) 59 | assert redirected_to(conn) == Routes.user_settings_path(conn, :email) 60 | assert get_flash(conn, :info) =~ "Email changed successfully" 61 | refute Users.get_user_by_email(user.email) 62 | assert Users.get_user_by_email(email) 63 | 64 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) 65 | assert redirected_to(conn) == Routes.user_settings_path(conn, :email) 66 | assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" 67 | end 68 | 69 | test "does not update email with invalid token", %{conn: conn, user: user} do 70 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, "oops")) 71 | assert redirected_to(conn) == Routes.user_settings_path(conn, :email) 72 | assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" 73 | assert Users.get_user_by_email(user.email) 74 | end 75 | 76 | test "redirects if user is not logged in", %{token: token} do 77 | conn = build_conn() 78 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) 79 | assert redirected_to(conn) == Routes.user_session_path(conn, :new) 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/phoenix_starter_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.LayoutViewTest do 2 | use PhoenixStarterWeb.ConnCase, async: true 3 | 4 | import Phoenix.HTML 5 | import PhoenixStarter.UsersFixtures 6 | 7 | alias PhoenixStarterWeb.LayoutView 8 | 9 | @base_class "border border-transparent rounded mb-5 p-4" 10 | @info_class "bg-blue-100 border-blue-200 text-blue-600" 11 | @warning_class "bg-yellow-100 border-yellow-200 text-yellow-700" 12 | @error_class "bg-red-200 border-red-300 text-red-800" 13 | 14 | test "alert/2 returns the correct HTML for a conn struct" do 15 | assert "info" 16 | |> conn_with_flash("info message") 17 | |> LayoutView.alert(:info) 18 | |> safe_to_string() == 19 | ~s() 20 | 21 | assert "warning" 22 | |> conn_with_flash("warning message") 23 | |> LayoutView.alert(:warning) 24 | |> safe_to_string() == 25 | ~s() 26 | 27 | assert "error" 28 | |> conn_with_flash("error message") 29 | |> LayoutView.alert(:error) 30 | |> safe_to_string() == 31 | ~s() 32 | end 33 | 34 | test "alert/2 returns the correct HTML for a flash assign" do 35 | assert %{"info" => "info message"} 36 | |> LayoutView.alert(:info) 37 | |> safe_to_string() == 38 | """ 39 | 46 | """ 47 | 48 | assert %{"warning" => "warning message"} 49 | |> LayoutView.alert(:warning) 50 | |> safe_to_string() == 51 | """ 52 | 59 | """ 60 | 61 | assert %{"error" => "error message"} 62 | |> LayoutView.alert(:error) 63 | |> safe_to_string() == 64 | """ 65 | 72 | """ 73 | end 74 | 75 | test "permitted?/2 returns a boolean" do 76 | current_user = user_fixture(%{role: :admin}) 77 | 78 | assert LayoutView.permitted?(current_user, "me.update_profile") 79 | refute LayoutView.permitted?(current_user, "notareal.permission") 80 | end 81 | 82 | defp conn_with_flash(flash_key, msg) do 83 | :get 84 | |> build_conn("/") 85 | |> with_session() 86 | |> put_session("phoenix_flash", %{flash_key => msg}) 87 | |> fetch_flash() 88 | end 89 | 90 | @session Plug.Session.init( 91 | store: :cookie, 92 | key: "_app", 93 | encryption_salt: "yadayada", 94 | signing_salt: "yadayada" 95 | ) 96 | 97 | defp with_session(conn) do 98 | conn 99 | |> Map.put(:secret_key_base, String.duplicate("abcdefgh", 8)) 100 | |> Plug.Session.call(@session) 101 | |> Plug.Conn.fetch_session() 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at 59 | [team+phx-starter-coc@newaperio.com]. All complaints will be reviewed and 60 | investigated and will result in a response that is deemed necessary and 61 | appropriate to the circumstances. The project team is obligated to maintain 62 | confidentiality with regard to the reporter of an incident. Further details 63 | of specific enforcement policies may be posted separately. 64 | 65 | Project maintainers who do not follow or enforce the Code of Conduct in good 66 | faith may face temporary or permanent repercussions as determined by other 67 | members of the project's leadership. 68 | 69 | [team+phx-starter-coc@newaperio.com]: mailto:team+phx-starter-coc@newaperio.com 70 | 71 | ## Attribution 72 | 73 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 74 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 75 | 76 | [homepage]: https://www.contributor-covenant.org 77 | 78 | For answers to common questions about this code of conduct, see 79 | https://www.contributor-covenant.org/faq 80 | -------------------------------------------------------------------------------- /test/phoenix_starter_web/controllers/user_confirmation_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserConfirmationControllerTest do 2 | use PhoenixStarterWeb.ConnCase, async: true 3 | 4 | alias PhoenixStarter.Users 5 | alias PhoenixStarter.Repo 6 | import PhoenixStarter.UsersFixtures 7 | 8 | setup do 9 | %{user: user_fixture()} 10 | end 11 | 12 | describe "GET /users/confirm" do 13 | test "renders the confirmation page", %{conn: conn} do 14 | conn = get(conn, Routes.user_confirmation_path(conn, :new)) 15 | response = html_response(conn, 200) 16 | assert response =~ "

Resend confirmation instructions

" 17 | end 18 | end 19 | 20 | describe "POST /users/confirm" do 21 | @tag :capture_log 22 | test "sends a new confirmation token", %{conn: conn, user: user} do 23 | conn = 24 | post(conn, Routes.user_confirmation_path(conn, :create), %{ 25 | "user" => %{"email" => user.email} 26 | }) 27 | 28 | assert redirected_to(conn) == "/" 29 | assert get_flash(conn, :info) =~ "If your email is in our system" 30 | assert Repo.get_by!(Users.UserToken, user_id: user.id).context == "confirm" 31 | end 32 | 33 | test "does not send confirmation token if account is confirmed", %{conn: conn, user: user} do 34 | Repo.update!(Users.User.confirm_changeset(user)) 35 | 36 | conn = 37 | post(conn, Routes.user_confirmation_path(conn, :create), %{ 38 | "user" => %{"email" => user.email} 39 | }) 40 | 41 | assert redirected_to(conn) == "/" 42 | assert get_flash(conn, :info) =~ "If your email is in our system" 43 | refute Repo.get_by(Users.UserToken, user_id: user.id) 44 | end 45 | 46 | test "does not send confirmation token if email is invalid", %{conn: conn} do 47 | conn = 48 | post(conn, Routes.user_confirmation_path(conn, :create), %{ 49 | "user" => %{"email" => "unknown@example.com"} 50 | }) 51 | 52 | assert redirected_to(conn) == "/" 53 | assert get_flash(conn, :info) =~ "If your email is in our system" 54 | assert Repo.all(Users.UserToken) == [] 55 | end 56 | end 57 | 58 | describe "GET /users/confirm/:token" do 59 | test "confirms the given token once", %{conn: conn, user: user} do 60 | token = 61 | extract_user_token(fn url -> 62 | Users.deliver_user_confirmation_instructions(user, url, false) 63 | end) 64 | 65 | conn = get(conn, Routes.user_confirmation_path(conn, :confirm, token)) 66 | assert redirected_to(conn) == "/" 67 | assert get_flash(conn, :info) =~ "Account confirmed successfully" 68 | assert Users.get_user!(user.id).confirmed_at 69 | refute get_session(conn, :user_token) 70 | assert Repo.all(Users.UserToken) == [] 71 | 72 | # When not logged in 73 | conn = get(conn, Routes.user_confirmation_path(conn, :confirm, token)) 74 | assert redirected_to(conn) == "/" 75 | assert get_flash(conn, :error) =~ "Account confirmation link is invalid or it has expired" 76 | 77 | # When logged in 78 | conn = 79 | build_conn() 80 | |> log_in_user(user) 81 | |> get(Routes.user_confirmation_path(conn, :confirm, token)) 82 | 83 | assert redirected_to(conn) == "/" 84 | refute get_flash(conn, :error) 85 | end 86 | 87 | test "does not confirm email with invalid token", %{conn: conn, user: user} do 88 | conn = get(conn, Routes.user_confirmation_path(conn, :confirm, "oops")) 89 | assert redirected_to(conn) == "/" 90 | assert get_flash(conn, :error) =~ "Account confirmation link is invalid or it has expired" 91 | refute Users.get_user!(user.id).confirmed_at 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## Unreleased 9 | 10 | ## v2.0 - 2022-02-24 11 | 12 | ### Added 13 | 14 | - GitHub pull request template 15 | - Dialyzer for type-checking (via dialyxir) 16 | 17 | ### Changed 18 | 19 | - Target Elixir v1.13.2 20 | - Target Erlang v24.2.1 21 | - Target Node.js v16.13.2 22 | - Update Phoenix to v1.6.6 23 | - Update Phoenix LiveView to v0.17.6 24 | - Update various dependencies to satisfy new Phoenix/LiveView version constraints 25 | - credo 26 | - phoenix_ecto 27 | - phoenix_html 28 | - phoenix_live_dashboard 29 | - phoenix_live_reload 30 | - telemetry_metrics 31 | - telemetry_poller 32 | - Update EEx and LEEx templates to the new HEEx templating engine 33 | - Run tests with `--warnings-as-errors` in CI 34 | - Replace Bamboo with Swoosh for emails 35 | - Update Sobelow skips 36 | - Parallelize GitHub Actions CI workflow 37 | 38 | ## v1.1 - 2021-05-27 39 | 40 | ### Added 41 | 42 | - Add LICENSE (#48) 43 | - Add CODE_OF_CONDUCT (#49) 44 | - Add configuration to allow app to run on AWS ECS Fargate (#74) 45 | - Add CHANGELOG and version scripts (#83) 46 | - Add LiveView uploads, refactor User Settings to LiveView (#186) 47 | 48 | ### Changed 49 | 50 | - Bump dependencies (various PRs) 51 | - Update CSP for Alpinejs (#85) 52 | - Update configuration for deploy (#84) 53 | - Update GitHub Actions CI Config (#158) 54 | - Update NodeJS to 16.x (#185) 55 | 56 | ## v1.0 - 2020-11-11 57 | 58 | ### Added 59 | 60 | - Add and configure Oban and setup email job 61 | - Refactor ContentSecurityPolicy plug 62 | - Add Plug to check user permissions to protect LiveDashboard 63 | - Add seeds to create users for testing 64 | - Add UserRole struct and type for starter authz permission system 65 | - Add Bamboo for email sending and configure auth emails 66 | - Add and configure Bamboo for sending emails 67 | - Add phx_gen_auth for user authentication 68 | - Remove unused configuration 69 | - Add alert helpers 70 | - Remove Phoenix default page content 71 | - Update phoenix.css to use Tailwind CSS @apply 72 | - Add custom migration template to specify id and timestamp defaults 73 | - Enable PG stats in LiveDashboard 74 | - Configure OS data for PhoenixLiveDashboard 75 | - Add CSP Plug to set headers correctly (#29) 76 | - Add template README for apps generated from the starter 77 | - Add preflight script to check if all system deps are installed 78 | - Add init script to rename starter app and setup base app 79 | - Build assets on CI to catch errors 80 | - Add dependabot config for auto mix, npm, and docker dep updates 81 | - Update config to use new Elixir 1.11 runtime.exs file 82 | - Add linters to CI run 83 | - Add and set up AlpineJS 84 | - Setup and configure TypeScript 85 | - Add Tailwind CSS and PostCSS 86 | - Add and configure ESLint, Stylint, Credo, Sobelow for various linting 87 | - Add and configure Prettier for JS and CSS formatting 88 | - Add Sentry for exception tracking and configure 89 | - Add GitHub CI config 90 | - Add .editorconfig for universal editor settings 91 | - Add ExDoc and configure with defaults 92 | - Add Repo/Schema add-ons 93 | - Add Dockerfile to build container 94 | - Remove SASS dependency from Phoenix assets 95 | - Setup Mix releases for deploying app 96 | - Remove some comments from generated files 97 | - Remove username/password from Ecto PG configuration for dev/test 98 | - Add assert_identity for test helpers 99 | - Add .tool-versions to manage runtime versions with asdf 100 | - Initial Phoenix generator skeleton 101 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :phoenix_starter, 7 | version: "0.1.0", 8 | elixir: "~> 1.13", 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 | dialyzer: dialyzer(), 15 | default_release: :phoenix_starter, 16 | releases: releases(), 17 | name: "PhoenixStarter", 18 | source_url: "https://github.com/newaperio/phoenix_starter", 19 | docs: docs() 20 | ] 21 | end 22 | 23 | def application do 24 | [ 25 | mod: {PhoenixStarter.Application, []}, 26 | extra_applications: [:logger, :runtime_tools, :os_mon] 27 | ] 28 | end 29 | 30 | defp elixirc_paths(:test), do: ["lib", "test/support"] 31 | defp elixirc_paths(_), do: ["lib"] 32 | 33 | defp deps do 34 | [ 35 | {:assert_identity, "~> 0.1.0", only: :test}, 36 | {:bcrypt_elixir, "~> 2.0"}, 37 | {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, 38 | {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, 39 | {:ecto_psql_extras, "~> 0.2"}, 40 | {:ecto_sql, "~> 3.4"}, 41 | {:ex_aws_s3, "~> 2.1"}, 42 | {:ex_doc, "~> 0.22", only: :dev, runtime: false}, 43 | {:floki, ">= 0.27.0", only: :test}, 44 | {:gen_smtp, "~> 1.0"}, 45 | {:gettext, "~> 0.11"}, 46 | {:hackney, "~> 1.8"}, 47 | {:jason, "~> 1.0"}, 48 | {:libcluster, "~> 3.2"}, 49 | {:oban, "~> 2.1"}, 50 | {:phoenix, "~> 1.6"}, 51 | {:phoenix_ecto, "~> 4.4"}, 52 | {:phoenix_html, "~> 3.0"}, 53 | {:phoenix_live_dashboard, "~> 0.6"}, 54 | {:phoenix_live_reload, "~> 1.3", only: :dev}, 55 | {:phoenix_live_view, "~> 0.17"}, 56 | {:phoenix_swoosh, "~> 1.0"}, 57 | {:plug_cowboy, "~> 2.0"}, 58 | {:postgrex, ">= 0.0.0"}, 59 | {:sentry, "8.0.4"}, 60 | {:sobelow, "~> 0.10", only: [:dev, :test]}, 61 | {:swoosh, "~> 1.6"}, 62 | {:telemetry_metrics, "~> 0.6"}, 63 | {:telemetry_poller, "~> 1.0"}, 64 | {:timex, "~> 3.6"} 65 | ] 66 | end 67 | 68 | defp dialyzer do 69 | [ 70 | ignore_warnings: ".dialyzer_ignore.exs", 71 | plt_file: {:no_warn, "priv/plts/dialyzer.plt"}, 72 | flags: [:error_handling, :race_conditions, :underspecs, :unknown], 73 | list_unused_filters: true 74 | ] 75 | end 76 | 77 | defp aliases do 78 | [ 79 | setup: ["deps.get", "ecto.setup", "cmd npm install --prefix assets"], 80 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 81 | "ecto.reset": ["ecto.drop", "ecto.setup"], 82 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], 83 | lint: [ 84 | "deps.unlock --check-unused", 85 | "format --check-formatted", 86 | "xref graph --label compile-connected --fail-above 0", 87 | "credo", 88 | "sobelow --config" 89 | ] 90 | ] 91 | end 92 | 93 | defp releases do 94 | [ 95 | phoenix_starter: [ 96 | applications: [phoenix_starter: :permanent], 97 | include_executables_for: [:unix], 98 | version: {:from_app, :phoenix_starter} 99 | ] 100 | ] 101 | end 102 | 103 | defp docs do 104 | [ 105 | main: "readme", 106 | formatters: ["html"], 107 | extras: ["README.md"], 108 | groups_for_modules: [ 109 | Core: ~r/PhoenixStarter(\.{0}|\.{1}.*)$/, 110 | Web: ~r/PhoenixStarterWeb(\.{0}|\.{1}.*)$/ 111 | ], 112 | nest_modules_by_prefix: [ 113 | PhoenixStarter, 114 | PhoenixStarterWeb 115 | ] 116 | ] 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/live/user_settings/profile_component.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserSettingsLive.ProfileComponent do 2 | use PhoenixStarterWeb, :live_component 3 | 4 | alias PhoenixStarter.Uploads 5 | alias PhoenixStarter.Users 6 | 7 | @upload_limit 25 * 1024 * 1024 8 | 9 | @impl true 10 | def mount(socket) do 11 | socket = 12 | allow_upload(socket, :profile_image, 13 | accept: ~w(.jpg .jpeg .png), 14 | external: &presign_upload/2, 15 | max_file_size: @upload_limit 16 | ) 17 | 18 | {:ok, socket} 19 | end 20 | 21 | @impl true 22 | def update(assigns, socket) do 23 | if socket.assigns[:current_user] do 24 | {:ok, socket} 25 | else 26 | {:ok, 27 | socket 28 | |> assign(:current_user, assigns.current_user) 29 | |> assign(:current_image, assigns.current_user.profile_image) 30 | |> assign(:changeset, Users.change_user_profile(assigns.current_user))} 31 | end 32 | end 33 | 34 | @impl true 35 | def render(assigns) do 36 | ~H""" 37 |
38 |

Update profile

39 | 40 | <.form for={@changeset} id="form__update-profile" phx-change="validate" phx-submit="save" phx-target={@myself}> 41 |

Profile Photo

42 | 43 |
44 | Current Image: 45 | <%= img_tag Uploads.upload_url(@current_image), width: 75 %> 46 |
47 | 48 | <%= live_file_input @uploads.profile_image %> 49 | <%= for entry <- @uploads.profile_image.entries do %> 50 | Selected Image: 51 | <%= live_img_preview entry, width: 75 %> 52 | <%= entry.client_name %> - <%= entry.progress %>% 53 | <%= for err <- upload_errors(@uploads.profile_image, entry) do %> 54 |

<%= upload_error(err) %>

55 | <% end %> 56 | <% end %> 57 | 58 | <%= submit "Update profile", phx_disable_with: "Saving..." %> 59 | 60 |
61 | """ 62 | end 63 | 64 | @impl true 65 | def handle_event("validate", _params, socket) do 66 | changeset = 67 | socket.assigns.current_user 68 | |> Users.change_user_profile(%{}) 69 | |> Map.put(:action, :validate) 70 | 71 | {:noreply, assign(socket, :changeset, changeset)} 72 | end 73 | 74 | @impl true 75 | def handle_event("save", _params, socket) do 76 | [uploaded_file] = 77 | consume_uploaded_entries(socket, :profile_image, fn params, entry -> 78 | %{ 79 | "variation" => "source", 80 | "key" => params.fields.key, 81 | "last_modified" => entry.client_last_modified, 82 | "name" => entry.client_name, 83 | "size" => entry.client_size, 84 | "type" => entry.client_type 85 | } 86 | end) 87 | 88 | case Users.update_user_profile(socket.assigns.current_user, %{ 89 | "profile_image" => [uploaded_file] 90 | }) do 91 | {:ok, user} -> 92 | send( 93 | self(), 94 | {:flash, :info, "Profile updated succcessfully."} 95 | ) 96 | 97 | {:noreply, assign(socket, :current_image, user.profile_image)} 98 | 99 | {:error, %Ecto.Changeset{} = changeset} -> 100 | {:noreply, assign(socket, :changeset, changeset)} 101 | end 102 | end 103 | 104 | defp presign_upload(entry, socket) do 105 | current_user = socket.assigns.current_user 106 | prefix = "profile-images/#{current_user.id}" 107 | {:ok, upload} = Uploads.create_upload(entry, prefix: prefix, upload_limit: @upload_limit) 108 | 109 | meta = Map.put(upload, :uploader, "S3") 110 | {:ok, meta, socket} 111 | end 112 | 113 | defp upload_error(:too_large), do: "must be smaller than 25mb" 114 | defp upload_error(:too_many_files), do: "must be one file" 115 | defp upload_error(:not_accepted), do: "must be .jpg or .png" 116 | end 117 | -------------------------------------------------------------------------------- /lib/phoenix_starter/users/user_role.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Users.UserRole do 2 | @moduledoc """ 3 | Authorizations for `PhoenixStarter.Users.User`. 4 | 5 | `UserRole` is a struct with a `name` field as an atom and a `permissions` 6 | field, which is a list of strings. 7 | 8 | Permissions should be specified in the format: `"scope.action"`. For 9 | example, `"me.update_profile"` or `"users.update"`. 10 | """ 11 | defstruct [:name, :permissions] 12 | 13 | @type role_name :: :admin | :ops_admin | :user 14 | @type t :: %__MODULE__{name: role_name, permissions: list(String.t())} 15 | 16 | @doc """ 17 | Returns a list of valid roles. 18 | """ 19 | @spec roles :: [role_name, ...] 20 | def roles do 21 | [:admin, :ops_admin, :user] 22 | end 23 | 24 | @doc """ 25 | Returns a `PhoenixStarter.Users.UserRole` struct for the given role name. 26 | """ 27 | @spec role(role_name) :: t 28 | def role(role) 29 | 30 | def role(:admin) do 31 | %__MODULE__{ 32 | name: :admin, 33 | permissions: ["me.update_profile"] 34 | } 35 | end 36 | 37 | def role(:ops_admin) do 38 | %__MODULE__{ 39 | name: :ops_admin, 40 | permissions: ["ops.dashboard"] 41 | } 42 | end 43 | 44 | def role(:user) do 45 | %__MODULE__{ 46 | name: :user, 47 | permissions: ["me.update_profile"] 48 | } 49 | end 50 | 51 | def role(role) do 52 | raise ArgumentError, """ 53 | #{inspect(role)} given but no such role defined 54 | """ 55 | end 56 | 57 | @spec permitted?(t, String.t()) :: boolean() 58 | def permitted?(%__MODULE__{} = role, permission), do: permission in role.permissions 59 | 60 | defmodule Type do 61 | @moduledoc """ 62 | An `Ecto.ParameterizedType` representing a `PhoenixStarter.Users.UserRole`. 63 | 64 | Stored as a `string` in the database but expanded as a struct with 65 | hydrated `permissions` field, for easy usage. 66 | """ 67 | use Ecto.ParameterizedType 68 | alias PhoenixStarter.Users.UserRole 69 | 70 | @impl true 71 | def type(_params), do: :string 72 | 73 | @impl true 74 | def init(opts) do 75 | roles = Keyword.get(opts, :roles, nil) 76 | 77 | unless is_list(roles) and Enum.all?(roles, &is_atom/1) do 78 | raise ArgumentError, """ 79 | PhoenixStarter.Users.UserRole.Type must have a `roles` option specified as a list of atoms. 80 | For example: 81 | 82 | field :my_field, PhoenixStarter.Users.UserRole.Type, roles: [:admin, :user] 83 | """ 84 | end 85 | 86 | on_load = Map.new(roles, &{Atom.to_string(&1), &1}) 87 | on_dump = Map.new(roles, &{&1, Atom.to_string(&1)}) 88 | 89 | %{on_load: on_load, on_dump: on_dump, roles: roles} 90 | end 91 | 92 | @impl true 93 | def cast(data, params) do 94 | case params do 95 | %{on_load: %{^data => as_atom}} -> {:ok, UserRole.role(as_atom)} 96 | %{on_dump: %{^data => _}} -> {:ok, UserRole.role(data)} 97 | _ -> :error 98 | end 99 | end 100 | 101 | @impl true 102 | def load(nil, _, _), do: {:ok, nil} 103 | 104 | def load(data, _loader, %{on_load: on_load}) do 105 | case on_load do 106 | %{^data => as_atom} -> {:ok, UserRole.role(as_atom)} 107 | _ -> :error 108 | end 109 | end 110 | 111 | @impl true 112 | def dump(nil, _, _), do: {:ok, nil} 113 | 114 | def dump(data, _dumper, %{on_dump: on_dump}) when is_atom(data) do 115 | case on_dump do 116 | %{^data => as_string} -> {:ok, as_string} 117 | _ -> :error 118 | end 119 | end 120 | 121 | def dump(%UserRole{name: data}, _dumper, %{on_dump: on_dump}) do 122 | case on_dump do 123 | %{^data => as_string} -> {:ok, as_string} 124 | _ -> :error 125 | end 126 | end 127 | 128 | @impl true 129 | def equal?(a, b, _params), do: a == b 130 | 131 | @impl true 132 | def embed_as(_, _), do: :self 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /test/phoenix_starter_web/controllers/user_reset_password_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserResetPasswordControllerTest do 2 | use PhoenixStarterWeb.ConnCase, async: true 3 | 4 | alias PhoenixStarter.Users 5 | alias PhoenixStarter.Repo 6 | import PhoenixStarter.UsersFixtures 7 | 8 | setup do 9 | %{user: user_fixture()} 10 | end 11 | 12 | describe "GET /users/reset_password" do 13 | test "renders the reset password page", %{conn: conn} do 14 | conn = get(conn, Routes.user_reset_password_path(conn, :new)) 15 | response = html_response(conn, 200) 16 | assert response =~ "

Forgot your password?

" 17 | end 18 | end 19 | 20 | describe "POST /users/reset_password" do 21 | @tag :capture_log 22 | test "sends a new reset password token", %{conn: conn, user: user} do 23 | conn = 24 | post(conn, Routes.user_reset_password_path(conn, :create), %{ 25 | "user" => %{"email" => user.email} 26 | }) 27 | 28 | assert redirected_to(conn) == "/" 29 | assert get_flash(conn, :info) =~ "If your email is in our system" 30 | assert Repo.get_by!(Users.UserToken, user_id: user.id).context == "reset_password" 31 | end 32 | 33 | test "does not send reset password token if email is invalid", %{conn: conn} do 34 | conn = 35 | post(conn, Routes.user_reset_password_path(conn, :create), %{ 36 | "user" => %{"email" => "unknown@example.com"} 37 | }) 38 | 39 | assert redirected_to(conn) == "/" 40 | assert get_flash(conn, :info) =~ "If your email is in our system" 41 | assert Repo.all(Users.UserToken) == [] 42 | end 43 | end 44 | 45 | describe "GET /users/reset_password/:token" do 46 | setup %{user: user} do 47 | token = 48 | extract_user_token(fn url -> 49 | Users.deliver_user_reset_password_instructions(user, url, false) 50 | end) 51 | 52 | %{token: token} 53 | end 54 | 55 | test "renders reset password", %{conn: conn, token: token} do 56 | conn = get(conn, Routes.user_reset_password_path(conn, :edit, token)) 57 | assert html_response(conn, 200) =~ "

Reset password

" 58 | end 59 | 60 | test "does not render reset password with invalid token", %{conn: conn} do 61 | conn = get(conn, Routes.user_reset_password_path(conn, :edit, "oops")) 62 | assert redirected_to(conn) == "/" 63 | assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" 64 | end 65 | end 66 | 67 | describe "PUT /users/reset_password/:token" do 68 | setup %{user: user} do 69 | token = 70 | extract_user_token(fn url -> 71 | Users.deliver_user_reset_password_instructions(user, url, false) 72 | end) 73 | 74 | %{token: token} 75 | end 76 | 77 | test "resets password once", %{conn: conn, user: user, token: token} do 78 | conn = 79 | put(conn, Routes.user_reset_password_path(conn, :update, token), %{ 80 | "user" => %{ 81 | "password" => "new valid password", 82 | "password_confirmation" => "new valid password" 83 | } 84 | }) 85 | 86 | assert redirected_to(conn) == Routes.user_session_path(conn, :new) 87 | refute get_session(conn, :user_token) 88 | assert get_flash(conn, :info) =~ "Password reset successfully" 89 | assert Users.get_user_by_email_and_password(user.email, "new valid password") 90 | end 91 | 92 | test "does not reset password on invalid data", %{conn: conn, token: token} do 93 | conn = 94 | put(conn, Routes.user_reset_password_path(conn, :update, token), %{ 95 | "user" => %{ 96 | "password" => "too short", 97 | "password_confirmation" => "does not match" 98 | } 99 | }) 100 | 101 | response = html_response(conn, 200) 102 | assert response =~ "

Reset password

" 103 | assert response =~ "should be at least 12 character(s)" 104 | assert response =~ "does not match password" 105 | end 106 | 107 | test "does not reset password with invalid token", %{conn: conn} do 108 | conn = put(conn, Routes.user_reset_password_path(conn, :update, "oops")) 109 | assert redirected_to(conn) == "/" 110 | assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /README_starter.md: -------------------------------------------------------------------------------- 1 | # PhoenixStarter 2 | 3 | _Use this introductory section to give the project's elevator pitch. Explain the idea. What are we building? Who is it for? What problems does it solve? What are the primary, proprietary aspects of the technology?_ 4 | 5 | ## Preflight 6 | 7 | To develop and run this app, a few dependencies are required. To check if you have them installed, run the preflight script: 8 | 9 | ```sh 10 | ./bin/preflight.sh 11 | ``` 12 | 13 | This will report which dependency, if any, still needs to be installed and configured. 14 | 15 | If all dependencies are present, it'll run `asdf` to install language versions. 16 | 17 | ### Prerequisites 18 | 19 | If the preflight script reports any missing executables or you run into any other errors, here are the prerequisites for running the app. Check you have all of these installed. 20 | 21 | 1. asdf, which manages package versions. We recommend [installing with the `git` method][asdf-install]. We also need plugins for the versions specified in `.tool-versions` (the preflight script will install these for you). Don’t forget to [load asdf in your shell][asdf-shell]. 22 | 2. Docker Desktop (community edition). You can download the Mac version from [Docker Hub]. It's a self-contained install. 23 | 3. PostgreSQL, the database. This can be installed with [Homebrew][brew-pg]: `brew install postgresql`. Be sure to follow the post-installation notes to make sure PG is running: `brew info postgresql`. You can start it with `brew services start postgresql`. 24 | 4. A few dependencies for installing languages: 25 | - `autoconf`: [required][erlang-req] to build Erlang; `brew install autoconf` 26 | - `gnupg`: [required][node-req] to verify NodeJS; `brew install gnupg` 27 | - For Erlang, it's recommended to skip the Java dependency on macOS. Run this or add to your shell: `export KERL_CONFIGURE_OPTIONS="--without-javac --with-ssl=$(brew --prefix openssl)"`. 28 | 29 | [asdf-install]: https://asdf-vm.com/#/core-manage-asdf?id=install 30 | [asdf-shell]: https://asdf-vm.com/#/core-manage-asdf?id=add-to-your-shell 31 | [docker hub]: https://www.docker.com/products/docker-desktop 32 | [brew-pg]: https://formulae.brew.sh/cask/postgres#default 33 | [erlang-req]: https://github.com/asdf-vm/asdf-erlang#osx 34 | [node-req]: https://github.com/asdf-vm/asdf-nodejs#install 35 | 36 | ## Setup 37 | 38 | Once you've finished preflight setup, run the setup script: 39 | 40 | ```sh 41 | mix setup 42 | ``` 43 | 44 | This will do a few things: 45 | 46 | 1. Run `mix deps.get` to fetch Elixir dependencies 47 | 2. Run `ecto.setup`, which creates, migrates, and seeds the DB 48 | 3. Run `npm install --prefix assets`, to fetch Node dependencies for the asset pipeline 49 | 50 | ## Running 51 | 52 | To start the web app, run the server command: 53 | 54 | ```sh 55 | mix phx.server 56 | ``` 57 | 58 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 59 | 60 | ## Contributing 61 | 62 | Refer to our [contributing guide] for our current standard practices for contributing new features and opening pull requests. 63 | 64 | ### Testing 65 | 66 | This project uses test-driven development. Tests can be found in the `test/`. 67 | To run the test suite, use Mix: 68 | 69 | ```sh 70 | mix test 71 | ``` 72 | 73 | Refer to our [testing guide] for our current standard practices on test-driven development. 74 | 75 | ### Documentation 76 | 77 | The codebase is documented where appropriate. 78 | 79 | To view the docs in development, run ExDoc: 80 | 81 | ```sh 82 | mix docs 83 | ``` 84 | 85 | This generates an HTML version available in the `doc/` folder. Open to view: 86 | 87 | ```sh 88 | open doc/index.html 89 | ``` 90 | 91 | ### Linting 92 | 93 | The codebase spans Elixir, JS, and CSS, and includes linters for each. 94 | 95 | For Elixir, there's the standard formatter, Credo (style) and Sobelow (security): 96 | 97 | ```sh 98 | mix format --check-formatted 99 | mix sobelow 100 | mix credo 101 | ``` 102 | 103 | For Javascript, there's Prettier (formatting) and ESLint (style): 104 | 105 | ```sh 106 | cd assets 107 | npx prettier --check . 108 | npx eslint js --ext .js,.jsx,.ts,.tsx 109 | ``` 110 | 111 | For CSS, there's Stylelint (style): 112 | 113 | ```sh 114 | cd assets 115 | npx stylelint "css/*" 116 | ``` 117 | 118 | ## Template 119 | 120 | This app is based on the NewAperio [Phoenix Starter] project, which is updated from time to time. Refer to that project for documentation and routine updates. 121 | 122 | ## About NewAperio 123 | 124 | PhoenixStarter is built by [NewAperio], LLC. 125 | 126 | [contributing guide]: https://github.com/newaperio/guides/blob/master/contributing/README.md 127 | [testing guide]: https://github.com/newaperio/guides/blob/master/testing/README.md 128 | [phoenix starter]: https://github.com/newaperio/phoenix_starter 129 | [newaperio]: https://newaperio.com 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Phoenix Starter 2 | 3 | ![CI](https://github.com/newaperio/phoenix_starter/workflows/CI/badge.svg) 4 | 5 | This repo contains the base app we use to get our Elixir / Phoenix apps started at [NewAperio]. 6 | 7 | The goal of this repo is to enable our development teams to start fast with a solid foundation. This app is updated with new releases and includes the boilerplate configuration we typically use when bootstrapping new products. 8 | 9 | ## Technologies 10 | 11 | The following technologies are included and configured with our defaults: 12 | 13 | - [Phoenix], the web framework 14 | - [Phoenix LiveView] for server-rendered reactive UI 15 | - [Ecto] for database integration 16 | - [ExUnit] for testing 17 | - [ExDoc] for rendering Elixir code documentation 18 | - [AssertIdentity] for easy Ecto identity assertions in tests 19 | - [phx_gen_auth] for a user authentication system 20 | - [Swoosh] for sending emails 21 | - [Oban] for background jobs 22 | - [asdf] for managing runtime versions 23 | - [Docker] for building release containers 24 | - [Mix Releases] for compiling release binaries 25 | - [Sentry] for error reporting 26 | - [GitHub Actions] for CI 27 | - [GitHub Dependabot] for automated security patches and weekly dep updates 28 | - [PostCSS] for building CSS 29 | - [Tailwind], a CSS framework 30 | - [TypeScript] for type-safe JS 31 | - [Alpine], a component JS framework that integrates with LiveView 32 | - [Sobelow] for Phoenix security static analysis 33 | - [Dialyzer] for Erlang/Elixir static analysis, including type-checking 34 | - A suite of linters: [Credo] for Elixir, [ESLint] for JS, [Stylelint] for CSS, and [Prettier] for JS formatting 35 | 36 | ## Usage 37 | 38 | The repo is setup as a [GitHub template] to make it easy to get started. 39 | 40 | 1. Click the ["Use this template" button]. This will setup a new repo with a clean history. 41 | 2. Clone the new repo locally. 42 | 3. Run the init script to rename the starter project and do some other housekeeping: `$ ./bin/init.sh MyApp my_app`. 43 | 4. Commit the result: `$ git add --all . && git commit -m "Initalize starter project"`. 44 | 45 | ## Updating 46 | 47 | To update a Phoenix app generated from this repo, you need to compare the changes between the version that initialized your repo and the current version. 48 | 49 | You can find your current version in the `.starter-version` file. This is the git tag that generated your app. 50 | 51 | You can find changes in the [CHANGELOG](/CHANGELOG.md). 52 | 53 | You can also check the diff on GitHub between your version and the latest version. There's a helpful script that will grab the version information and print out the URLs. 54 | 55 | ```sh 56 | ./bin/starter-version-info.sh 57 | ``` 58 | 59 | After updating make sure to update the version in `.starter-version` with the tag you updated to. 60 | 61 | ## License 62 | 63 | Phoenix Starter is Copyright © 2020 NewAperio. It is free software, and may be redistributed under the terms specified in the [LICENSE](/LICENSE) file. 64 | 65 | ## About NewAperio 66 | 67 | PhoenixStarter is built by NewAperio, LLC. 68 | 69 | NewAperio is a web and mobile design and development studio. We offer [expert 70 | Elixir and Phoenix][services] development as part of our portfolio of services. 71 | [Get in touch][contact] to see how our team can help you. 72 | 73 | [newaperio]: https://newaperio.com?utm_source=github 74 | [phoenix]: https://github.com/phoenixframework/phoenix 75 | [phoenix liveview]: https://github.com/phoenixframework/phoenix_live_view 76 | [ecto]: https://github.com/elixir-ecto/ecto 77 | [exunit]: https://hexdocs.pm/ex_unit/master/ExUnit.html 78 | [exdoc]: https://github.com/elixir-lang/ex_doc 79 | [assertidentity]: https://github.com/newaperio/assert_identity/ 80 | [phx_gen_auth]: https://github.com/aaronrenner/phx_gen_auth 81 | [swoosh]: https://github.com/swoosh/swoosh 82 | [oban]: https://github.com/sorentwo/oban 83 | [asdf]: https://asdf-vm.com/ 84 | [docker]: https://docs.docker.com/ 85 | [mix releases]: https://hexdocs.pm/mix/Mix.Tasks.Release.html 86 | [sentry]: https://sentry.io/welcome/ 87 | [github actions]: https://github.com/features/actions 88 | [github dependabot]: https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/enabling-and-disabling-version-updates 89 | [postcss]: https://postcss.org/ 90 | [tailwind]: https://tailwindcss.com/ 91 | [typescript]: https://www.typescriptlang.org/ 92 | [alpine]: https://github.com/alpinejs/alpine/ 93 | [sobelow]: https://github.com/nccgroup/sobelow 94 | [credo]: https://github.com/rrrene/credo 95 | [eslint]: https://eslint.org/ 96 | [stylelint]: https://stylelint.io/ 97 | [prettier]: https://prettier.io/ 98 | [github template]: https://docs.github.com/en/free-pro-team@latest/github/creating-cloning-and-archiving-repositories/creating-a-repository-from-a-template#creating-a-repository-from-a-template 99 | ["use this template" button]: https://github.com/newaperio/phoenix_starter/generate 100 | [services]: https://newaperio.com/services#elixir?utm_source=github 101 | [contact]: https://newaperio.com/contact?utm_source=github 102 | -------------------------------------------------------------------------------- /lib/phoenix_starter/uploads.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Uploads do 2 | @moduledoc """ 3 | Logic for creating direct uploads to AWS S3. 4 | """ 5 | 6 | @default_upload_limit 25 * 1024 * 1024 7 | 8 | @doc """ 9 | Returns a presigned URL to the upload on S3. 10 | 11 | It is assumed that the given param is a list of maps with string keys `variation` 12 | and `key`. The returned URL is to the map with `variation == "source"`. 13 | Returns `nil` if no match is found. 14 | """ 15 | @spec upload_url(list(map())) :: String.t() | nil 16 | def upload_url(upload) do 17 | upload 18 | |> Enum.find(fn u -> 19 | variation = Map.get(u, "variation") 20 | variation == "source" && Map.has_key?(u, "key") 21 | end) 22 | |> case do 23 | nil -> 24 | nil 25 | 26 | source -> 27 | presigned_url(source["key"]) 28 | end 29 | end 30 | 31 | defp presigned_url(key) do 32 | {:ok, url} = 33 | ExAws.S3.presigned_url(config(), :get, config(:bucket_name), key, virtual_host: true) 34 | 35 | url 36 | end 37 | 38 | @doc """ 39 | Creates a presigned direct upload. 40 | 41 | Creates the fields necessary for a presigned and authenticated upload request to AWS S3. 42 | 43 | First argument is a `Phoenix.LiveView.UploadEntry` from a LiveView upload form. 44 | 45 | Second argument is options. Currently `opts` accepts: 46 | 47 | - `:acl` - AWS canned ACL for the upload (default: "private") 48 | - `:prefix` - path prefix for file location in S3 (default: "cache/") 49 | - `:upload_limit` - limit in bytes for the upload (default: 25mb) 50 | 51 | Returns a map with the fields necessary for successfully completing an upload request. 52 | 53 | ## Examples 54 | 55 | iex> create_upload(%Phoenix.LiveView.UploadEntry{}) 56 | %{method: "post", ...} 57 | 58 | """ 59 | @spec create_upload(Phoenix.LiveView.UploadEntry.t(), keyword(), DateTime.t()) :: {:ok, map()} 60 | def create_upload(entry, opts \\ [], now \\ Timex.now()) do 61 | upload_acl = Keyword.get(opts, :acl, "private") 62 | upload_limit = Keyword.get(opts, :upload_limit, @default_upload_limit) 63 | upload_prefix = Keyword.get(opts, :prefix, "cache/") 64 | 65 | upload = %{method: "post", url: bucket_url()} 66 | policy = generate_policy(entry.client_type, upload_acl, upload_limit, upload_prefix, now) 67 | 68 | fields = %{ 69 | :acl => upload_acl, 70 | "Content-Type" => entry.client_type, 71 | :key => generate_key(entry, upload_prefix), 72 | :policy => policy, 73 | "x-amz-algorithm" => generate_amz_algorithm(), 74 | "x-amz-credential" => generate_amz_credential(now), 75 | "x-amz-date" => generate_amz_date(now), 76 | "x-amz-signature" => generate_signature(policy, now) 77 | } 78 | 79 | {:ok, Map.put(upload, :fields, fields)} 80 | end 81 | 82 | defp generate_policy(content_type, acl, upload_limit, prefix, now) do 83 | expires_at = now |> Timex.shift(minutes: 5) |> Timex.format!("{ISO:Extended:Z}") 84 | 85 | %{ 86 | expiration: expires_at, 87 | conditions: [ 88 | %{acl: acl}, 89 | %{bucket: config(:bucket_name)}, 90 | ["content-length-range", 0, upload_limit], 91 | %{"Content-Type" => content_type}, 92 | ["starts-with", "$key", prefix], 93 | %{"x-amz-algorithm" => generate_amz_algorithm()}, 94 | %{"x-amz-credential" => generate_amz_credential(now)}, 95 | %{"x-amz-date" => generate_amz_date(now)} 96 | ] 97 | } 98 | |> Jason.encode!() 99 | |> Base.encode64() 100 | end 101 | 102 | defp generate_signature(policy, now) do 103 | date = now |> Timex.to_date() |> Timex.format!("{YYYY}{0M}{0D}") 104 | 105 | "AWS4#{config(:secret_access_key)}" 106 | |> hmac_digest(date) 107 | |> hmac_digest(config(:region)) 108 | |> hmac_digest("s3") 109 | |> hmac_digest("aws4_request") 110 | |> hmac_digest(policy) 111 | |> hmac_hexdigest() 112 | end 113 | 114 | defp config() do 115 | :phoenix_starter 116 | |> Application.get_env(PhoenixStarter.Uploads) 117 | |> Enum.into(%{}) 118 | |> Map.merge(ExAws.Config.new(:s3)) 119 | end 120 | 121 | defp config(key), do: Map.get(config(), key) 122 | 123 | defp bucket_url do 124 | "https://#{config(:bucket_name)}.s3.amazonaws.com" 125 | end 126 | 127 | defp generate_amz_algorithm, do: "AWS4-HMAC-SHA256" 128 | 129 | defp generate_amz_credential(now) do 130 | date = now |> Timex.to_date() |> Timex.format!("{YYYY}{0M}{0D}") 131 | "#{config(:access_key_id)}/#{date}/#{config(:region)}/s3/aws4_request" 132 | end 133 | 134 | defp generate_amz_date(now) do 135 | now |> Timex.to_date() |> Timex.format!("{ISO:Basic:Z}") 136 | end 137 | 138 | defp generate_key(entry, prefix) do 139 | key = 12 |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false) 140 | ext = Path.extname(entry.client_name) 141 | Path.join([prefix, "#{key}#{ext}"]) 142 | end 143 | 144 | defp hmac_digest(key, string) do 145 | :crypto.mac(:hmac, :sha256, key, string) 146 | end 147 | 148 | defp hmac_hexdigest(digest) do 149 | Base.encode16(digest, case: :lower) 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/phoenix_starter/users/user_token.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Users.UserToken do 2 | @moduledoc """ 3 | Represents a token authenticating a particular `PhoenixStarterWeb.Users.User`. 4 | """ 5 | use PhoenixStarter.Schema 6 | import Ecto.Query 7 | alias PhoenixStarter.Users.User 8 | 9 | @hash_algorithm :sha256 10 | @rand_size 32 11 | 12 | # It is very important to keep the reset password token expiry short, 13 | # since someone with access to the email may take over the account. 14 | @reset_password_validity_in_days 1 15 | @confirm_validity_in_days 7 16 | @change_email_validity_in_days 7 17 | @session_validity_in_days 60 18 | 19 | schema "users_tokens" do 20 | field :token, :binary 21 | field :context, :string 22 | field :sent_to, :string 23 | belongs_to :user, PhoenixStarter.Users.User 24 | 25 | timestamps(updated_at: false) 26 | end 27 | 28 | @doc """ 29 | Generates a token that will be stored in a signed place, 30 | such as session or cookie. As they are signed, those 31 | tokens do not need to be hashed. 32 | """ 33 | @spec build_session_token(User.t()) :: {binary, t} 34 | def build_session_token(user) do 35 | token = :crypto.strong_rand_bytes(@rand_size) 36 | {token, %PhoenixStarter.Users.UserToken{token: token, context: "session", user_id: user.id}} 37 | end 38 | 39 | @doc """ 40 | Checks if the token is valid and returns its underlying lookup query. 41 | 42 | The query returns the `PhoenixStarter.Users.User` found by the token. 43 | """ 44 | @spec verify_session_token_query(binary) :: {:ok, Ecto.Query.t()} 45 | def verify_session_token_query(token) do 46 | query = 47 | from token in token_and_context_query(token, "session"), 48 | join: user in assoc(token, :user), 49 | where: token.inserted_at > ago(@session_validity_in_days, "day"), 50 | select: user 51 | 52 | {:ok, query} 53 | end 54 | 55 | @doc """ 56 | Builds a token with a hashed counter part. 57 | 58 | The non-hashed token is sent to the `PhoenixStarter.Users.User` email while 59 | the hashed part is stored in the database, to avoid reconstruction. The 60 | token is valid for a week as long as the email doen't change. 61 | """ 62 | @spec build_email_token(User.t(), String.t()) :: {binary, t} 63 | def build_email_token(user, context) do 64 | build_hashed_token(user, context, user.email) 65 | end 66 | 67 | defp build_hashed_token(user, context, sent_to) do 68 | token = :crypto.strong_rand_bytes(@rand_size) 69 | hashed_token = :crypto.hash(@hash_algorithm, token) 70 | 71 | {Base.url_encode64(token, padding: false), 72 | %PhoenixStarter.Users.UserToken{ 73 | token: hashed_token, 74 | context: context, 75 | sent_to: sent_to, 76 | user_id: user.id 77 | }} 78 | end 79 | 80 | @doc """ 81 | Checks if the token is valid and returns its underlying lookup query. 82 | 83 | The query returns the `PhoenixStarter.Users.User` found by the token. 84 | """ 85 | @spec verify_email_token_query(binary, String.t()) :: {:ok, Ecto.Query.t()} | :error 86 | def verify_email_token_query(token, context) do 87 | case Base.url_decode64(token, padding: false) do 88 | {:ok, decoded_token} -> 89 | hashed_token = :crypto.hash(@hash_algorithm, decoded_token) 90 | days = days_for_context(context) 91 | 92 | query = 93 | from token in token_and_context_query(hashed_token, context), 94 | join: user in assoc(token, :user), 95 | where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email, 96 | select: user 97 | 98 | {:ok, query} 99 | 100 | :error -> 101 | :error 102 | end 103 | end 104 | 105 | defp days_for_context("confirm"), do: @confirm_validity_in_days 106 | defp days_for_context("reset_password"), do: @reset_password_validity_in_days 107 | 108 | @doc """ 109 | Checks if the token is valid and returns its underlying lookup query. 110 | 111 | The query returns the `PhoenixStarter.Users.User` token record. 112 | """ 113 | @spec verify_change_email_token_query(binary, String.t()) :: {:ok, Ecto.Query.t()} | :error 114 | def verify_change_email_token_query(token, context) do 115 | case Base.url_decode64(token, padding: false) do 116 | {:ok, decoded_token} -> 117 | hashed_token = :crypto.hash(@hash_algorithm, decoded_token) 118 | 119 | query = 120 | from token in token_and_context_query(hashed_token, context), 121 | where: token.inserted_at > ago(@change_email_validity_in_days, "day") 122 | 123 | {:ok, query} 124 | 125 | :error -> 126 | :error 127 | end 128 | end 129 | 130 | @doc """ 131 | Returns the given token with the given context. 132 | """ 133 | @spec token_and_context_query(binary, String.t()) :: Ecto.Query.t() 134 | def token_and_context_query(token, context) do 135 | from PhoenixStarter.Users.UserToken, where: [token: ^token, context: ^context] 136 | end 137 | 138 | @doc """ 139 | Gets all tokens for the given `PhoenixStarter.Users.User` for the given contexts. 140 | """ 141 | @spec user_and_contexts_query(User.t(), :all | list) :: Ecto.Query.t() 142 | def user_and_contexts_query(user, :all) do 143 | from t in PhoenixStarter.Users.UserToken, where: t.user_id == ^user.id 144 | end 145 | 146 | def user_and_contexts_query(user, [_ | _] = contexts) do 147 | from t in PhoenixStarter.Users.UserToken, 148 | where: t.user_id == ^user.id and t.context in ^contexts 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | configs: [ 3 | %{ 4 | name: "default", 5 | files: %{ 6 | included: ["*.exs", "config/", "lib/", "priv/", "test/"], 7 | excluded: ["priv/templates/"] 8 | }, 9 | plugins: [], 10 | requires: [], 11 | strict: false, 12 | parse_timeout: 5000, 13 | color: true, 14 | checks: [ 15 | {Credo.Check.Consistency.ExceptionNames, []}, 16 | {Credo.Check.Consistency.LineEndings, []}, 17 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 18 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 19 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 20 | {Credo.Check.Consistency.SpaceInParentheses, []}, 21 | {Credo.Check.Consistency.TabsOrSpaces, []}, 22 | {Credo.Check.Consistency.UnusedVariableNames, false}, 23 | {Credo.Check.Design.AliasUsage, 24 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 25 | {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, 26 | {Credo.Check.Design.TagFIXME, []}, 27 | {Credo.Check.Design.TagTODO, []}, 28 | {Credo.Check.Readability.AliasAs, []}, 29 | {Credo.Check.Readability.AliasOrder, []}, 30 | {Credo.Check.Readability.BlockPipe, false}, 31 | {Credo.Check.Readability.FunctionNames, []}, 32 | {Credo.Check.Readability.ImplTrue, false}, 33 | {Credo.Check.Readability.LargeNumbers, []}, 34 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 35 | {Credo.Check.Readability.ModuleAttributeNames, []}, 36 | {Credo.Check.Readability.ModuleDoc, 37 | [ 38 | ignore_names: [ 39 | ~r/(\.\w+Controller|\.Endpoint|\.Repo|\.Router|\.\w+Socket|\.\w+View|\.\w+Live(.\w+)?|\.\w+Component)$/ 40 | ] 41 | ]}, 42 | {Credo.Check.Readability.ModuleNames, []}, 43 | {Credo.Check.Readability.MultiAlias, []}, 44 | {Credo.Check.Readability.ParenthesesInCondition, []}, 45 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 46 | {Credo.Check.Readability.PredicateFunctionNames, []}, 47 | {Credo.Check.Readability.PreferImplicitTry, []}, 48 | {Credo.Check.Readability.RedundantBlankLines, []}, 49 | {Credo.Check.Readability.Semicolons, []}, 50 | {Credo.Check.Readability.SeparateAliasRequire, []}, 51 | {Credo.Check.Readability.SinglePipe, []}, 52 | {Credo.Check.Readability.SpaceAfterCommas, []}, 53 | {Credo.Check.Readability.Specs, 54 | files: %{ 55 | excluded: [ 56 | "mix.exs", 57 | "lib/phoenix_starter_web.ex", 58 | "lib/phoenix_starter_web/", 59 | "test/", 60 | "priv/" 61 | ] 62 | }}, 63 | {Credo.Check.Readability.StrictModuleLayout, 64 | [order: [:shortdoc, :moduledoc, :behaviour, :use, :require, :import, :alias]]}, 65 | {Credo.Check.Readability.StringSigils, []}, 66 | {Credo.Check.Readability.TrailingBlankLine, []}, 67 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 68 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 69 | {Credo.Check.Readability.VariableNames, []}, 70 | {Credo.Check.Readability.WithCustomTaggedTuple, false}, 71 | {Credo.Check.Refactor.ABCSize, false}, 72 | {Credo.Check.Refactor.AppendSingleItem, false}, 73 | {Credo.Check.Refactor.CondStatements, []}, 74 | {Credo.Check.Refactor.CyclomaticComplexity, false}, 75 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 76 | {Credo.Check.Refactor.FunctionArity, []}, 77 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 78 | {Credo.Check.Refactor.MatchInCondition, []}, 79 | {Credo.Check.Refactor.ModuleDependencies, false}, 80 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 81 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 82 | {Credo.Check.Refactor.NegatedIsNil, false}, 83 | {Credo.Check.Refactor.Nesting, []}, 84 | {Credo.Check.Refactor.PipeChainStart, [excluded_functions: ~w(from)]}, 85 | {Credo.Check.Refactor.UnlessWithElse, []}, 86 | {Credo.Check.Refactor.VariableRebinding, false}, 87 | {Credo.Check.Refactor.WithClauses, []}, 88 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 89 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 90 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 91 | {Credo.Check.Warning.IExPry, []}, 92 | {Credo.Check.Warning.IoInspect, []}, 93 | {Credo.Check.Warning.LeakyEnvironment, []}, 94 | {Credo.Check.Warning.MapGetUnsafePass, []}, 95 | {Credo.Check.Warning.MixEnv, false}, 96 | {Credo.Check.Warning.OperationOnSameValues, []}, 97 | {Credo.Check.Warning.OperationWithConstantResult, []}, 98 | {Credo.Check.Warning.RaiseInsideRescue, []}, 99 | {Credo.Check.Warning.UnsafeExec, []}, 100 | {Credo.Check.Warning.UnsafeToAtom, []}, 101 | {Credo.Check.Warning.UnusedEnumOperation, []}, 102 | {Credo.Check.Warning.UnusedFileOperation, []}, 103 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 104 | {Credo.Check.Warning.UnusedListOperation, []}, 105 | {Credo.Check.Warning.UnusedPathOperation, []}, 106 | {Credo.Check.Warning.UnusedRegexOperation, []}, 107 | {Credo.Check.Warning.UnusedStringOperation, []}, 108 | {Credo.Check.Warning.UnusedTupleOperation, []} 109 | ] 110 | } 111 | ] 112 | } 113 | -------------------------------------------------------------------------------- /lib/phoenix_starter_web/controllers/user_auth.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarterWeb.UserAuth do 2 | @moduledoc """ 3 | Session helpers to authentication. 4 | """ 5 | import Plug.Conn 6 | import Phoenix.Controller 7 | 8 | alias PhoenixStarter.Users 9 | alias PhoenixStarterWeb.Router.Helpers, as: Routes 10 | 11 | # Make the remember me cookie valid for 60 days. 12 | # If you want bump or reduce this value, also change 13 | # the token expiry itself in `PhoenixStarter.Users.UserToken`. 14 | @max_age 60 * 60 * 24 * 60 15 | @remember_me_cookie "_phoenix_starter_web_user_remember_me" 16 | @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] 17 | 18 | @doc """ 19 | Logs the `PhoenixStarter.Users.User` in. 20 | 21 | It renews the session ID and clears the whole session 22 | to avoid fixation attacks. See the renew_session 23 | function to customize this behaviour. 24 | 25 | It also sets a `:live_socket_id` key in the session, 26 | so LiveView sessions are identified and automatically 27 | disconnected on log out. The line can be safely removed 28 | if you are not using LiveView. 29 | """ 30 | @spec log_in_user(Plug.Conn.t(), Users.User.t(), map) :: Plug.Conn.t() 31 | def log_in_user(conn, user, params \\ %{}) do 32 | token = Users.generate_user_session_token(user) 33 | user_return_to = get_session(conn, :user_return_to) 34 | 35 | conn 36 | |> renew_session() 37 | |> put_session(:user_token, token) 38 | |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") 39 | |> maybe_write_remember_me_cookie(token, params) 40 | |> redirect(to: user_return_to || signed_in_path(conn)) 41 | end 42 | 43 | defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do 44 | put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) 45 | end 46 | 47 | defp maybe_write_remember_me_cookie(conn, _token, _params) do 48 | conn 49 | end 50 | 51 | # This function renews the session ID and erases the whole 52 | # session to avoid fixation attacks. If there is any data 53 | # in the session you may want to preserve after log in/log out, 54 | # you must explicitly fetch the session data before clearing 55 | # and then immediately set it after clearing, for example: 56 | # 57 | # defp renew_session(conn) do 58 | # preferred_locale = get_session(conn, :preferred_locale) 59 | # 60 | # conn 61 | # |> configure_session(renew: true) 62 | # |> clear_session() 63 | # |> put_session(:preferred_locale, preferred_locale) 64 | # end 65 | # 66 | defp renew_session(conn) do 67 | conn 68 | |> configure_session(renew: true) 69 | |> clear_session() 70 | end 71 | 72 | @doc """ 73 | Logs the `PhoenixStarter.Users.User` out. 74 | 75 | It clears all session data for safety. See renew_session. 76 | """ 77 | @spec log_out_user(Plug.Conn.t()) :: Plug.Conn.t() 78 | def log_out_user(conn) do 79 | user_token = get_session(conn, :user_token) 80 | user_token && Users.delete_session_token(user_token) 81 | 82 | if live_socket_id = get_session(conn, :live_socket_id) do 83 | PhoenixStarterWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) 84 | end 85 | 86 | conn 87 | |> renew_session() 88 | |> delete_resp_cookie(@remember_me_cookie) 89 | |> redirect(to: "/") 90 | end 91 | 92 | @doc """ 93 | Authenticates the `PhoenixStarter.Users.User` by looking into the session 94 | and remember me token. 95 | """ 96 | @spec fetch_current_user(Plug.Conn.t(), any) :: Plug.Conn.t() 97 | def fetch_current_user(conn, _opts) do 98 | {user_token, conn} = ensure_user_token(conn) 99 | user = user_token && Users.get_user_by_session_token(user_token) 100 | assign(conn, :current_user, user) 101 | end 102 | 103 | defp ensure_user_token(conn) do 104 | if user_token = get_session(conn, :user_token) do 105 | {user_token, conn} 106 | else 107 | conn = fetch_cookies(conn, signed: [@remember_me_cookie]) 108 | 109 | if user_token = conn.cookies[@remember_me_cookie] do 110 | {user_token, put_session(conn, :user_token, user_token)} 111 | else 112 | {nil, conn} 113 | end 114 | end 115 | end 116 | 117 | @doc """ 118 | Used for routes that require the `PhoenixStarter.Users.User` to not be authenticated. 119 | """ 120 | @spec redirect_if_user_is_authenticated(Plug.Conn.t(), any) :: Plug.Conn.t() 121 | def redirect_if_user_is_authenticated(conn, _opts) do 122 | if conn.assigns[:current_user] do 123 | conn 124 | |> redirect(to: signed_in_path(conn)) 125 | |> halt() 126 | else 127 | conn 128 | end 129 | end 130 | 131 | @doc """ 132 | Used for routes that require the `PhoenixStarter.Users.User` to be authenticated. 133 | 134 | If you want to enforce the `PhoenixStarter.Users.User` email is confirmed before 135 | they use the application at all, here would be a good place. 136 | """ 137 | @spec require_authenticated_user(Plug.Conn.t(), any) :: Plug.Conn.t() 138 | def require_authenticated_user(conn, _opts) do 139 | if conn.assigns[:current_user] do 140 | conn 141 | else 142 | conn 143 | |> put_flash(:error, "You must log in to access this page.") 144 | |> maybe_store_return_to() 145 | |> redirect(to: Routes.user_session_path(conn, :new)) 146 | |> halt() 147 | end 148 | end 149 | 150 | defp maybe_store_return_to(%{method: "GET"} = conn) do 151 | put_session(conn, :user_return_to, current_path(conn)) 152 | end 153 | 154 | defp maybe_store_return_to(conn), do: conn 155 | 156 | defp signed_in_path(_conn), do: "/" 157 | end 158 | -------------------------------------------------------------------------------- /lib/phoenix_starter/users/user.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStarter.Users.User do 2 | @moduledoc """ 3 | Represents a user who can authenticate with the system. 4 | """ 5 | use PhoenixStarter.Schema 6 | import Ecto.Changeset 7 | alias Ecto.Changeset 8 | alias PhoenixStarter.Users.UserRole 9 | 10 | @derive {Inspect, except: [:password]} 11 | schema "users" do 12 | field :email, :string 13 | field :password, :string, virtual: true 14 | field :hashed_password, :string 15 | field :confirmed_at, :naive_datetime 16 | field :role, UserRole.Type, roles: UserRole.roles(), default: :user 17 | field :profile_image, {:array, :map} 18 | 19 | timestamps() 20 | end 21 | 22 | @doc """ 23 | A `PhoenixStarter.Users.User` changeset for registration. 24 | 25 | It is important to validate the length of both email and password. 26 | Otherwise databases may truncate the email without warnings, which 27 | could lead to unpredictable or insecure behaviour. Long passwords may 28 | also be very expensive to hash for certain algorithms. 29 | 30 | ## Options 31 | 32 | * `:hash_password` - Hashes the password so it can be stored securely 33 | in the database and ensures the password field is cleared to prevent 34 | leaks in the logs. If password hashing is not needed and clearing the 35 | password field is not desired (like when using this changeset for 36 | validations on a LiveView form), this option can be set to `false`. 37 | Defaults to `true`. 38 | 39 | """ 40 | @spec registration_changeset(t, map, list) :: Changeset.t() 41 | def registration_changeset(user, attrs, opts \\ []) do 42 | user 43 | |> cast(attrs, [:email, :password, :role]) 44 | |> validate_email() 45 | |> validate_password(opts) 46 | |> validate_required([:role]) 47 | end 48 | 49 | defp validate_email(changeset) do 50 | changeset 51 | |> validate_required([:email]) 52 | |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") 53 | |> validate_length(:email, max: 160) 54 | |> unsafe_validate_unique(:email, PhoenixStarter.Repo) 55 | |> unique_constraint(:email) 56 | end 57 | 58 | defp validate_password(changeset, opts) do 59 | changeset 60 | |> validate_required([:password]) 61 | |> validate_length(:password, min: 12, max: 80) 62 | # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") 63 | # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") 64 | # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") 65 | |> maybe_hash_password(opts) 66 | end 67 | 68 | defp maybe_hash_password(changeset, opts) do 69 | hash_password? = Keyword.get(opts, :hash_password, true) 70 | password = get_change(changeset, :password) 71 | 72 | if hash_password? && password && changeset.valid? do 73 | changeset 74 | |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) 75 | |> delete_change(:password) 76 | else 77 | changeset 78 | end 79 | end 80 | 81 | @doc """ 82 | A `PhoenixStarter.Users.User` changeset for changing the email. 83 | 84 | It requires the email to change otherwise an error is added. 85 | """ 86 | @spec email_changeset(t, map) :: Changeset.t() 87 | def email_changeset(user, attrs) do 88 | user 89 | |> cast(attrs, [:email]) 90 | |> validate_email() 91 | |> case do 92 | %{changes: %{email: _}} = changeset -> changeset 93 | %{errors: [_ | _]} = changeset -> changeset 94 | %{} = changeset -> add_error(changeset, :email, "did not change") 95 | end 96 | end 97 | 98 | @doc """ 99 | A `PhoenixStarter.Users.User` changeset for changing the password. 100 | 101 | ## Options 102 | 103 | * `:hash_password` - Hashes the password so it can be stored securely 104 | in the database and ensures the password field is cleared to prevent 105 | leaks in the logs. If password hashing is not needed and clearing the 106 | password field is not desired (like when using this changeset for 107 | validations on a LiveView form), this option can be set to `false`. 108 | Defaults to `true`. 109 | 110 | """ 111 | @spec password_changeset(t, map, list) :: Changeset.t() 112 | def password_changeset(user, attrs, opts \\ []) do 113 | user 114 | |> cast(attrs, [:password]) 115 | |> validate_confirmation(:password, message: "does not match password") 116 | |> validate_password(opts) 117 | end 118 | 119 | @doc """ 120 | Confirms the account by setting `confirmed_at`. 121 | """ 122 | @spec confirm_changeset(t | Changeset.t()) :: Changeset.t() 123 | def confirm_changeset(user) do 124 | now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) 125 | change(user, confirmed_at: now) 126 | end 127 | 128 | @doc """ 129 | Verifies the password. 130 | 131 | If there is no `PhoenixStarter.Users.User` or the `User` doesn't have a password, we call 132 | `Bcrypt.no_user_verify/0` to avoid timing attacks. 133 | """ 134 | @spec valid_password?(t, String.t() | nil) :: boolean() 135 | def valid_password?(%PhoenixStarter.Users.User{hashed_password: hashed_password}, password) 136 | when is_binary(hashed_password) and byte_size(password) > 0 do 137 | Bcrypt.verify_pass(password, hashed_password) 138 | end 139 | 140 | def valid_password?(_, _) do 141 | Bcrypt.no_user_verify() 142 | end 143 | 144 | @doc """ 145 | Validates the current password otherwise adds an error to the changeset. 146 | """ 147 | @spec validate_current_password(Changeset.t(), String.t() | nil) :: Changeset.t() 148 | def validate_current_password(changeset, password) do 149 | if valid_password?(changeset.data, password) do 150 | changeset 151 | else 152 | add_error(changeset, :current_password, "is not valid") 153 | end 154 | end 155 | 156 | @doc """ 157 | A `PhoenixStarter.Users.User` changeset for updating the profile. 158 | """ 159 | @spec profile_changeset(t, map) :: Changeset.t() 160 | def profile_changeset(user, attrs) do 161 | cast(user, attrs, [:profile_image]) 162 | end 163 | end 164 | 165 | defimpl Swoosh.Email.Recipient, for: PhoenixStarter.Users.User do 166 | @spec format(PhoenixStarter.Users.User.t()) :: {String.t(), String.t()} 167 | def format(%PhoenixStarter.Users.User{email: address}) do 168 | {"", address} 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - "**" 10 | 11 | env: 12 | MIX_ENV: test 13 | 14 | jobs: 15 | deps: 16 | runs-on: ubuntu-latest 17 | name: Dependencies 18 | 19 | strategy: 20 | matrix: 21 | elixir: ["1.13.2"] 22 | otp: ["24.2.1"] 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | 27 | - uses: erlef/setup-beam@v1 28 | with: 29 | otp-version: ${{matrix.otp}} 30 | elixir-version: ${{matrix.elixir}} 31 | 32 | - name: Restore dependencies cache 33 | uses: actions/cache@v2 34 | with: 35 | path: deps 36 | key: ${{runner.os}}-mix-${{matrix.otp}}-${{matrix.elixir}}-${{hashFiles('**/mix.lock')}} 37 | restore-keys: ${{runner.os}}-mix- 38 | 39 | - name: Restore build cache 40 | uses: actions/cache@v2 41 | with: 42 | path: _build 43 | key: ${{runner.os}}-build-${{matrix.otp}}-${{matrix.elixir}}-${{hashFiles('**/mix.lock')}} 44 | 45 | - name: Install Dependencies 46 | run: mix deps.get 47 | 48 | - name: Compile app 49 | run: mix compile --force --warnings-as-errors 50 | 51 | assets: 52 | name: Frontend Assets 53 | needs: deps 54 | runs-on: ubuntu-latest 55 | 56 | strategy: 57 | matrix: 58 | elixir: ["1.13.2"] 59 | otp: ["24.2.1"] 60 | node: ["14.13.1"] 61 | 62 | steps: 63 | - uses: actions/checkout@v2 64 | 65 | - uses: actions/setup-node@v1 66 | with: 67 | node-version: ${{matrix.node}} 68 | 69 | - name: Restore dependencies cache 70 | uses: actions/cache@v2 71 | with: 72 | path: deps 73 | key: ${{runner.os}}-mix-${{matrix.otp}}-${{matrix.elixir}}-${{hashFiles('**/mix.lock')}} 74 | restore-keys: ${{runner.os}}-mix- 75 | 76 | - name: Restore NPM cache 77 | uses: actions/cache@v2 78 | with: 79 | path: ~/.npm 80 | key: ${{runner.os}}-npm-${{matrix.node}}-${{hashFiles(format('**/assets/package-lock.json'))}} 81 | restore-keys: ${{runner.os}}-npm- 82 | 83 | - name: Install Node dependencies 84 | run: npm --prefix assets ci 85 | - run: npx prettier --check . 86 | working-directory: ./assets 87 | - run: npx stylelint "css/*" 88 | working-directory: ./assets 89 | - run: npx eslint js --ext .js,.jsx,.ts,.tsx 90 | working-directory: ./assets 91 | - run: npm run deploy 92 | env: 93 | NODE_ENV: production 94 | working-directory: ./assets 95 | 96 | lint: 97 | name: Lint 98 | needs: deps 99 | runs-on: ubuntu-latest 100 | env: 101 | MIX_ENV: dev 102 | 103 | strategy: 104 | matrix: 105 | elixir: ["1.13.2"] 106 | otp: ["24.2.1"] 107 | 108 | steps: 109 | - uses: actions/checkout@v2 110 | 111 | - uses: erlef/setup-beam@v1 112 | with: 113 | otp-version: ${{matrix.otp}} 114 | elixir-version: ${{matrix.elixir}} 115 | 116 | - name: Restore dependencies cache 117 | uses: actions/cache@v2 118 | with: 119 | path: deps 120 | key: ${{runner.os}}-mix-${{matrix.otp}}-${{matrix.elixir}}-${{hashFiles('**/mix.lock')}} 121 | restore-keys: ${{runner.os}}-mix- 122 | 123 | - name: Restore build cache 124 | uses: actions/cache@v2 125 | with: 126 | path: _build 127 | key: ${{runner.os}}-build-${{matrix.otp}}-${{matrix.elixir}}-${{hashFiles('**/mix.lock')}} 128 | 129 | - run: mix deps.unlock --check-unused 130 | - run: mix format --check-formatted 131 | - run: mix xref graph --label compile-connected --fail-above 0 132 | - run: mix credo 133 | - run: mix sobelow --config 134 | 135 | dialyzer: 136 | name: Dialyzer 137 | needs: deps 138 | runs-on: ubuntu-latest 139 | env: 140 | MIX_ENV: dev 141 | 142 | strategy: 143 | matrix: 144 | elixir: ["1.13.2"] 145 | otp: ["24.2.1"] 146 | 147 | steps: 148 | - uses: actions/checkout@v2 149 | 150 | - uses: erlef/setup-beam@v1 151 | with: 152 | otp-version: ${{matrix.otp}} 153 | elixir-version: ${{matrix.elixir}} 154 | 155 | - name: Restore dependencies cache 156 | uses: actions/cache@v2 157 | with: 158 | path: deps 159 | key: ${{runner.os}}-mix-${{matrix.otp}}-${{matrix.elixir}}-${{hashFiles('**/mix.lock')}} 160 | restore-keys: ${{runner.os}}-mix- 161 | 162 | - name: Restore build cache 163 | uses: actions/cache@v2 164 | with: 165 | path: _build 166 | key: ${{runner.os}}-build-${{matrix.otp}}-${{matrix.elixir}}-${{hashFiles('**/mix.lock')}} 167 | 168 | - name: Restore PLT cache 169 | uses: actions/cache@v2 170 | id: plt-cache 171 | with: 172 | path: priv/plts 173 | key: ${{runner.os}}-plts-${{matrix.otp}}-${{matrix.elixir}} 174 | 175 | - name: Create PLTs 176 | if: steps.plt-cache.outputs.cache-hit != 'true' 177 | run: | 178 | mkdir -p priv/plts 179 | mix dialyzer --plt 180 | 181 | - run: mix dialyzer --no-check --format github 182 | 183 | test: 184 | name: Tests 185 | needs: deps 186 | runs-on: ubuntu-latest 187 | 188 | env: 189 | DATABASE_URL: ecto://postgres:newaperio@localhost/phoenix_starter_test 190 | 191 | strategy: 192 | matrix: 193 | elixir: ["1.13.2"] 194 | otp: ["24.2.1"] 195 | 196 | services: 197 | db: 198 | image: postgres:12 199 | env: 200 | POSTGRES_DB: phoenix_starter_test 201 | POSTGRES_PASSWORD: newaperio 202 | POSTGRES_USER: postgres 203 | ports: ["5432:5432"] 204 | options: >- 205 | --health-cmd pg_isready 206 | --health-interval 10s 207 | --health-timeout 5s 208 | --health-retries 5 209 | 210 | steps: 211 | - uses: actions/checkout@v2 212 | 213 | - uses: erlef/setup-beam@v1 214 | with: 215 | otp-version: ${{matrix.otp}} 216 | elixir-version: ${{matrix.elixir}} 217 | 218 | - name: Restore dependencies cache 219 | uses: actions/cache@v2 220 | with: 221 | path: deps 222 | key: ${{runner.os}}-mix-${{matrix.otp}}-${{matrix.elixir}}-${{hashFiles('**/mix.lock')}} 223 | restore-keys: ${{runner.os}}-mix- 224 | 225 | - name: Restore build cache 226 | uses: actions/cache@v2 227 | with: 228 | path: _build 229 | key: ${{runner.os}}-build-${{matrix.otp}}-${{matrix.elixir}}-${{hashFiles('**/mix.lock')}} 230 | 231 | - run: mix test --warnings-as-errors 232 | --------------------------------------------------------------------------------