├── .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 |
2 | <%= if @current_user do %>
3 | <%= @current_user.email %>
4 | <%= link "Settings", to: Routes.user_settings_path(@conn, :profile) %>
5 | <%= link "Log out", to: Routes.user_session_path(@conn, :delete), method: :delete %>
6 |
7 | <%= if permitted?(@current_user, "ops.dashboard") do %>
8 | <%= link "Dashboard", to: Routes.live_dashboard_path(@conn, :home) %>
9 | <% end %>
10 | <% else %>
11 | <%= link "Register", to: Routes.user_registration_path(@conn, :new) %>
12 | <%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
13 | <% end %>
14 |
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 |
4 | <%= live_patch "Update profile", to: Routes.user_settings_path(@socket, :profile) %>
5 | <%= live_patch "Update email", to: Routes.user_settings_path(@socket, :email) %>
6 | <%= live_patch "Update password", to: Routes.user_settings_path(@socket, :password) %>
7 |
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 |
48 | #{safe_msg}
49 |
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(info message
)
20 |
21 | assert "warning"
22 | |> conn_with_flash("warning message")
23 | |> LayoutView.alert(:warning)
24 | |> safe_to_string() ==
25 | ~s(warning message
)
26 |
27 | assert "error"
28 | |> conn_with_flash("error message")
29 | |> LayoutView.alert(:error)
30 | |> safe_to_string() ==
31 | ~s(error message
)
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 |
44 | info message
45 |
46 | """
47 |
48 | assert %{"warning" => "warning message"}
49 | |> LayoutView.alert(:warning)
50 | |> safe_to_string() ==
51 | """
52 |
57 | warning message
58 |
59 | """
60 |
61 | assert %{"error" => "error message"}
62 | |> LayoutView.alert(:error)
63 | |> safe_to_string() ==
64 | """
65 |
70 | error message
71 |
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 | 
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 |
--------------------------------------------------------------------------------