diff --git a/config/config.exs b/config/config.exs
index b0ef676..a27441d 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -8,7 +8,11 @@
import Config
config :kmxcrm,
- ecto_repos: [Kmxcrm.Repo]
+ ecto_repos: [Kmxcrm.Repo],
+ footer: "<a href=\"https://www.kmx.io/\" target=\"_blank\">kmx.io</a>",
+ mail_from: "crm@kmx.io",
+ recaptcha_secret: System.get_env("RECAPTCHA_SECRET"),
+ recaptcha_site_key: System.get_env("RECAPTCHA_SITE_KEY")
# Configures the endpoint
config :kmxcrm, KmxcrmWeb.Endpoint,
diff --git a/config/test.exs b/config/test.exs
index 1aa3cba..97f665a 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -1,5 +1,8 @@
import Config
+# Only in tests, remove the complexity from the password hashing algorithm
+config :bcrypt_elixir, :log_rounds, 1
+
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
diff --git a/lib/kmxcrm/index_params.ex b/lib/kmxcrm/index_params.ex
new file mode 100644
index 0000000..c24fa22
--- /dev/null
+++ b/lib/kmxcrm/index_params.ex
@@ -0,0 +1,5 @@
+defmodule Kmxcrm.IndexParams do
+
+ defstruct column: "id", page: 1, per: 50, reverse: false, search: nil
+
+end
diff --git a/lib/kmxcrm/organisation_manager.ex b/lib/kmxcrm/organisation_manager.ex
new file mode 100644
index 0000000..65dab82
--- /dev/null
+++ b/lib/kmxcrm/organisation_manager.ex
@@ -0,0 +1,95 @@
+defmodule Kmxcrm.OrganisationManager do
+
+ import Ecto.Query, warn: false
+
+ alias Kmxcrm.IndexParams
+ alias Kmxcrm.OrganisationManager.Organisation
+ alias Kmxcrm.Pagination
+ alias Kmxcrm.Repo
+
+ def list_organisations(params \\ %IndexParams{}) do
+ from(org in Organisation)
+ |> search(params)
+ |> index_order_by(params)
+ |> Pagination.page(params, preload: [])
+ end
+
+ def search(query, %IndexParams{search: search}) do
+ expr = "%#{search}%"
+ query
+ |> where([org], ilike(org.name, ^expr) or ilike(org.slug, ^expr))
+ end
+
+ def index_order_by(query, %{column: "id", reverse: true}) do
+ order_by(query, [desc: :id])
+ end
+ def index_order_by(query, %{column: "id"}) do
+ order_by(query, :id)
+ end
+ def index_order_by(query, %{column: "name", reverse: true}) do
+ order_by(query, [org], [desc_nulls_last: fragment("lower(?)", org.name)])
+ end
+ def index_order_by(query, %{column: "name"}) do
+ order_by(query, [org], [asc_nulls_last: fragment("lower(?)", org.name)])
+ end
+ def index_order_by(query, %{column: "slug", reverse: true}) do
+ order_by(query, [org], [desc_nulls_last: fragment("lower(?)", org.slug)])
+ end
+ def index_order_by(query, %{column: "slug"}) do
+ order_by(query, [org], [asc_nulls_last: fragment("lower(?)", org.slug)])
+ end
+
+ def count_organisations do
+ Repo.one from org in Organisation, select: count()
+ end
+
+ def get_organisation(id) do
+ Repo.one from organisation in Organisation,
+ where: [id: ^id],
+ preload: [:slug,
+ owned_repositories: [organisation: :slug,
+ user: :slug],
+ users: :slug],
+ limit: 1
+ end
+
+ def get_organisation!(id) do
+ get_organisation(id) || raise Ecto.NoResultsError
+ end
+
+ def change_organisation(organisation \\ %Organisation{}) do
+ Organisation.changeset(organisation, %{})
+ end
+
+ def create_organisation(user, attrs \\ %{}) do
+ %Organisation{}
+ |> Organisation.changeset(attrs)
+ |> Ecto.Changeset.put_assoc(:users, [user])
+ |> Repo.insert()
+ end
+
+ def update_organisation(organisation, attrs \\ %{}) do
+ organisation
+ |> Organisation.changeset(attrs)
+ |> Repo.update()
+ end
+
+ def get_organisation_by_slug(slug) do
+ Repo.one from o in Organisation,
+ where: fragment("lower(?)", o.slug) == ^String.downcase(slug),
+ preload: :users,
+ limit: 1
+ end
+
+ def delete_organisation(%Organisation{} = organisation) do
+ organisation
+ |> Organisation.changeset(%{})
+ |> Repo.delete()
+ end
+
+ def admin_create_organisation(attrs \\ %{}) do
+ %Organisation{}
+ |> Organisation.changeset(attrs)
+ |> Repo.insert()
+ end
+end
diff --git a/lib/kmxcrm/organisation_manager/organisation.ex b/lib/kmxcrm/organisation_manager/organisation.ex
new file mode 100644
index 0000000..0400d42
--- /dev/null
+++ b/lib/kmxcrm/organisation_manager/organisation.ex
@@ -0,0 +1,32 @@
+defmodule Kmxcrm.OrganisationManager.Organisation do
+
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ alias Kmxcrm.UserManager.User
+
+ schema "organisations" do
+ field :description, :string
+ field :name, :string
+ many_to_many :users, User, join_through: "users_organisations", on_replace: :delete, on_delete: :delete_all
+ field :slug, :string, null: false
+ timestamps()
+ end
+
+ @doc false
+ def changeset(organisation, attrs \\ %{}) do
+ organisation
+ |> cast(attrs, [:description, :name, :slug])
+ |> validate_slug(:slug)
+ |> Markdown.validate_markdown(:description)
+ |> foreign_key_constraint(:owned_repositories, name: :repositories_organisation_id_fkey)
+ end
+
+ def validate_slug(changeset, key) do
+ changeset
+ |> validate_required([key])
+ |> validate_format(key, ~r/^[A-Za-z][-_+.0-9A-Za-z]{1,64}$/)
+ |> unsafe_validate_unique(key, Kmxcrm.Repo)
+ |> unique_constraint(key)
+ end
+end
diff --git a/lib/kmxcrm/pagination.ex b/lib/kmxcrm/pagination.ex
new file mode 100644
index 0000000..e54da20
--- /dev/null
+++ b/lib/kmxcrm/pagination.ex
@@ -0,0 +1,42 @@
+defmodule Kmxcrm.Pagination do
+ import Ecto.Query
+
+ alias Kmxcrm.IndexParams
+ alias Kmxcrm.Repo
+
+ def query(query, %IndexParams{page: page, per: per}) do
+ query
+ |> limit(^per + 1)
+ |> offset(^(per * (page - 1)))
+ end
+
+ def page(query, params = %IndexParams{page: page, per: per}, preload: preload) do
+ result = query
+ |> query(params)
+ |> preload(^preload)
+ |> Repo.all()
+ first_page = if page > 2, do: 1
+ prev_page = if page > 1, do: page - 1
+ {next_page, result} = if length(result) > per do
+ {page + 1, Enum.slice(result, 0..-2)}
+ else
+ {nil, result}
+ end
+ count = Repo.one(from(t in subquery(query), select: count("*")))
+ count_pages = Float.ceil(count / per) |> trunc()
+ last_page = if (page < count_pages - 1), do: count_pages
+ first = if count > 0, do: (page - 1) * per + 1, else: 0
+ last = if count > 0, do: first + length(result) - 1, else: 0
+ %{count: count,
+ count_pages: count_pages,
+ first: first,
+ first_page: first_page,
+ last: last,
+ last_page: last_page,
+ next_page: next_page,
+ page: page,
+ per: per,
+ prev_page: prev_page,
+ result: result}
+ end
+end
diff --git a/lib/kmxcrm/plug/ensure_admin.ex b/lib/kmxcrm/plug/ensure_admin.ex
new file mode 100644
index 0000000..2a406a1
--- /dev/null
+++ b/lib/kmxcrm/plug/ensure_admin.ex
@@ -0,0 +1,23 @@
+defmodule Kmxcrm.Plug.EnsureAdmin do
+ import Plug.Conn
+ alias Kmxcrm.UserManager.User
+
+ def init(default), do: default
+
+ def call(conn, _) do
+ conn
+ |> ensure_admin(conn.assigns.current_user)
+ end
+
+ defp ensure_admin(conn, user = %User{is_admin: true}) do
+ conn
+ |> assign(:current_admin_user, user)
+ end
+
+ defp ensure_admin(conn, _user) do
+ body = "Forbidden"
+ conn
+ |> put_resp_content_type("text/plain")
+ |> send_resp(403, body)
+ end
+end
diff --git a/lib/kmxcrm/user_manager.ex b/lib/kmxcrm/user_manager.ex
new file mode 100644
index 0000000..f563cf6
--- /dev/null
+++ b/lib/kmxcrm/user_manager.ex
@@ -0,0 +1,506 @@
+defmodule Kmxcrm.UserManager do
+ @moduledoc """
+ The UserManager context.
+ """
+
+ import Ecto.Query, warn: false
+
+ alias Kmxcrm.IndexParams
+ alias Kmxcrm.Pagination
+ alias Kmxcrm.Repo
+ alias Kmxcrm.UserManager.{Avatar, User, UserToken, UserNotifier}
+
+ def list_users(params \\ %IndexParams{}) do
+ from(u in User)
+ |> search(params)
+ |> index_order_by(params)
+ |> Pagination.page(params, preload: [])
+ end
+
+ def search(query, %IndexParams{search: search}) do
+ expr = "%#{search}%"
+ query
+ |> where([u], ilike(u.name, ^expr) or ilike(u.login, ^expr) or ilike(u.email, ^expr))
+ end
+
+ def index_order_by(query, %IndexParams{column: "id", reverse: true}) do
+ order_by(query, [desc: :id])
+ end
+ def index_order_by(query, %IndexParams{column: "id"}) do
+ order_by(query, :id)
+ end
+
+ def admin_create_user(attrs \\ %{}) do
+ %User{}
+ |> User.admin_changeset(attrs)
+ |> Repo.insert()
+ end
+
+ def update_user(%User{} = user, attrs) do
+ old_login = user.login
+ case user
+ |> User.changeset(attrs)
+ |> Repo.update() do
+ {:ok, u} ->
+ if u.login != old_login do
+ UserNotifier.deliver_login_changed_email(u, old_login, u.login)
+ end
+ if attrs["avatar"] do
+ %{path: path} = attrs["avatar"]
+ Avatar.set_image(u, path)
+ end
+ {:ok, u}
+ x -> x
+ end
+ end
+
+ def admin_update_user(%User{} = user, attrs) do
+ user
+ |> User.admin_changeset(attrs)
+ |> Repo.update()
+ end
+
+ def admin_update_user_password(%User{} = user, attrs) do
+ user
+ |> User.password_changeset(attrs)
+ |> Repo.update()
+ end
+
+ def delete_user(%User{} = user) do
+ user
+ |> change_user()
+ |> Repo.delete()
+ end
+
+ def change_user(user = %User{} \\ %User{}, params \\ %{}) do
+ User.changeset(user, params)
+ end
+
+ def totp_init do
+ Repo.transaction fn ->
+ Enum.each list_users(), fn u ->
+ {:ok, _} = User.totp_changeset(u) |> Repo.update()
+ end
+ end
+ end
+
+ @doc """
+ Returns a URL that be rendered with a QR code.
+ It meets the Google Authenticator specification
+ at https://github.com/google/google-authenticator/wiki/Key-Uri-Format.
+ ## Examples
+ iex> generate_totp_enrolment_url(user)
+ """
+ def totp_enrolment_url(%User{email: email, totp_secret: secret}) do
+ "otpauth://totp/kmxgit:#{email}?secret=#{secret}&issuer=kmxgit&algorithm=SHA1&digits=6&period=30"
+ end
+
+ def update_user_totp(user = %User{}, params) do
+ case user
+ |> User.totp_changeset(params)
+ |> Repo.update()
+ do
+ {:ok, user1} ->
+ if (user.totp_last == 0 && user1.totp_last != 0), do: UserNotifier.deliver_totp_enabled_email(user)
+ {:ok, user1}
+ x -> x
+ end
+ end
+
+ def verify_user_totp(user = %User{}, token) do
+ User.totp_verify(user, token || 0)
+ end
+
+ def delete_user_totp(user = %User{}) do
+ case user
+ |> User.totp_changeset(:delete)
+ |> Repo.update()
+ do
+ {:ok, user1} ->
+ if (user.totp_last != 0 && user1.totp_last == 0), do: UserNotifier.deliver_totp_disabled_email(user)
+ {:ok, user1}
+ x -> x
+ end
+ end
+
+ def admin_user_present? do
+ if Repo.one(from user in User,
+ where: [is_admin: true],
+ limit: 1) do
+ true
+ else
+ false
+ end
+ end
+
+ ## Database getters
+
+ @doc """
+ Gets a user by email.
+
+ ## Examples
+
+ iex> get_user_by_email("foo@example.com")
+ %User{}
+
+ iex> get_user_by_email("unknown@example.com")
+ nil
+
+ """
+ def get_user_by_email(email) when is_binary(email) do
+ Repo.get_by(User, email: email)
+ end
+
+ @doc """
+ Gets a user by email and password.
+
+ ## Examples
+
+ iex> get_user_by_email_and_password("foo@example.com", "correct_password")
+ %User{}
+
+ iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
+ nil
+
+ """
+ def get_user_by_email_and_password(email, password)
+ when is_binary(email) and is_binary(password) do
+ user = Repo.get_by(User, email: email)
+ if User.valid_password?(user, password), do: user
+ end
+
+ @doc """
+ Gets a single user.
+
+ Returns nil if the User does not exist.
+
+ ## Examples
+
+ iex> get_user(1)
+ %User{}
+
+ iex> get_user(1000000000000000)
+ nil
+ """
+ def get_user(id) do
+ Repo.one from user in User,
+ where: [id: ^id],
+ preload: :organisations
+ end
+
+ @doc """
+ Gets a single user.
+
+ Raises `Ecto.NoResultsError` if the User does not exist.
+
+ ## Examples
+
+ iex> get_user!(123)
+ %User{}
+
+ iex> get_user!(456)
+ ** (Ecto.NoResultsError)
+
+ """
+ def get_user!(id) do
+ get_user(id) || raise Ecto.NoResultsError
+ end
+
+ def get_user_by_login(login) do
+ Repo.one from u in User,
+ where: u.login == ^login,
+ limit: 1,
+ preload: :organisations
+ end
+
+ ## User registration
+
+ @doc """
+ Registers a user.
+
+ ## Examples
+
+ iex> register_user(%{field: value})
+ {:ok, %User{}}
+
+ iex> register_user(%{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def register_user(attrs) do
+ %User{}
+ |> User.registration_changeset(attrs)
+ |> Repo.insert()
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking user changes.
+
+ ## Examples
+
+ iex> change_user_registration(user)
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_registration(%User{} = user, attrs \\ %{}) do
+ User.registration_changeset(user, attrs, hash_password: false)
+ end
+
+ ## Settings
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for changing the user email.
+
+ ## Examples
+
+ iex> change_user_email(user)
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_email(user, attrs \\ %{}) do
+ User.email_changeset(user, attrs)
+ end
+
+ @doc """
+ Emulates that the email will change without actually changing
+ it in the database.
+
+ ## Examples
+
+ iex> apply_user_email(user, "valid password", %{email: ...})
+ {:ok, %User{}}
+
+ iex> apply_user_email(user, "invalid password", %{email: ...})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def apply_user_email(user, password, attrs) do
+ user
+ |> User.email_changeset(attrs)
+ |> User.validate_current_password(password)
+ |> Ecto.Changeset.apply_action(:update)
+ end
+
+ @doc """
+ Updates the user email using the given token.
+
+ If the token matches, the user email is updated and the token is deleted.
+ The confirmed_at date is also updated to the current time.
+ """
+ def update_user_email(user, token) do
+ context = "change:#{user.email}"
+
+ with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
+ %UserToken{sent_to: email} <- Repo.one(query),
+ {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do
+ :ok
+ else
+ _ -> :error
+ end
+ end
+
+ defp user_email_multi(user, email, context) do
+ changeset =
+ user
+ |> User.email_changeset(%{email: email})
+ |> User.confirm_changeset()
+
+ Ecto.Multi.new()
+ |> Ecto.Multi.update(:user, changeset)
+ |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context]))
+ end
+
+ @doc """
+ Delivers the update email instructions to the given user.
+
+ ## Examples
+
+ iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1))
+ {:ok, %{to: ..., body: ...}}
+
+ """
+ def deliver_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
+ when is_function(update_email_url_fun, 1) do
+ {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
+
+ Repo.insert!(user_token)
+ UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for changing the user password.
+
+ ## Examples
+
+ iex> change_user_password(user)
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_password(user, attrs \\ %{}) do
+ User.password_changeset(user, attrs, hash_password: false)
+ end
+
+ @doc """
+ Updates the user password.
+
+ ## Examples
+
+ iex> update_user_password(user, "valid password", %{password: ...})
+ {:ok, %User{}}
+
+ iex> update_user_password(user, "invalid password", %{password: ...})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_user_password(user, password, attrs) do
+ changeset =
+ user
+ |> User.password_changeset(attrs)
+ |> User.validate_current_password(password)
+
+ Ecto.Multi.new()
+ |> Ecto.Multi.update(:user, changeset)
+ |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
+ |> Repo.transaction()
+ |> case do
+ {:ok, %{user: user}} -> {:ok, user}
+ {:error, :user, changeset, _} -> {:error, changeset}
+ end
+ end
+
+ ## Session
+
+ @doc """
+ Generates a session token.
+ """
+ def generate_user_session_token(user) do
+ {token, user_token} = UserToken.build_session_token(user)
+ Repo.insert!(user_token)
+ token
+ end
+
+ @doc """
+ Gets the user with the given signed token.
+ """
+ def get_user_by_session_token(token) do
+ {:ok, query} = UserToken.verify_session_token_query(token)
+ Repo.one(query)
+ end
+
+ @doc """
+ Deletes the signed token with the given context.
+ """
+ def delete_session_token(token) do
+ Repo.delete_all(UserToken.token_and_context_query(token, "session"))
+ :ok
+ end
+
+ ## Confirmation
+
+ @doc """
+ Delivers the confirmation email instructions to the given user.
+
+ ## Examples
+
+ iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :edit, &1))
+ {:ok, %{to: ..., body: ...}}
+
+ iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :edit, &1))
+ {:error, :already_confirmed}
+
+ """
+ def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun)
+ when is_function(confirmation_url_fun, 1) do
+ if user.confirmed_at do
+ {:error, :already_confirmed}
+ else
+ {encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
+ Repo.insert!(user_token)
+ UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
+ end
+ end
+
+ @doc """
+ Confirms a user by the given token.
+
+ If the token matches, the user account is marked as confirmed
+ and the token is deleted.
+ """
+ def confirm_user(token) do
+ with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
+ %User{} = user <- Repo.one(query),
+ {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do
+ {:ok, user}
+ else
+ _ -> :error
+ end
+ end
+
+ defp confirm_user_multi(user) do
+ Ecto.Multi.new()
+ |> Ecto.Multi.update(:user, User.confirm_changeset(user))
+ |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"]))
+ end
+
+ ## Reset password
+
+ @doc """
+ Delivers the reset password email to the given user.
+
+ ## Examples
+
+ iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1))
+ {:ok, %{to: ..., body: ...}}
+
+ """
+ def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
+ when is_function(reset_password_url_fun, 1) do
+ {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
+ Repo.insert!(user_token)
+ UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
+ end
+
+ @doc """
+ Gets the user by reset password token.
+
+ ## Examples
+
+ iex> get_user_by_reset_password_token("validtoken")
+ %User{}
+
+ iex> get_user_by_reset_password_token("invalidtoken")
+ nil
+
+ """
+ def get_user_by_reset_password_token(token) do
+ with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"),
+ %User{} = user <- Repo.one(query) do
+ user
+ else
+ _ -> nil
+ end
+ end
+
+ @doc """
+ Resets the user password.
+
+ ## Examples
+
+ iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
+ {:ok, %User{}}
+
+ iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def reset_user_password(user, attrs) do
+ Ecto.Multi.new()
+ |> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
+ |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
+ |> Repo.transaction()
+ |> case do
+ {:ok, %{user: user}} -> {:ok, user}
+ {:error, :user, changeset, _} -> {:error, changeset}
+ end
+ end
+end
diff --git a/lib/kmxcrm/user_manager/avatar.ex b/lib/kmxcrm/user_manager/avatar.ex
new file mode 100644
index 0000000..a312bf6
--- /dev/null
+++ b/lib/kmxcrm/user_manager/avatar.ex
@@ -0,0 +1,20 @@
+defmodule Kmxcrm.UserManager.Avatar do
+ import Mogrify
+
+ @sizes [256, 48]
+
+ def path(user, size) do
+ dir = "priv/avatar/#{size}"
+ File.mkdir_p(dir)
+ "#{dir}/#{user.id}.png"
+ end
+
+ def set_image(user, path) do
+ original = open(path)
+ for size <- @sizes do
+ original
+ |> resize_to_fill("#{size}x#{size}")
+ |> save(path: path(user, size))
+ end
+ end
+end
diff --git a/lib/kmxcrm/user_manager/user.ex b/lib/kmxcrm/user_manager/user.ex
new file mode 100644
index 0000000..605f9f2
--- /dev/null
+++ b/lib/kmxcrm/user_manager/user.ex
@@ -0,0 +1,237 @@
+defmodule Kmxcrm.UserManager.User do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "users" do
+ field :confirmed_at, :naive_datetime
+ field :description, :string
+ field :email, :string
+ field :hashed_password, :string, redact: true
+ field :is_admin, :boolean, default: false, null: false
+ field :login, :string, null: false, unique: true
+ field :name, :string
+ many_to_many :organisations, Organisation, join_through: "users_organisations", on_delete: :delete_all
+ field :password, :string, virtual: true, redact: true
+ field :totp_last, :integer, default: 0, redact: true
+ field :totp_secret, :string, redact: true
+ timestamps()
+ end
+
+ def display_name(user) do
+ user.name || user.login
+ end
+
+ defp common_changeset(changeset) do
+ changeset
+ |> cast_assoc(:login)
+ |> validate_required([:email, :hashed_password, :is_admin, :login, :totp_secret])
+ |> validate_login()
+ |> validate_email()
+ |> Markdown.validate_markdown(:description)
+ end
+
+ defp generate_totp_secret(changeset) do
+ secret = :crypto.strong_rand_bytes(10) |> Base.encode32()
+ put_change(changeset, :totp_secret, secret)
+ end
+
+ def changeset(user, attrs \\ %{}) do
+ user
+ |> cast(attrs, [:deploy_only, :description, :name, :ssh_keys])
+ |> common_changeset()
+ end
+
+ def admin_changeset(user, attrs \\ %{}, opts \\ []) do
+ user
+ |> cast(attrs, [:deploy_only, :description, :email, :is_admin, :name, :password, :ssh_keys])
+ |> validate_email()
+ |> maybe_validate_password(opts)
+ |> common_changeset()
+ end
+
+ defp maybe_validate_password(changeset, opts) do
+ p = changeset.changes[:password]
+ if p && p != "" do
+ changeset
+ |> validate_password(opts)
+ else
+ changeset
+ end
+ end
+
+ def admin_create_user_changeset(user, attrs \\ %{}, opts \\ []) do
+ user
+ |> cast(attrs, [:deploy_only, :description, :email, :is_admin, :name, :password, :ssh_keys])
+ |> generate_totp_secret()
+ |> validate_email()
+ |> maybe_validate_password(opts)
+ |> common_changeset()
+ end
+
+ def totp_verify(%__MODULE__{totp_secret: secret}, token) do
+ :pot.valid_totp(token, secret, [window: 1, addwindow: 1])
+ end
+
+ def totp_changeset(user) do
+ user
+ |> cast(%{}, [])
+ |> generate_totp_secret()
+ |> common_changeset()
+ end
+
+ def totp_changeset(user, :delete) do
+ user
+ |> cast(%{totp_last: 0}, [:totp_last])
+ end
+ def totp_changeset(user, params) do
+ user
+ |> cast(params, [:totp_last])
+ |> verify_totp_last()
+ end
+
+ defp verify_totp_last(changeset) do
+ otp_last = changeset |> get_field(:otp_last) |> Integer.to_string()
+ if totp_verify(changeset.data, otp_last) do
+ changeset
+ else
+ changeset
+ |> add_error(:totp_last, "invalid token")
+ end
+ end
+
+ @doc """
+ A user changeset for registration.
+
+ It is important to validate the length of both email and password.
+ Otherwise databases may truncate the email without warnings, which
+ could lead to unpredictable or insecure behaviour. Long passwords may
+ also be very expensive to hash for certain algorithms.
+
+ ## Options
+
+ * `:hash_password` - Hashes the password so it can be stored securely
+ in the database and ensures the password field is cleared to prevent
+ leaks in the logs. If password hashing is not needed and clearing the
+ password field is not desired (like when using this changeset for
+ validations on a LiveView form), this option can be set to `false`.
+ Defaults to `true`.
+ """
+ def registration_changeset(user, attrs, opts \\ []) do
+ user
+ |> cast(attrs, [:email, :login, :password])
+ |> validate_email()
+ |> validate_login()
+ |> validate_password(opts)
+ end
+
+ defp validate_email(changeset) do
+ changeset
+ |> validate_required([:email])
+ |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
+ |> validate_length(:email, max: 160)
+ |> unsafe_validate_unique(:email, Kmxcrm.Repo)
+ |> unique_constraint(:email)
+ end
+
+ defp validate_login(changeset) do
+ changeset
+ |> validate_required([:login])
+ |> validate_format(:login, ~r/^[A-Za-z][-_+.0-9A-Za-z]{1,64}$/)
+ |> unsafe_validate_unique(:login, Kmxcrm.Repo)
+ |> unique_constraint(:login)
+ end
+
+ defp validate_password(changeset, opts) do
+ changeset
+ |> validate_required([:password])
+ |> validate_length(:password, min: 12, max: 72)
+ # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
+ # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
+ # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
+ |> maybe_hash_password(opts)
+ end
+
+ defp maybe_hash_password(changeset, opts) do
+ hash_password? = Keyword.get(opts, :hash_password, true)
+ password = get_change(changeset, :password)
+
+ if hash_password? && password && changeset.valid? do
+ changeset
+ # If using Bcrypt, then further validate it is at most 72 bytes long
+ |> validate_length(:password, max: 72, count: :bytes)
+ |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
+ |> delete_change(:password)
+ else
+ changeset
+ end
+ end
+
+ @doc """
+ A user changeset for changing the email.
+
+ It requires the email to change otherwise an error is added.
+ """
+ def email_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:email])
+ |> validate_email()
+ |> case do
+ %{changes: %{email: _}} = changeset -> changeset
+ %{} = changeset -> add_error(changeset, :email, "did not change")
+ end
+ end
+
+ @doc """
+ A user changeset for changing the password.
+
+ ## Options
+
+ * `:hash_password` - Hashes the password so it can be stored securely
+ in the database and ensures the password field is cleared to prevent
+ leaks in the logs. If password hashing is not needed and clearing the
+ password field is not desired (like when using this changeset for
+ validations on a LiveView form), this option can be set to `false`.
+ Defaults to `true`.
+ """
+ def password_changeset(user, attrs, opts \\ []) do
+ user
+ |> cast(attrs, [:password])
+ |> validate_confirmation(:password, message: "does not match password")
+ |> validate_password(opts)
+ end
+
+ @doc """
+ Confirms the account by setting `confirmed_at`.
+ """
+ def confirm_changeset(user) do
+ now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
+ change(user, confirmed_at: now)
+ end
+
+ @doc """
+ Verifies the password.
+
+ If there is no user or the user doesn't have a password, we call
+ `Bcrypt.no_user_verify/0` to avoid timing attacks.
+ """
+ def valid_password?(%Kmxcrm.UserManager.User{hashed_password: hashed_password}, password)
+ when is_binary(hashed_password) and byte_size(password) > 0 do
+ Bcrypt.verify_pass(password, hashed_password)
+ end
+
+ def valid_password?(_, _) do
+ Bcrypt.no_user_verify()
+ false
+ end
+
+ @doc """
+ Validates the current password otherwise adds an error to the changeset.
+ """
+ def validate_current_password(changeset, password) do
+ if valid_password?(changeset.data, password) do
+ changeset
+ else
+ add_error(changeset, :current_password, "is not valid")
+ end
+ end
+end
diff --git a/lib/kmxcrm/user_manager/user_notifier.ex b/lib/kmxcrm/user_manager/user_notifier.ex
new file mode 100644
index 0000000..c9bcd63
--- /dev/null
+++ b/lib/kmxcrm/user_manager/user_notifier.ex
@@ -0,0 +1,124 @@
+defmodule Kmxcrm.UserManager.UserNotifier do
+ import Swoosh.Email
+
+ alias Kmxcrm.Mailer
+
+ # Delivers the email using the application mailer.
+ defp deliver(recipient, subject, body) do
+ email =
+ new()
+ |> to(recipient)
+ |> from({"kmxcrm", Application.fetch_env!(:kmxcrm, :mail_from)})
+ |> subject(subject)
+ |> text_body(body)
+
+ with {:ok, _metadata} <- Mailer.deliver(email) do
+ {:ok, email}
+ end
+ end
+
+ @doc """
+ Deliver instructions to confirm account.
+ """
+ def deliver_confirmation_instructions(user, url) do
+ deliver(user.email, "Confirmation instructions", """
+ Hi #{user.email},
+
+ You can confirm your account by visiting the URL below:
+
+ #{url}
+
+ If you didn't create an account with us, please ignore this.
+ """)
+ end
+
+ @doc """
+ Deliver instructions to reset a user password.
+ """
+ def deliver_reset_password_instructions(user, url) do
+ deliver(user.email, "Reset password instructions", """
+ Hi #{user.email},
+
+ You can reset your password by visiting the URL below:
+
+ #{url}
+
+ If you didn't request this change, please ignore this.
+ """)
+ end
+
+ @doc """
+ Deliver instructions to update a user email.
+ """
+ def deliver_update_email_instructions(user, url) do
+ deliver(user.email, "Update email instructions", """
+ Hi #{user.email},
+
+ You can change your email by visiting the URL below:
+
+ #{url}
+
+ If you didn't request this change, please ignore this.
+ """)
+ end
+
+ def deliver_login_changed_email(user, old_login, new_login) do
+ deliver(user.email, "Your login was changed", """
+ Hi #{user.email},
+
+ Your login was changed from #{old_login} to #{new_login}.
+
+ If you didn't request this change, please reply to this e-mail.
+ """)
+ end
+
+ defp email_changed_email_body(email, old, new) do
+ """
+ Hi #{email},
+
+ Your e-mail address was changed from #{old} to #{new}.
+
+ If you didn't request this change, please reply to this e-mail.
+ """
+ end
+
+ def deliver_email_changed_email(old, new) do
+ subject = "Your email address was changed"
+ deliver(old, subject, email_changed_email_body(old, old, new))
+ deliver(new, subject, email_changed_email_body(new, old, new))
+ end
+
+ def deliver_password_changed_email(user) do
+ deliver(user.email, "Your password was changed", """
+ Hi #{user.email},
+
+ Your password was changed.
+
+ If you didn't request this change, please reply to this e-mail.
+ """)
+ end
+
+ def deliver_totp_enabled_email(user) do
+ deliver(user.email, "TOTP was enabled", """
+ Hi #{user.email},
+
+ TOTP (Google Authenticator) has been activated on your account.
+ You will need to enter a new TOTP each time you login in addition
+ to login / password.
+
+ If you didn't request this change, please reply to this e-mail.
+ """)
+ end
+
+
+ def deliver_totp_disabled_email(user) do
+ deliver(user.email, "TOTP was disabled !", """
+ Hi #{user.email},
+
+ TOTP (Google Authenticator) has been disabled on your account.
+ You will no longer need to enter a new TOTP each time you login.
+
+ If you didn't request this change, please reply to this e-mail.
+ """)
+ end
+end
diff --git a/lib/kmxcrm/user_manager/user_token.ex b/lib/kmxcrm/user_manager/user_token.ex
new file mode 100644
index 0000000..e0f0e7c
--- /dev/null
+++ b/lib/kmxcrm/user_manager/user_token.ex
@@ -0,0 +1,178 @@
+defmodule Kmxcrm.UserManager.UserToken do
+ use Ecto.Schema
+ import Ecto.Query
+
+ @hash_algorithm :sha256
+ @rand_size 32
+
+ # It is very important to keep the reset password token expiry short,
+ # since someone with access to the email may take over the account.
+ @reset_password_validity_in_days 1
+ @confirm_validity_in_days 7
+ @change_email_validity_in_days 7
+ @session_validity_in_days 60
+
+ schema "users_tokens" do
+ field :token, :binary
+ field :context, :string
+ field :sent_to, :string
+ belongs_to :user, Kmxcrm.UserManager.User
+
+ timestamps(updated_at: false)
+ end
+
+ @doc """
+ Generates a token that will be stored in a signed place,
+ such as session or cookie. As they are signed, those
+ tokens do not need to be hashed.
+
+ The reason why we store session tokens in the database, even
+ though Phoenix already provides a session cookie, is because
+ Phoenix' default session cookies are not persisted, they are
+ simply signed and potentially encrypted. This means they are
+ valid indefinitely, unless you change the signing/encryption
+ salt.
+
+ Therefore, storing them allows individual user
+ sessions to be expired. The token system can also be extended
+ to store additional data, such as the device used for logging in.
+ You could then use this information to display all valid sessions
+ and devices in the UI and allow users to explicitly expire any
+ session they deem invalid.
+ """
+ def build_session_token(user) do
+ token = :crypto.strong_rand_bytes(@rand_size)
+ {token, %Kmxcrm.UserManager.UserToken{token: token, context: "session", user_id: user.id}}
+ end
+
+ @doc """
+ Checks if the token is valid and returns its underlying lookup query.
+
+ The query returns the user found by the token, if any.
+
+ The token is valid if it matches the value in the database and it has
+ not expired (after @session_validity_in_days).
+ """
+ def verify_session_token_query(token) do
+ query =
+ from token in token_and_context_query(token, "session"),
+ join: user in assoc(token, :user),
+ where: token.inserted_at > ago(@session_validity_in_days, "day"),
+ select: user
+
+ {:ok, query}
+ end
+
+ @doc """
+ Builds a token and its hash to be delivered to the user's email.
+
+ The non-hashed token is sent to the user email while the
+ hashed part is stored in the database. The original token cannot be reconstructed,
+ which means anyone with read-only access to the database cannot directly use
+ the token in the application to gain access. Furthermore, if the user changes
+ their email in the system, the tokens sent to the previous email are no longer
+ valid.
+
+ Users can easily adapt the existing code to provide other types of delivery methods,
+ for example, by phone numbers.
+ """
+ def build_email_token(user, context) do
+ build_hashed_token(user, context, user.email)
+ end
+
+ defp build_hashed_token(user, context, sent_to) do
+ token = :crypto.strong_rand_bytes(@rand_size)
+ hashed_token = :crypto.hash(@hash_algorithm, token)
+
+ {Base.url_encode64(token, padding: false),
+ %Kmxcrm.UserManager.UserToken{
+ token: hashed_token,
+ context: context,
+ sent_to: sent_to,
+ user_id: user.id
+ }}
+ end
+
+ @doc """
+ Checks if the token is valid and returns its underlying lookup query.
+
+ The query returns the user found by the token, if any.
+
+ The given token is valid if it matches its hashed counterpart in the
+ database and the user email has not changed. This function also checks
+ if the token is being used within a certain period, depending on the
+ context. The default contexts supported by this function are either
+ "confirm", for account confirmation emails, and "reset_password",
+ for resetting the password. For verifying requests to change the email,
+ see `verify_change_email_token_query/2`.
+ """
+ def verify_email_token_query(token, context) do
+ case Base.url_decode64(token, padding: false) do
+ {:ok, decoded_token} ->
+ hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
+ days = days_for_context(context)
+
+ query =
+ from token in token_and_context_query(hashed_token, context),
+ join: user in assoc(token, :user),
+ where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email,
+ select: user
+
+ {:ok, query}
+
+ :error ->
+ :error
+ end
+ end
+
+ defp days_for_context("confirm"), do: @confirm_validity_in_days
+ defp days_for_context("reset_password"), do: @reset_password_validity_in_days
+
+ @doc """
+ Checks if the token is valid and returns its underlying lookup query.
+
+ The query returns the user found by the token, if any.
+
+ This is used to validate requests to change the user
+ email. It is different from `verify_email_token_query/2` precisely because
+ `verify_email_token_query/2` validates the email has not changed, which is
+ the starting point by this function.
+
+ The given token is valid if it matches its hashed counterpart in the
+ database and if it has not expired (after @change_email_validity_in_days).
+ The context must always start with "change:".
+ """
+ def verify_change_email_token_query(token, "change:" <> _ = context) do
+ case Base.url_decode64(token, padding: false) do
+ {:ok, decoded_token} ->
+ hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
+
+ query =
+ from token in token_and_context_query(hashed_token, context),
+ where: token.inserted_at > ago(@change_email_validity_in_days, "day")
+
+ {:ok, query}
+
+ :error ->
+ :error
+ end
+ end
+
+ @doc """
+ Returns the token struct for the given token value and context.
+ """
+ def token_and_context_query(token, context) do
+ from Kmxcrm.UserManager.UserToken, where: [token: ^token, context: ^context]
+ end
+
+ @doc """
+ Gets all tokens for the given user for the given contexts.
+ """
+ def user_and_contexts_query(user, :all) do
+ from t in Kmxcrm.UserManager.UserToken, where: t.user_id == ^user.id
+ end
+
+ def user_and_contexts_query(user, [_ | _] = contexts) do
+ from t in Kmxcrm.UserManager.UserToken, where: t.user_id == ^user.id and t.context in ^contexts
+ end
+end
diff --git a/lib/kmxcrm_web.ex b/lib/kmxcrm_web.ex
index a255032..37cfeab 100644
--- a/lib/kmxcrm_web.ex
+++ b/lib/kmxcrm_web.ex
@@ -23,7 +23,15 @@ defmodule KmxcrmWeb do
import Plug.Conn
import KmxcrmWeb.Gettext
+ alias KmxcrmWeb.ErrorView
alias KmxcrmWeb.Router.Helpers, as: Routes
+
+ def not_found(conn, _params \\ %{}) do
+ conn
+ |> put_status(:not_found)
+ |> put_view(ErrorView)
+ |> render(:"404")
+ end
end
end
diff --git a/lib/kmxcrm_web/controllers/admin.ex b/lib/kmxcrm_web/controllers/admin.ex
new file mode 100644
index 0000000..17fb77d
--- /dev/null
+++ b/lib/kmxcrm_web/controllers/admin.ex
@@ -0,0 +1,34 @@
+defmodule KmxcrmWeb.Admin do
+
+ alias Kmxcrm.IndexParams
+
+ def page_params(index_params = %IndexParams{}, nil, nil) do
+ index_params
+ end
+ def page_params(index_params = %IndexParams{}, page, nil) do
+ %IndexParams{index_params | page: String.to_integer(page)}
+ end
+ def page_params(index_params = %IndexParams{}, nil, per) do
+ %IndexParams{index_params | per: String.to_integer(per)}
+ end
+ def page_params(index_params = %IndexParams{}, page, per) do
+ %IndexParams{index_params | page: String.to_integer(page),
+ per: String.to_integer(per)}
+ end
+
+ def search_param(index_params = %IndexParams{}, param) do
+ %IndexParams{index_params | search: param}
+ end
+
+ def sort_param(index_params = %IndexParams{}, param) do
+ if param && param != "" do
+ case String.split(param, "-") do
+ [col, _] -> %IndexParams{index_params | column: col, reverse: true}
+ [col] -> %IndexParams{index_params | column: col}
+ _ -> index_params
+ end
+ else
+ index_params
+ end
+ end
+end
diff --git a/lib/kmxcrm_web/controllers/admin/dashboard_controller.ex b/lib/kmxcrm_web/controllers/admin/dashboard_controller.ex
new file mode 100644
index 0000000..179d668
--- /dev/null
+++ b/lib/kmxcrm_web/controllers/admin/dashboard_controller.ex
@@ -0,0 +1,9 @@
+defmodule KmxcrmWeb.Admin.DashboardController do
+ use KmxcrmWeb, :controller
+
+ def index(conn, _params) do
+ conn
+ |> assign(:page_title, gettext "Dashboard")
+ |> render("index.html")
+ end
+end
diff --git a/lib/kmxcrm_web/controllers/admin/organisation_controller.ex b/lib/kmxcrm_web/controllers/admin/organisation_controller.ex
new file mode 100644
index 0000000..d85b0c2
--- /dev/null
+++ b/lib/kmxcrm_web/controllers/admin/organisation_controller.ex
@@ -0,0 +1,104 @@
+defmodule KmxcrmWeb.Admin.OrganisationController do
+ use KmxcrmWeb, :controller
+
+ alias Kmxcrm.IndexParams
+ alias Kmxcrm.OrganisationManager
+
+ def index(conn, params) do
+ index_params = %IndexParams{}
+ |> KmxcrmWeb.Admin.page_params(params["page"], params["per"])
+ |> KmxcrmWeb.Admin.search_param(params["search"])
+ |> KmxcrmWeb.Admin.sort_param(params["sort"])
+ pagination = OrganisationManager.list_organisations(index_params)
+ conn
+ |> assign(:index, index_params)
+ |> assign(:pagination, pagination)
+ |> assign(:search, params["search"])
+ |> assign(:search_action, Routes.admin_organisation_path(conn, :index, sort: params["sort"], search: params["search"]))
+ |> assign(:sort, params["sort"])
+ |> render("index.html")
+ end
+
+ def new(conn, _params) do
+ changeset = OrganisationManager.change_organisation
+ conn
+ |> assign(:action, Routes.admin_organisation_path(conn, :create))
+ |> assign(:changeset, changeset)
+ |> assign(:org, nil)
+ |> render("new.html")
+ end
+
+ def create(conn, params) do
+ org_params = params["organisation"]
+ case OrganisationManager.admin_create_organisation(org_params) do
+ {:ok, org} ->
+ conn
+ |> redirect(to: Routes.admin_organisation_path(conn, :show, org))
+ {:error, changeset} ->
+ conn
+ |> assign(:action, Routes.admin_organisation_path(conn, :create))
+ |> assign(:changeset, changeset)
+ |> assign(:org, nil)
+ |> render("new.html")
+ end
+ end
+
+ def show(conn, params) do
+ org = OrganisationManager.get_organisation(params["id"])
+ if org do
+ conn
+ |> assign(:org, org)
+ |> render("show.html")
+ else
+ not_found(conn)
+ end
+ end
+
+ def edit(conn, params) do
+ org = OrganisationManager.get_organisation(params["id"])
+ if org do
+ changeset = OrganisationManager.change_organisation(org)
+ conn
+ |> assign(:action, Routes.admin_organisation_path(conn, :update, org))
+ |> assign(:changeset, changeset)
+ |> assign(:org, org)
+ |> render("edit.html")
+ else
+ not_found(conn)
+ end
+ end
+
+ def update(conn, params) do
+ org = OrganisationManager.get_organisation(params["id"])
+ if org do
+ case OrganisationManager.update_organisation(org, params["organisation"]) do
+ {:ok, org1} ->
+ conn
+ |> redirect(to: Routes.admin_organisation_path(conn, :show, org1))
+ {:error, changeset} ->
+ conn
+ |> assign(:action, Routes.admin_organisation_path(conn, :update, org))
+ |> assign(:changeset, changeset)
+ |> render("edit.html")
+ end
+ else
+ not_found(conn)
+ end
+ end
+
+ def delete(conn, params) do
+ org = OrganisationManager.get_organisation(params["id"])
+ if org do
+ case OrganisationManager.delete_organisation(org) do
+ {:ok, _} ->
+ conn
+ |> redirect(to: Routes.admin_organisation_path(conn, :index))
+ {:error, _} ->
+ conn
+ |> redirect(to: Routes.admin_organisation_path(conn, :show, org))
+ end
+ else
+ not_found(conn)
+ end
+ end
+end
diff --git a/lib/kmxcrm_web/controllers/admin/user_controller.ex b/lib/kmxcrm_web/controllers/admin/user_controller.ex
new file mode 100644
index 0000000..46c791c
--- /dev/null
+++ b/lib/kmxcrm_web/controllers/admin/user_controller.ex
@@ -0,0 +1,132 @@
+defmodule KmxcrmWeb.Admin.UserController do
+ use KmxcrmWeb, :controller
+
+ alias Kmxcrm.IndexParams
+ alias Kmxcrm.UserManager
+
+ def index(conn, params) do
+ index_params = %IndexParams{}
+ |> KmxcrmWeb.Admin.page_params(params["page"], params["per"])
+ |> KmxcrmWeb.Admin.search_param(params["search"])
+ |> KmxcrmWeb.Admin.sort_param(params["sort"])
+ pagination = UserManager.list_users(index_params)
+ conn
+ |> assign(:index, index_params)
+ |> assign(:page_title, gettext("Users"))
+ |> assign(:pagination, pagination)
+ |> assign(:search, params["search"])
+ |> assign(:search_action, Routes.admin_user_path(conn, :index, sort: params["sort"], search: params["search"]))
+ |> assign(:sort, params["sort"])
+ |> render("index.html")
+ end
+
+ def new(conn, _params) do
+ changeset = UserManager.change_user()
+ conn
+ |> assign(:action, Routes.admin_user_path(conn, :create))
+ |> assign(:changeset, changeset)
+ |> render("new.html")
+ end
+
+ def create(conn, params) do
+ pw = :crypto.strong_rand_bytes(16) |> Base.url_encode64()
+ pw = "Az0!#{pw}"
+ user_params = Map.merge(params["user"], %{"password" => pw})
+ case UserManager.admin_create_user(user_params) do
+ {:ok, user} ->
+ conn
+ |> redirect(to: Routes.admin_user_path(conn, :show, user))
+ {:error, changeset} ->
+ IO.inspect changeset
+ conn
+ |> assign(:action, Routes.admin_user_path(conn, :create))
+ |> assign(:changeset, changeset)
+ |> render("new.html")
+ end
+ end
+
+ def show(conn, params) do
+ user = UserManager.get_user(params["id"])
+ if user do
+ conn
+ |> assign(:page_title, gettext("User %{login}", login: user.login))
+ |> assign(:user, user)
+ |> render("show.html")
+ else
+ not_found(conn)
+ end
+ end
+
+ def edit(conn, params) do
+ user = UserManager.get_user(params["id"])
+ if user do
+ changeset = UserManager.change_user(user)
+ conn
+ |> assign(:changeset, changeset)
+ |> assign(:user, user)
+ |> render("edit.html")
+ else
+ not_found(conn)
+ end
+ end
+
+ def update(conn, params) do
+ user = UserManager.get_user(params["id"])
+ if user do
+ case UserManager.admin_update_user(user, params["user"]) do
+ {:ok, user1} ->
+ conn
+ |> redirect(to: Routes.admin_user_path(conn, :show, user1))
+ {:error, changeset} ->
+ IO.inspect(changeset)
+ conn
+ |> assign(:changeset, changeset)
+ |> assign(:user, user)
+ |> render("edit.html")
+ end
+ else
+ not_found(conn)
+ end
+ end
+
+ def edit_password(conn, params) do
+ user = UserManager.get_user(params["user_id"])
+ if user do
+ changeset = UserManager.change_user(user)
+ conn
+ |> assign(:changeset, changeset)
+ |> assign(:user, user)
+ |> render("edit_password.html")
+ else
+ not_found(conn)
+ end
+ end
+
+ def update_password(conn, params) do
+ user = UserManager.get_user(params["user_id"])
+ if user do
+ case UserManager.admin_update_user_password(user, params["user"]) do
+ {:ok, user1} ->
+ conn
+ |> redirect(to: Routes.admin_user_path(conn, :show, user1))
+ {:error, changeset} ->
+ conn
+ |> assign(:changeset, changeset)
+ |> assign(:user, user)
+ |> render("edit_password.html")
+ end
+ else
+ not_found(conn)
+ end
+ end
+
+ def delete(conn, params) do
+ if user = UserManager.get_user(params["id"]) do
+ {:ok, _} = UserManager.delete_user(user)
+ conn
+ |> redirect(to: Routes.admin_user_path(conn, :index))
+ else
+ not_found(conn)
+ end
+ end
+end
diff --git a/lib/kmxcrm_web/controllers/organisation_controller.ex b/lib/kmxcrm_web/controllers/organisation_controller.ex
new file mode 100644
index 0000000..1607d0d
--- /dev/null
+++ b/lib/kmxcrm_web/controllers/organisation_controller.ex
@@ -0,0 +1,28 @@
+defmodule KmxcrmWeb.OrganisationController do
+ use KmxcrmWeb, :controller
+
+ alias Kmxcrm.OrganisationManager
+
+ def new(conn, _params) do
+ _ = conn.assigns.current_user
+ changeset = OrganisationManager.change_organisation
+ conn
+ |> assign(:action, Routes.organisation_path(conn, :create))
+ |> assign(:changeset, changeset)
+ |> render("new.html")
+ end
+
+ def create(conn, params) do
+ current_user = conn.assigns.current_user
+ case OrganisationManager.create_organisation(current_user, params["organisation"]) do
+ {:ok, organisation} ->
+ conn
+ |> redirect(to: Routes.organisation_path(conn, :show, organisation))
+ {:error, changeset} ->
+ conn
+ |> assign(:action, Routes.organisation_path(conn, :create))
+ |> assign(:changeset, changeset)
+ |> render("new.html")
+ end
+ end
+end
diff --git a/lib/kmxcrm_web/controllers/page_controller.ex b/lib/kmxcrm_web/controllers/page_controller.ex
index 7750e9f..8f6e969 100644
--- a/lib/kmxcrm_web/controllers/page_controller.ex
+++ b/lib/kmxcrm_web/controllers/page_controller.ex
@@ -1,7 +1,53 @@
defmodule KmxcrmWeb.PageController do
use KmxcrmWeb, :controller
+ alias Kmxcrm.Repo
+ alias Kmxcrm.UserManager
+ alias Kmxcrm.UserManager.User
+ alias KmxcrmWeb.UserAuth
+
def index(conn, _params) do
- render(conn, "index.html")
+ if ! UserManager.admin_user_present? do
+ redirect(conn, to: Routes.page_path(conn, :new_admin))
+ else
+ conn
+ |> render("index.html")
+ end
+ end
+
+ def new_admin(conn, _params) do
+ if ! UserManager.admin_user_present? do
+ changeset = UserManager.change_user(%User{})
+ conn
+ |> assign(:action, Routes.page_path(conn, :new_admin))
+ |> assign(:changeset, changeset)
+ |> assign(:no_navbar_links, true)
+ |> render("new_admin.html")
+ else
+ redirect(conn, to: "/")
+ end
end
+
+ def new_admin_post(conn, params) do
+ if ! UserManager.admin_user_present? do
+ user_params = Map.merge(params["user"], %{"is_admin" => true})
+ Repo.transaction fn ->
+ case UserManager.admin_create_user(user_params) do
+ {:ok, user} ->
+ conn
+ |> UserAuth.log_in_user(user, user_params)
+ |> redirect(to: "/")
+ {:error, changeset} ->
+ conn
+ |> assign(:no_navbar_links, true)
+ |> assign(:changeset, changeset)
+ |> assign(:action, Routes.page_path(conn, :new_admin))
+ |> render("new_admin.html")
+ end
+ end
+ else
+ redirect(conn, to: "/")
+ end
+ end
+
end
diff --git a/lib/kmxcrm_web/controllers/user_auth.ex b/lib/kmxcrm_web/controllers/user_auth.ex
new file mode 100644
index 0000000..c840d10
--- /dev/null
+++ b/lib/kmxcrm_web/controllers/user_auth.ex
@@ -0,0 +1,149 @@
+defmodule KmxcrmWeb.UserAuth do
+ import Plug.Conn
+ import Phoenix.Controller
+
+ alias Kmxcrm.UserManager
+ alias KmxcrmWeb.Router.Helpers, as: Routes
+
+ # Make the remember me cookie valid for 60 days.
+ # If you want bump or reduce this value, also change
+ # the token expiry itself in UserToken.
+ @max_age 60 * 60 * 24 * 60
+ @remember_me_cookie "_kmxcrm_web_user_remember_me"
+ @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
+
+ @doc """
+ Logs the user in.
+
+ It renews the session ID and clears the whole session
+ to avoid fixation attacks. See the renew_session
+ function to customize this behaviour.
+
+ It also sets a `:live_socket_id` key in the session,
+ so LiveView sessions are identified and automatically
+ disconnected on log out. The line can be safely removed
+ if you are not using LiveView.
+ """
+ def log_in_user(conn, user, params \\ %{}) do
+ token = UserManager.generate_user_session_token(user)
+ user_return_to = get_session(conn, :user_return_to)
+
+ conn
+ |> renew_session()
+ |> put_session(:user_token, token)
+ |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
+ |> maybe_write_remember_me_cookie(token, params)
+ |> redirect(to: user_return_to || signed_in_path(conn))
+ end
+
+ defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
+ put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
+ end
+
+ defp maybe_write_remember_me_cookie(conn, _token, _params) do
+ conn
+ end
+
+ # This function renews the session ID and erases the whole
+ # session to avoid fixation attacks. If there is any data
+ # in the session you may want to preserve after log in/log out,
+ # you must explicitly fetch the session data before clearing
+ # and then immediately set it after clearing, for example:
+ #
+ # defp renew_session(conn) do
+ # preferred_locale = get_session(conn, :preferred_locale)
+ #
+ # conn
+ # |> configure_session(renew: true)
+ # |> clear_session()
+ # |> put_session(:preferred_locale, preferred_locale)
+ # end
+ #
+ defp renew_session(conn) do
+ conn
+ |> configure_session(renew: true)
+ |> clear_session()
+ end
+
+ @doc """
+ Logs the user out.
+
+ It clears all session data for safety. See renew_session.
+ """
+ def log_out_user(conn) do
+ user_token = get_session(conn, :user_token)
+ user_token && UserManager.delete_session_token(user_token)
+
+ if live_socket_id = get_session(conn, :live_socket_id) do
+ KmxcrmWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
+ end
+
+ conn
+ |> renew_session()
+ |> delete_resp_cookie(@remember_me_cookie)
+ |> redirect(to: "/")
+ end
+
+ @doc """
+ Authenticates the user by looking into the session
+ and remember me token.
+ """
+ def fetch_current_user(conn, _opts) do
+ {user_token, conn} = ensure_user_token(conn)
+ user = user_token && UserManager.get_user_by_session_token(user_token)
+ assign(conn, :current_user, user)
+ end
+
+ defp ensure_user_token(conn) do
+ if user_token = get_session(conn, :user_token) do
+ {user_token, conn}
+ else
+ conn = fetch_cookies(conn, signed: [@remember_me_cookie])
+
+ if user_token = conn.cookies[@remember_me_cookie] do
+ {user_token, put_session(conn, :user_token, user_token)}
+ else
+ {nil, conn}
+ end
+ end
+ end
+
+ @doc """
+ Used for routes that require the user to not be authenticated.
+ """
+ def redirect_if_user_is_authenticated(conn, _opts) do
+ if conn.assigns[:current_user] do
+ conn
+ |> redirect(to: signed_in_path(conn))
+ |> halt()
+ else
+ conn
+ end
+ end
+
+ @doc """
+ Used for routes that require the user to be authenticated.
+
+ If you want to enforce the user email is confirmed before
+ they use the application at all, here would be a good place.
+ """
+ def require_authenticated_user(conn, _opts) do
+ if conn.assigns[:current_user] do
+ conn
+ else
+ conn
+ |> put_flash(:error, "You must log in to access this page.")
+ |> maybe_store_return_to()
+ |> redirect(to: Routes.user_session_path(conn, :new))
+ |> halt()
+ end
+ end
+
+ defp maybe_store_return_to(%{method: "GET"} = conn) do
+ put_session(conn, :user_return_to, current_path(conn))
+ end
+
+ defp maybe_store_return_to(conn), do: conn
+
+ defp signed_in_path(_conn), do: "/"
+end
diff --git a/lib/kmxcrm_web/controllers/user_confirmation_controller.ex b/lib/kmxcrm_web/controllers/user_confirmation_controller.ex
new file mode 100644
index 0000000..979c8e2
--- /dev/null
+++ b/lib/kmxcrm_web/controllers/user_confirmation_controller.ex
@@ -0,0 +1,56 @@
+defmodule KmxcrmWeb.UserConfirmationController do
+ use KmxcrmWeb, :controller
+
+ alias Kmxcrm.UserManager
+
+ def new(conn, _params) do
+ render(conn, "new.html")
+ end
+
+ def create(conn, %{"user" => %{"email" => email}}) do
+ if user = UserManager.get_user_by_email(email) do
+ UserManager.deliver_user_confirmation_instructions(
+ user,
+ &Routes.user_confirmation_url(conn, :edit, &1)
+ )
+ end
+
+ conn
+ |> put_flash(
+ :info,
+ "If your email is in our system and it has not been confirmed yet, " <>
+ "you will receive an email with instructions shortly."
+ )
+ |> redirect(to: "/")
+ end
+
+ def edit(conn, %{"token" => token}) do
+ render(conn, "edit.html", token: token)
+ end
+
+ # Do not log in the user after confirmation to avoid a
+ # leaked token giving the user access to the account.
+ def update(conn, %{"token" => token}) do
+ case UserManager.confirm_user(token) do
+ {:ok, _} ->
+ conn
+ |> put_flash(:info, "User confirmed successfully.")
+ |> redirect(to: "/")
+
+ :error ->
+ # If there is a current user and the account was already confirmed,
+ # then odds are that the confirmation link was already visited, either
+ # by some automation or by the user themselves, so we redirect without
+ # a warning message.
+ case conn.assigns do
+ %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
+ redirect(conn, to: "/")
+
+ %{} ->
+ conn
+ |> put_flash(:error, "User confirmation link is invalid or it has expired.")
+ |> redirect(to: "/")
+ end
+ end
+ end
+end
diff --git a/lib/kmxcrm_web/controllers/user_controller.ex b/lib/kmxcrm_web/controllers/user_controller.ex
new file mode 100644
index 0000000..1e50427
--- /dev/null
+++ b/lib/kmxcrm_web/controllers/user_controller.ex
@@ -0,0 +1,150 @@
+defmodule KmxcrmWeb.UserController do
+ use KmxcrmWeb, :controller
+
+ alias Kmxcrm.UserManager
+ alias Kmxcrm.UserManager.Avatar
+
+ def avatar(conn, %{"login" => login,
+ "size" => size}) do
+ user = UserManager.get_user_by_login(login)
+ if user do
+ path = Avatar.path(user, size)
+ conn
+ |> put_resp_content_type("image/png")
+ |> send_file(200, path)
+ end
+ end
+ def avatar(conn, _) do
+ not_found(conn)
+ end
+
+ def edit(conn, params) do
+ current_user = conn.assigns.current_user
+ if params["login"] == current_user.login do
+ user = current_user
+ changeset = UserManager.change_user(user)
+ email_changeset = UserManager.change_user_email(user)
+ password_changeset = UserManager.change_user_password(user)
+ conn
+ |> assign(:changeset, changeset)
+ |> assign(:email_changeset, email_changeset)
+ |> assign(:page_title, gettext("Edit user %{login}", login: user.login))
+ |> assign(:password_changeset, password_changeset)
+ |> assign(:user, user)
+ |> render("edit.html")
+ else
+ not_found(conn)
+ end
+ end
+
+ def update(conn, params) do
+ current_user = conn.assigns.current_user
+ if params["login"] == current_user.login do
+ user = current_user
+ case UserManager.update_user(user, params["user"]) do
+ {:ok, user1} ->
+ conn
+ |> redirect(to: Routes.user_path(conn, :show, user1))
+ {:error, changeset} ->
+ conn
+ |> assign(:page_title, gettext("Edit user %{login}", login: user.login))
+ |> assign(:changeset, changeset)
+ |> assign(:user, user)
+ |> render("edit.html")
+ end
+ else
+ not_found(conn)
+ end
+ end
+
+ defp img_src_data(data, type) do
+ "data:#{type};base64,#{Base.encode64(data)}"
+ end
+
+ defp totp_enrolment_qrcode_src(user) do
+ UserManager.totp_enrolment_url(user)
+ |> QRCodeEx.encode()
+ |> QRCodeEx.svg()
+ |> img_src_data("image/svg+xml")
+ end
+
+ def totp(conn, params) do
+ current_user = conn.assigns.current_user
+ if params["login"] == current_user.login do
+ user = current_user
+ changeset = UserManager.change_user(user)
+ |> Ecto.Changeset.put_change(:totp_last, "")
+ totp_enrolment_qrcode_src = totp_enrolment_qrcode_src(user)
+ conn
+ |> assign(:changeset, changeset)
+ |> assign(:page_title, gettext("Enrol TOTP for user %{login}", login: user.login))
+ |> assign(:totp_enrolment_qrcode_src, totp_enrolment_qrcode_src)
+ |> assign(:user, user)
+ |> render("totp.html")
+ else
+ not_found(conn)
+ end
+ end
+
+ def totp_update(conn, params) do
+ current_user = conn.assigns.current_user
+ if params["login"] == current_user.login do
+ user = current_user
+ IO.inspect(params)
+ case UserManager.update_user_totp(user, params["user"]) do
+ {:ok, user} ->
+ conn
+ |> put_flash(:info, "Enroled 2FA (TOTP) successfuly.")
+ |> redirect(to: Routes.user_path(conn, :show, user))
+ {:error, changeset} ->
+ totp_enrolment_qrcode_src = totp_enrolment_qrcode_src(user)
+ conn
+ |> assign(:changeset, changeset)
+ |> assign(:page_title, gettext("Enrol TOTP for user %{login}", login: user.login))
+ |> assign(:totp_enrolment_qrcode_src, totp_enrolment_qrcode_src)
+ |> assign(:user, user)
+ |> render("totp.html")
+ end
+ else
+ not_found(conn)
+ end
+ end
+
+ def totp_delete(conn, params) do
+ current_user = conn.assigns.current_user
+ if params["login"] == current_user.login do
+ user = current_user
+ case UserManager.delete_user_totp(user) do
+ {:ok, user} ->
+ conn
+ |> put_flash(:info, "Removed 2FA (TOTP) successfuly.")
+ |> redirect(to: Routes.user_path(conn, :show, user))
+ {:error, changeset} ->
+ IO.inspect(changeset)
+ conn
+ |> put_flash(:error, "Failed to remove 2FA (TOTP).")
+ |> redirect(to: Routes.user_path(conn, :edit, user.login))
+ end
+ else
+ not_found(conn)
+ end
+ end
+
+ def delete(conn, params) do
+ current_user = conn.assigns.current_user
+ if params["login"] == current_user.login do
+ case UserManager.delete_user(current_user) do
+ {:ok, _} ->
+ conn
+ |> redirect(to: "/")
+ {:error, changeset} ->
+ conn
+ |> assign(:changeset, changeset)
+ |> assign(:page_title, gettext("Edit user %{login}", login: current_user.login))
+ |> render("edit.html")
+ end
+ else
+ not_found(conn)
+ end
+ end
+end
diff --git a/lib/kmxcrm_web/controllers/user_registration_controller.ex b/lib/kmxcrm_web/controllers/user_registration_controller.ex
new file mode 100644
index 0000000..336de18
--- /dev/null
+++ b/lib/kmxcrm_web/controllers/user_registration_controller.ex
@@ -0,0 +1,30 @@
+defmodule KmxcrmWeb.UserRegistrationController do
+ use KmxcrmWeb, :controller
+
+ alias Kmxcrm.UserManager
+ alias Kmxcrm.UserManager.User
+ alias KmxcrmWeb.UserAuth
+
+ def new(conn, _params) do
+ changeset = UserManager.change_user_registration(%User{})
+ render(conn, "new.html", changeset: changeset)
+ end
+
+ def create(conn, %{"user" => user_params}) do
+ case UserManager.register_user(user_params) do
+ {:ok, user} ->
+ {:ok, _} =
+ UserManager.deliver_user_confirmation_instructions(
+ user,
+ &Routes.user_confirmation_url(conn, :edit, &1)
+ )
+
+ conn
+ |> put_flash(:info, "User created successfully.")
+ |> UserAuth.log_in_user(user)
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ render(conn, "new.html", changeset: changeset)
+ end
+ end
+end
diff --git a/lib/kmxcrm_web/controllers/user_reset_password_controller.ex b/lib/kmxcrm_web/controllers/user_reset_password_controller.ex
new file mode 100644
index 0000000..a3206fc
--- /dev/null
+++ b/lib/kmxcrm_web/controllers/user_reset_password_controller.ex
@@ -0,0 +1,58 @@
+defmodule KmxcrmWeb.UserResetPasswordController do
+ use KmxcrmWeb, :controller
+
+ alias Kmxcrm.UserManager
+
+ plug :get_user_by_reset_password_token when action in [:edit, :update]
+
+ def new(conn, _params) do
+ render(conn, "new.html")
+ end
+
+ def create(conn, %{"user" => %{"email" => email}}) do
+ if user = UserManager.get_user_by_email(email) do
+ UserManager.deliver_user_reset_password_instructions(
+ user,
+ &Routes.user_reset_password_url(conn, :edit, &1)
+ )
+ end
+
+ conn
+ |> put_flash(
+ :info,
+ "If your email is in our system, you will receive instructions to reset your password shortly."
+ )
+ |> redirect(to: "/")
+ end
+
+ def edit(conn, _params) do
+ render(conn, "edit.html", changeset: UserManager.change_user_password(conn.assigns.user))
+ end
+
+ # Do not log in the user after reset password to avoid a
+ # leaked token giving the user access to the account.
+ def update(conn, %{"user" => user_params}) do
+ case UserManager.reset_user_password(conn.assigns.user, user_params) do
+ {:ok, _} ->
+ conn
+ |> put_flash(:info, "Password reset successfully.")
+ |> redirect(to: Routes.user_session_path(conn, :new))
+
+ {:error, changeset} ->
+ render(conn, "edit.html", changeset: changeset)
+ end
+ end
+
+ defp get_user_by_reset_password_token(conn, _opts) do
+ %{"token" => token} = conn.params
+
+ if user = UserManager.get_user_by_reset_password_token(token) do
+ conn |> assign(:user, user) |> assign(:token, token)
+ else
+ conn
+ |> put_flash(:error, "Reset password link is invalid or it has expired.")
+ |> redirect(to: "/")
+ |> halt()
+ end
+ end
+end
diff --git a/lib/kmxcrm_web/controllers/user_session_controller.ex b/lib/kmxcrm_web/controllers/user_session_controller.ex
new file mode 100644
index 0000000..e68cf37
--- /dev/null
+++ b/lib/kmxcrm_web/controllers/user_session_controller.ex
@@ -0,0 +1,27 @@
+defmodule KmxcrmWeb.UserSessionController do
+ use KmxcrmWeb, :controller
+
+ alias Kmxcrm.UserManager
+ alias KmxcrmWeb.UserAuth
+
+ def new(conn, _params) do
+ render(conn, "new.html", error_message: nil)
+ end
+
+ def create(conn, %{"user" => user_params}) do
+ %{"email" => email, "password" => password} = user_params
+
+ if user = UserManager.get_user_by_email_and_password(email, password) do
+ UserAuth.log_in_user(conn, user, user_params)
+ else
+ # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
+ render(conn, "new.html", error_message: "Invalid email or password")
+ end
+ end
+
+ def delete(conn, _params) do
+ conn
+ |> put_flash(:info, "Logged out successfully.")
+ |> UserAuth.log_out_user()
+ end
+end
diff --git a/lib/kmxcrm_web/controllers/user_settings_controller.ex b/lib/kmxcrm_web/controllers/user_settings_controller.ex
new file mode 100644
index 0000000..347feed
--- /dev/null
+++ b/lib/kmxcrm_web/controllers/user_settings_controller.ex
@@ -0,0 +1,74 @@
+defmodule KmxcrmWeb.UserSettingsController do
+ use KmxcrmWeb, :controller
+
+ alias Kmxcrm.UserManager
+ alias KmxcrmWeb.UserAuth
+
+ plug :assign_email_and_password_changesets
+
+ def edit(conn, _params) do
+ render(conn, "edit.html")
+ end
+
+ def update(conn, %{"action" => "update_email"} = params) do
+ %{"current_password" => password, "user" => user_params} = params
+ user = conn.assigns.current_user
+
+ case UserManager.apply_user_email(user, password, user_params) do
+ {:ok, applied_user} ->
+ UserManager.deliver_update_email_instructions(
+ applied_user,
+ user.email,
+ &Routes.user_settings_url(conn, :confirm_email, &1)
+ )
+
+ conn
+ |> put_flash(
+ :info,
+ "A link to confirm your email change has been sent to the new address."
+ )
+ |> redirect(to: Routes.user_settings_path(conn, :edit))
+
+ {:error, changeset} ->
+ render(conn, "edit.html", email_changeset: changeset)
+ end
+ end
+
+ def update(conn, %{"action" => "update_password"} = params) do
+ %{"current_password" => password, "user" => user_params} = params
+ user = conn.assigns.current_user
+
+ case UserManager.update_user_password(user, password, user_params) do
+ {:ok, user} ->
+ conn
+ |> put_flash(:info, "Password updated successfully.")
+ |> put_session(:user_return_to, Routes.user_settings_path(conn, :edit))
+ |> UserAuth.log_in_user(user)
+
+ {:error, changeset} ->
+ render(conn, "edit.html", password_changeset: changeset)
+ end
+ end
+
+ def confirm_email(conn, %{"token" => token}) do
+ case UserManager.update_user_email(conn.assigns.current_user, token) do
+ :ok ->
+ conn
+ |> put_flash(:info, "Email changed successfully.")
+ |> redirect(to: Routes.user_settings_path(conn, :edit))
+
+ :error ->
+ conn
+ |> put_flash(:error, "Email change link is invalid or it has expired.")
+ |> redirect(to: Routes.user_settings_path(conn, :edit))
+ end
+ end
+
+ defp assign_email_and_password_changesets(conn, _opts) do
+ user = conn.assigns.current_user
+
+ conn
+ |> assign(:email_changeset, UserManager.change_user_email(user))
+ |> assign(:password_changeset, UserManager.change_user_password(user))
+ end
+end
diff --git a/lib/kmxcrm_web/router.ex b/lib/kmxcrm_web/router.ex
index 494d439..864b110 100644
--- a/lib/kmxcrm_web/router.ex
+++ b/lib/kmxcrm_web/router.ex
@@ -1,6 +1,17 @@
defmodule KmxcrmWeb.Router do
use KmxcrmWeb, :router
+ import KmxcrmWeb.UserAuth
+
+ pipeline :admin do
+ plug Kmxcrm.Plug.EnsureAdmin
+ plug :put_root_layout, {KmxcrmWeb.LayoutView, "admin.html"}
+ end
+
+ pipeline :api do
+ plug :accepts, ["json"]
+ end
+
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
@@ -8,37 +19,28 @@ defmodule KmxcrmWeb.Router do
plug :put_root_layout, {KmxcrmWeb.LayoutView, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
+ plug :fetch_current_user
end
- pipeline :api do
- plug :accepts, ["json"]
+ pipeline :recaptcha do
+ plug PlugRecaptcha2, recaptcha_secret: Application.get_env(:kmxcrm, :recaptcha_secret)
end
scope "/", KmxcrmWeb do
pipe_through :browser
- get "/", PageController, :index
- end
+ get "/", PageController, :index
+ get "/_new_admin", PageController, :new_admin
+ post "/_new_admin", PageController, :new_admin_post
- # Other scopes may use custom stacks.
- # scope "/api", KmxcrmWeb do
- # pipe_through :api
- # end
+ get "/_confirm", UserConfirmationController, :new
+ post "/_confirm", UserConfirmationController, :create
+ get "/_confirm/:token", UserConfirmationController, :edit
+ post "/_confirm/:token", UserConfirmationController, :update
- # Enables LiveDashboard only for development
- #
- # If you want to use the LiveDashboard in production, you should put
- # it behind authentication and allow only admins to access it.
- # If your application does not have an admins-only section yet,
- # you can use Plug.BasicAuth to set up some basic authentication
- # as long as you are also using SSL (which you should anyway).
- if Mix.env() in [:dev, :test] do
- import Phoenix.LiveDashboard.Router
-
- scope "/" do
- pipe_through :browser
- live_dashboard "/dashboard", metrics: KmxcrmWeb.Telemetry
- end
+ get "/_avatar/:login/:size/avatar.png", UserController, :avatar
+
+ delete "/_log_out", UserSessionController, :delete
end
# Enables the Swoosh mailbox preview in development.
@@ -52,4 +54,58 @@ defmodule KmxcrmWeb.Router do
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
+
+ ## Authentication routes
+
+ scope "/", KmxcrmWeb do
+ pipe_through [:browser, :redirect_if_user_is_authenticated]
+
+ get "/_log_in", UserSessionController, :new
+ get "/_register", UserRegistrationController, :new
+ get "/_reset_password", UserResetPasswordController, :new
+ get "/_reset_password/:token", UserResetPasswordController, :edit
+ put "/_reset_password/:token", UserResetPasswordController, :update
+
+ pipe_through :recaptcha
+ post "/_log_in", UserSessionController, :create
+ post "/_register", UserRegistrationController, :create
+ post "/_reset_password", UserResetPasswordController, :create
+ end
+
+ scope "/", KmxcrmWeb do
+ pipe_through [:browser, :require_authenticated_user]
+
+ get "/_settings", UserSettingsController, :edit
+ put "/_settings", UserSettingsController, :update
+ get "/_settings/confirm_email/:token", UserSettingsController, :confirm_email
+
+ scope "/_edit/" do
+ get "/organisation/:slug", OrganisationController, :edit
+ put "/organisation/:slug", OrganisationController, :update
+ get "/user/:login", UserController, :edit
+ put "/user/:login", UserController, :update
+ get "/user/:login/totp", UserController, :totp
+ put "/user/:login/totp", UserController, :totp_update
+ delete "/user/:login/totp", UserController, :totp_delete
+ end
+
+ scope "/_new" do
+ get "/organisation", OrganisationController, :new
+ post "/organisation", OrganisationController, :create
+ end
+
+ scope "/_admin", Admin, as: "admin" do
+ pipe_through :admin
+ get "/", DashboardController, :index
+ resources "/organisations", OrganisationController
+ resources "/users", UserController do
+ get "/password/edit", UserController, :edit_password, as: :""
+ put "/password", UserController, :update_password, as: :""
+ end
+ import Phoenix.LiveDashboard.Router
+ live_dashboard "/dashboard", metrics: KmxcrmWeb.Telemetry
+ end
+
+ get "/:slug", OrganisationController, :show
+ end
end
diff --git a/lib/kmxcrm_web/templates/admin/user/edit.html.heex b/lib/kmxcrm_web/templates/admin/user/edit.html.heex
new file mode 100644
index 0000000..b6d247f
--- /dev/null
+++ b/lib/kmxcrm_web/templates/admin/user/edit.html.heex
@@ -0,0 +1,5 @@
+<div class="container-fluid">
+ <h1><%= gettext "Edit user" %> <%= @user.slug.slug %></h1>
+
+ <%= render("form.html", assigns) %>
+</div>
diff --git a/lib/kmxcrm_web/templates/admin/user/edit_password.html.heex b/lib/kmxcrm_web/templates/admin/user/edit_password.html.heex
new file mode 100644
index 0000000..5731463
--- /dev/null
+++ b/lib/kmxcrm_web/templates/admin/user/edit_password.html.heex
@@ -0,0 +1,31 @@
+<div class="container-fluid">
+ <h1><%= gettext "Edit user" %> <%= @user.slug.slug %></h1>
+
+ <%= form_for @changeset, Routes.admin_user__path(@conn, :update_password, @user), fn f -> %>
+ <div class="mb-3">
+ <%= label f, :password, class: "form-label" %>
+ <%= password_input f, :password, class: "form-control" %>
+ <%= error_tag f, :password %>
+ </div>
+
+ <div class="mb-3">
+ <%= label f, :password_confirmation, class: "form-label" %>
+ <%= password_input f, :password_confirmation, class: "form-control" %>
+ <%= error_tag f, :password_confirmation %>
+ </div>
+
+ <div class="mb-3">
+ <%= if @conn.assigns[:user] do %>
+ <%= link gettext("Cancel"),
+ to: Routes.admin_user_path(@conn, :show, @user),
+ class: "btn btn-secondary" %>
+ <% else %>
+ <%= link gettext("Cancel"),
+ to: Routes.admin_user_path(@conn, :index),
+ class: "btn btn-secondary" %>
+ <% end %>
+ <%= submit gettext("Submit"), class: "btn btn-primary" %>
+ </div>
+ <% end %>
+
+</div>
diff --git a/lib/kmxcrm_web/templates/admin/user/index.html.heex b/lib/kmxcrm_web/templates/admin/user/index.html.heex
new file mode 100644
index 0000000..0737836
--- /dev/null
+++ b/lib/kmxcrm_web/templates/admin/user/index.html.heex
@@ -0,0 +1,43 @@
+<div class="container-fluid">
+ <div class="row">
+ <div class="col col-md-7">
+ <h1>Users</h1>
+ </div>
+ <div class="col col-md-5">
+ <%= link gettext("Create user"), to: Routes.admin_user_path(@conn, :new), class: "btn btn-primary" %>
+ </div>
+ </div>
+ <%= render(KmxgitWeb.LayoutView, "search.html", assigns) %>
+ <%= render("pagination.html", assigns) %>
+ <table class="table admin-index">
+ <thead>
+ <tr>
+ <th><%= link gettext("Id"), to: Routes.admin_user_path(@conn, :index, search: @search, sort: "id#{if @index.column == "id" && !@index.reverse, do: "-"}") %><%= if @index.column == "id" do %><%= if @index.reverse do %> <i class="fa fa-angle-down"></i><% else %> <i class="fa fa-angle-up"></i><% end %><% end %></th>
+ <th><%= link gettext("Name"), to: Routes.admin_user_path(@conn, :index, search: @search, sort: "name#{if @index.column == "name" && !@index.reverse, do: "-"}") %><%= if @index.column == "name" do %><%= if @index.reverse do %> <i class="fa fa-angle-down"></i><% else %> <i class="fa fa-angle-up"></i><% end %><% end %></th>
+ <th><%= link gettext("Email"), to: Routes.admin_user_path(@conn, :index, search: @search, sort: "email#{if @index.column == "email" && !@index.reverse, do: "-"}") %><%= if @index.column == "email" do %><%= if @index.reverse do %> <i class="fa fa-angle-down"></i><% else %> <i class="fa fa-angle-up"></i><% end %><% end %></th>
+ <th><%= link gettext("Login"), to: Routes.admin_user_path(@conn, :index, search: @search, sort: "login#{if @index.column == "login" && !@index.reverse, do: "-"}") %><%= if @index.column == "login" do %><%= if @index.reverse do %> <i class="fa fa-angle-down"></i><% else %> <i class="fa fa-angle-up"></i><% end %><% end %></th>
+ <th><%= link gettext("Disk usage"), to: Routes.admin_user_path(@conn, :index, search: @search, sort: "du#{if @index.column != "du" || (@index.column == "du" && !@index.reverse), do: "-"}") %><%= if @index.column == "du" do %><%= if @index.reverse do %> <i class="fa fa-angle-down"></i><% else %> <i class="fa fa-angle-up"></i><% end %><% end %></th>
+ <th><%= link gettext("2FA"), to: Routes.admin_user_path(@conn, :index, search: @search, sort: "mfa#{if @index.column == "mfa" && !@index.reverse, do: "-"}") %><%= if @index.column == "mfa" do %><%= if @index.reverse do %> <i class="fa fa-angle-down"></i><% else %> <i class="fa fa-angle-up"></i><% end %><% end %></th>
+ <th><%= link gettext("Admin"), to: Routes.admin_user_path(@conn, :index, search: @search, sort: "admin#{if @index.column == "admin" && !@index.reverse, do: "-"}") %><%= if @index.column == "admin" do %><%= if @index.reverse do %> <i class="fa fa-angle-down"></i><% else %> <i class="fa fa-angle-up"></i><% end %><% end %></th>
+ <th><%= link gettext("Deploy"), to: Routes.admin_user_path(@conn, :index, search: @search, sort: "deploy#{if @index.column == "deploy" && !@index.reverse, do: "-"}") %><%= if @index.column == "deploy" do %><%= if @index.reverse do %> <i class="fa fa-angle-down"></i><% else %> <i class="fa fa-angle-up"></i><% end %><% end %></th>
+ <th><%= gettext "Actions" %></th>
+ </tr>
+ </thead>
+ <tbody>
+ <%= for user <- @pagination.result do %>
+ <tr>
+ <td><%= link user.id, to: Routes.admin_user_path(@conn, :show, user) %></td>
+ <td><%= link user.name, to: Routes.admin_user_path(@conn, :show, user) %></td>
+ <td><%= link user.email, to: "mailto:#{user.email}" %></td>
+ <td><%= link User.login(user), to: Routes.admin_user_path(@conn, :show, user) %></td>
+ <td><%= disk_usage(user.disk_usage) %></td>
+ <td><%= if user.totp_last != 0, do: "TOTP", else: "none" %></td>
+ <td><%= user.is_admin %></td>
+ <td><%= user.deploy_only %></td>
+ <td><%= link gettext("Show"), to: Routes.slug_path(@conn, :show, User.login(user) || ""), class: "btn btn-sm btn-primary" %></td>
+ </tr>
+ <% end %>
+ </tbody>
+ </table>
+ <%= render("pagination.html", assigns) %>
+</div>
diff --git a/lib/kmxcrm_web/templates/admin/user/show.html.heex b/lib/kmxcrm_web/templates/admin/user/show.html.heex
new file mode 100644
index 0000000..47c426c
--- /dev/null
+++ b/lib/kmxcrm_web/templates/admin/user/show.html.heex
@@ -0,0 +1,90 @@
+<div class="container-fluid">
+ <h1>User <%= User.login(@user) %></h1>
+
+ <table class="table admin-properties">
+ <tr>
+ <th><%= gettext "Id" %></th>
+ <td><%= @user.id %></td>
+ </tr>
+ <tr>
+ <th><%= gettext "Name" %></th>
+ <td><%= @user.name %></td>
+ </tr>
+ <tr>
+ <th><%= gettext "Email" %></th>
+ <td><%= link @user.email, to: "mailto:#{@user.email}" %></td>
+ </tr>
+ <tr>
+ <th><%= gettext "Login" %></th>
+ <td><%= User.login(@user) %></td>
+ </tr>
+ <tr>
+ <th><%= gettext "Description" %></th>
+ <td>
+ <%= if @user.description do %>
+ <%= raw Earmark.as_html!(@user.description) %>
+ <% end %>
+ </td>
+ </tr>
+ <tr>
+ <th>
+ <%= gettext "Organisations" %>
+ </th>
+ <td>
+ <%= for org <- @user.organisations do %>
+ <%= link(org.name || org.slug.slug, to: Routes.admin_organisation_path(@conn, :show, org), class: "org") %>
+ <% end %>
+ </td>
+ </tr>
+ <tr>
+ <th><%= gettext "Repositories" %></th>
+ <td>
+ <ul>
+ <%= for repo <- @repos do %>
+ <li>
+ <%= link(Repository.full_slug(repo), to: Routes.admin_repository_path(@conn, :show, repo), class: "repo") %>
+ </li>
+ <% end %>
+ </ul>
+ </td>
+ </tr>
+ <tr>
+ <th><%= gettext "Disk usage" %></th>
+ <td><%= disk_usage(@user.disk_usage) %></td>
+ </tr>
+ <tr>
+ <th><%= gettext "2FA" %></th>
+ <td><%= if @user.totp_last != 0, do: "TOTP", else: "none" %></td>
+ </tr>
+ <tr>
+ <th><%= gettext "Admin" %></th>
+ <td><%= @user.is_admin %></td>
+ </tr>
+ <tr>
+ <th><%= gettext "Deploy only" %></th>
+ <td><%= @user.deploy_only %></td>
+ </tr>
+ <tr>
+ <th><%= gettext "SSH keys" %></th>
+ <td><pre class="admin_ssh_keys"><%= @user.ssh_keys %></pre></td>
+ </tr>
+ </table>
+
+ <%= link gettext("Delete user"),
+ to: Routes.admin_user_path(@conn, :delete, @user),
+ class: "btn btn-danger",
+ data: [confirm: gettext("Are you sure you want to delete this user ?")],
+ method: :delete %>
+
+ <%= link gettext("Edit user"),
+ to: Routes.admin_user_path(@conn, :edit, @user),
+ class: "btn btn-primary" %>
+
+ <%= link gettext("Edit password"),
+ to: Routes.admin_user__path(@conn, :edit_password, @user),
+ class: "btn btn-primary" %>
+
+ <%= link gettext("Show"),
+ to: Routes.slug_path(@conn, :show, User.login(@user) || ""),
+ class: "btn btn-primary" %>
+</div>
diff --git a/lib/kmxcrm_web/templates/layout/_user_menu.html.heex b/lib/kmxcrm_web/templates/layout/_user_menu.html.heex
new file mode 100644
index 0000000..f281cfc
--- /dev/null
+++ b/lib/kmxcrm_web/templates/layout/_user_menu.html.heex
@@ -0,0 +1,10 @@
+<ul>
+<%= if @current_user do %>
+ <li><%= @current_user.email %></li>
+ <li><%= link "Settings", to: Routes.user_settings_path(@conn, :edit) %></li>
+ <li><%= link "Log out", to: Routes.user_session_path(@conn, :delete), method: :delete %></li>
+<% else %>
+ <li><%= link "Register", to: Routes.user_registration_path(@conn, :new) %></li>
+ <li><%= link "Log in", to: Routes.user_session_path(@conn, :new) %></li>
+<% end %>
+</ul>
diff --git a/lib/kmxcrm_web/templates/layout/admin.html.heex b/lib/kmxcrm_web/templates/layout/admin.html.heex
new file mode 100644
index 0000000..801ce9c
--- /dev/null
+++ b/lib/kmxcrm_web/templates/layout/admin.html.heex
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8"/>
+ <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+ <%= csrf_meta_tag() %>
+ <%= if assigns[:page_title] do %>
+ <%= live_title_tag assigns[:page_title], suffix: " · kmx crm [admin]" %>
+ <% else %>
+ <%= live_title_tag "kmx crm [admin]" %>
+ <% end %>
+ <link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/_assets/app.css")}/>
+ <script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/_assets/app.js")}></script>
+ <link rel="icon" type="image/png" sizes="128x128" href="/_images/kmxcrm.logo.orange.128.png">
+ <link rel="icon" type="image/png" sizes="64x64" href="/_images/kmxcrm.logo.orange.64.png">
+ <link rel="icon" type="image/png" sizes="32x32" href="/_images/kmxcrm.logo.orange.32.png">
+ <link rel="icon" type="image/png" sizes="16x16" href="/_images/kmxcrm.logo.orange.16.png">
+ </head>
+ <body>
+ <%= render "admin_nav.html", assigns %>
+ <%= render "flash.html", assigns %>
+ <%= @inner_content %>
+ <%= render "footer.html", assigns %>
+ </body>
+</html>
diff --git a/lib/kmxcrm_web/templates/layout/admin_nav.html.heex b/lib/kmxcrm_web/templates/layout/admin_nav.html.heex
new file mode 100644
index 0000000..17313ea
--- /dev/null
+++ b/lib/kmxcrm_web/templates/layout/admin_nav.html.heex
@@ -0,0 +1,32 @@
+<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
+ <div class="container-fluid">
+ <a class="navbar-brand" href="/_admin">
+ <div class="kmxcrm-logo">
+ <span><i class="fa fa-user-circle"></i></span> kmx crm [admin]
+ </div>
+ </a>
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
+ <span class="navbar-toggler-icon"></span>
+ </button>
+
+ <div class="collapse navbar-collapse" id="navbarSupportedContent">
+ <ul class="navbar-nav me-auto mb-2 mb-lg-0">
+ <li class="navbar-item">
+ <%= link gettext("Organisations"), to: Routes.admin_organisation_path(@conn, :index), class: "nav-link" %>
+ </li>
+ <li class="navbar-item">
+ <%= link gettext("Users"), to: Routes.admin_user_path(@conn, :index), class: "nav-link" %>
+ </li>
+ <li class="navbar-item">
+ <%= link "LiveDashboard", to: Routes.live_dashboard_path(@conn, :home), class: "nav-link" %>
+ </li>
+ <li class="navbar-item">
+ <%= link "kmx crm", to: "/", class: "nav-link" %>
+ </li>
+ <li class="navbar-item">
+ <%= link gettext("Logout"), method: :delete, to: Routes.user_session_path(@conn, :delete), class: "nav-link" %>
+ </li>
+ </ul>
+ </div>
+ </div>
+</nav>
diff --git a/lib/kmxcrm_web/templates/layout/flash.html.heex b/lib/kmxcrm_web/templates/layout/flash.html.heex
new file mode 100644
index 0000000..2f85a62
--- /dev/null
+++ b/lib/kmxcrm_web/templates/layout/flash.html.heex
@@ -0,0 +1,11 @@
+<div class="container-fluid center">
+ <%= if get_flash(@conn)["error"] do %>
+ <div class="flash-error"><%= get_flash(@conn)["error"] %></div>
+ <% end %>
+ <%= if get_flash(@conn)["warning"] do %>
+ <div class="flash-warning"><%= get_flash(@conn)["warning"] %></div>
+ <% end %>
+ <%= if get_flash(@conn)["info"] do %>
+ <div class="flash-info"><%= get_flash(@conn)["info"] %></div>
+ <% end %>
+</div>
diff --git a/lib/kmxcrm_web/templates/layout/footer.html.heex b/lib/kmxcrm_web/templates/layout/footer.html.heex
new file mode 100644
index 0000000..fd87c4f
--- /dev/null
+++ b/lib/kmxcrm_web/templates/layout/footer.html.heex
@@ -0,0 +1,3 @@
+<div class="footer bg-light">
+ <%= raw Application.fetch_env!(:kmxcrm, :footer) %>
+</div>
diff --git a/lib/kmxcrm_web/templates/layout/nav.html.heex b/lib/kmxcrm_web/templates/layout/nav.html.heex
new file mode 100644
index 0000000..08ab200
--- /dev/null
+++ b/lib/kmxcrm_web/templates/layout/nav.html.heex
@@ -0,0 +1,23 @@
+<nav class="navbar navbar-expand-md navbar-dark bg-dark">
+ <div class="container-fluid">
+ <a class="navbar-brand" href="/">
+ <div class="kmxcrm-logo">
+ <span><i class="fa fa-user-circle"></i></span> kmx crm
+ </div>
+ </a>
+ <%= if !@conn.assigns[:no_navbar_links] do %>
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
+ <span class="navbar-toggler-icon"></span>
+ </button>
+ <div class="collapse navbar-collapse" id="navbarSupportedContent">
+ <ul class="navbar-nav me-auto mb-2 mb-lg-0">
+ <%= if @current_user do %>
+ <%= render("nav_connected.html", assigns) %>
+ <% else %>
+ <%= render("nav_disconnected.html", assigns) %>
+ <% end %>
+ </ul>
+ </div>
+ <% end %>
+ </div>
+</nav>
diff --git a/lib/kmxcrm_web/templates/layout/nav_connected.html.heex b/lib/kmxcrm_web/templates/layout/nav_connected.html.heex
new file mode 100644
index 0000000..f6a9673
--- /dev/null
+++ b/lib/kmxcrm_web/templates/layout/nav_connected.html.heex
@@ -0,0 +1,18 @@
+<%= if @conn.assigns[:current_organisation] do %>
+ <li class="nav-item">
+ <%= link @current_organisation.name || @current_organisation.slug,
+ to: Routes.organisation_path(@conn, :show, @current_organisation),
+ class: "nav-link" %>
+ </li>
+<% end %>
+<li class="nav-item">
+ <%= link @current_user.login, to: Routes.user_path(@conn, :show, @current_user.login), class: "nav-link" %>
+</li>
+<%= if @current_user.is_admin do %>
+ <li class="nav-item">
+ <%= link gettext("Admin"), to: Routes.admin_dashboard_path(@conn, :index), class: "nav-link" %>
+ </li>
+<% end %>
+<li class="nav-item">
+ <%= link gettext("Logout"), method: :delete, to: Routes.user_session_path(@conn, :delete), class: "nav-link" %>
+</li>
diff --git a/lib/kmxcrm_web/templates/layout/nav_disconnected.html.heex b/lib/kmxcrm_web/templates/layout/nav_disconnected.html.heex
new file mode 100644
index 0000000..f00632a
--- /dev/null
+++ b/lib/kmxcrm_web/templates/layout/nav_disconnected.html.heex
@@ -0,0 +1,6 @@
+<li class="nav-item">
+ <%= link "login", to: Routes.user_session_path(@conn, :new), class: "nav-link" %>
+</li>
+<li class="nav-item">
+ <%= link "register", to: Routes.user_registration_path(@conn, :new), class: "nav-link" %>
+</li>
diff --git a/lib/kmxcrm_web/templates/layout/root.html.heex b/lib/kmxcrm_web/templates/layout/root.html.heex
index 49d3b21..dbf5387 100644
--- a/lib/kmxcrm_web/templates/layout/root.html.heex
+++ b/lib/kmxcrm_web/templates/layout/root.html.heex
@@ -5,26 +5,22 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<%= csrf_meta_tag() %>
- <%= live_title_tag assigns[:page_title] || "Kmxcrm", suffix: " · Phoenix Framework" %>
- <link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")}/>
- <script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/assets/app.js")}></script>
+ <%= if assigns[:page_title] do %>
+ <%= live_title_tag assigns[:page_title], suffix: " - kmx crm" %>
+ <% else %>
+ <%= live_title_tag "kmx crm" %>
+ <% end %>
+ <link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/_assets/app.css")}/>
+ <script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/_assets/app.js")}></script>
+ <link rel="icon" type="image/png" sizes="128x128" href="/_images/kmxcrm.logo.black.128.png">
+ <link rel="icon" type="image/png" sizes="64x64" href="/_images/kmxcrm.logo.black.64.png">
+ <link rel="icon" type="image/png" sizes="32x32" href="/_images/kmxcrm.logo.black.32.png">
+ <link rel="icon" type="image/png" sizes="16x16" href="/_images/kmxcrm.logo.black.16.png">
</head>
<body>
- <header>
- <section class="container">
- <nav>
- <ul>
- <li><a href="https://hexdocs.pm/phoenix/overview.html">Get Started</a></li>
- <%= if function_exported?(Routes, :live_dashboard_path, 2) do %>
- <li><%= link "LiveDashboard", to: Routes.live_dashboard_path(@conn, :home) %></li>
- <% end %>
- </ul>
- </nav>
- <a href="https://phoenixframework.org/" class="phx-logo">
- <img src={Routes.static_path(@conn, "/images/phoenix.png")} alt="Phoenix Framework Logo"/>
- </a>
- </section>
- </header>
+ <%= render "nav.html", assigns %>
+ <%= render "flash.html", assigns %>
<%= @inner_content %>
+ <%= render "footer.html", assigns %>
</body>
</html>
diff --git a/lib/kmxcrm_web/templates/user/avatar.html.heex b/lib/kmxcrm_web/templates/user/avatar.html.heex
new file mode 100644
index 0000000..cbf3563
--- /dev/null
+++ b/lib/kmxcrm_web/templates/user/avatar.html.heex
@@ -0,0 +1,7 @@
+<% user = UserManager.get_user_by_email(@email) %>
+<%= if user do %>
+ <% avatar_path = Avatar.path(user, @size) %>
+ <%= link to: Routes.slug_path(@conn, :show, User.login(user)) do %><%= if File.exists?(avatar_path) do %><%= img_tag(Routes.user_path(@conn, :avatar, User.login(user), @size), alt: @title, title: @title, class: @class) %><% else %><img src={Exgravatar.gravatar_url(@email, s: @size)} alt={@title} title={@title} class={@class} /><% end %><% end %>
+<% else %>
+ <img src={Exgravatar.gravatar_url(@email, s: @size)} alt={@title} title={@title} class={@class} />
+<% end %>
diff --git a/lib/kmxcrm_web/templates/user/edit.html.heex b/lib/kmxcrm_web/templates/user/edit.html.heex
new file mode 100644
index 0000000..89cf31a
--- /dev/null
+++ b/lib/kmxcrm_web/templates/user/edit.html.heex
@@ -0,0 +1,147 @@
+<div class="container-fluid center">
+ <h1>Edit user <%= User.login(@user) %></h1>
+
+ <%= form_for @changeset, Routes.user_path(@conn, :update, User.login(@user)), [multipart: true], fn f -> %>
+
+ <div class="mb-3">
+ <%= label f, :name, class: "form-label" %>
+ <%= text_input f, :name, class: "form-control" %>
+ <%= error_tag f, :name %>
+ </div>
+
+ <%= inputs_for f, :slug, fn ff -> %>
+ <div class="mb-3">
+ <%= label ff, :slug, gettext("Login"), class: "form-label" %>
+ <%= text_input ff, :slug, class: "form-control" %>
+ <%= error_tag ff, :slug %>
+ </div>
+ <% end %>
+
+ <div class="mb-3">
+ <%= label f, :avatar, class: "form-label" %>
+ <%= file_input f, :avatar, class: "form-control" %>
+ <%= error_tag f, :avatar %>
+ </div>
+
+ <div class="mb-3">
+ <%= label f, :description, class: "form-label" %>
+ <%= textarea f, :description, class: "form-control" %>
+ <%= error_tag f, :description %>
+ </div>
+
+ <div class="mb-3">
+ <%= label f, :ssh_keys, gettext("SSH keys"), class: "form-label" %>
+ <%= textarea f, :ssh_keys, class: "form-control" %>
+ <%= error_tag f, :ssh_keys %>
+ </div>
+
+ <div class="mb-3 form-check">
+ <%= checkbox f, :deploy_only, class: "form-check-input" %>
+ <%= label f, :deploy_only, class: "form-check-label" %>
+ <%= error_tag f, :deploy_only %>
+ </div>
+
+ <div>
+ <%= error_tag f, :owned_repositories %>
+ </div>
+
+ <div class="mb-3">
+ <%= link gettext("Cancel"),
+ to: Routes.slug_path(@conn, :show, User.login(@user)),
+ class: "btn btn-secondary" %>
+ <%= link gettext("Delete user"),
+ to: Routes.user_path(@conn, :delete, User.login(@user)),
+ method: :delete,
+ class: "btn btn-danger",
+ data: [confirm: gettext("Are you sure you want to delete the user %{user} ?", user: User.login(@user))] %>
+ <%= submit gettext("Submit"), class: "btn btn-primary" %>
+ </div>
+ <% end %>
+
+ <hr/>
+ <h2><%= gettext "Change email" %></h2>
+
+ <.form let={f} for={@email_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_email">
+ <%= if @email_changeset.action do %>
+ <div class="alert alert-danger">
+ <p>Oops, something went wrong! Please check the errors below.</p>
+ </div>
+ <% end %>
+
+ <%= hidden_input f, :action, name: "action", value: "update_email" %>
+
+ <div class="mb-3">
+ <%= label f, :email, class: "form-label" %>
+ <%= email_input f, :email, class: "form-control", required: true %>
+ <%= error_tag f, :email %>
+ </div>
+
+ <div class="mb-3">
+ <%= label f, :current_password, for: "current_password_for_email", class: "form-label" %>
+ <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_email", class: "form-control" %>
+ <%= error_tag f, :current_password %>
+ </div>
+
+ <div class="mb-3">
+ <%= submit gettext("Submit"), class: "btn btn-primary" %>
+ </div>
+ </.form>
+
+ <hr/>
+ <h2><%= gettext "Change password" %></h2>
+
+ <.form let={f} for={@password_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_password">
+ <%= if @password_changeset.action do %>
+ <div class="alert alert-danger">
+ <p>Oops, something went wrong! Please check the errors below.</p>
+ </div>
+ <% end %>
+
+ <%= hidden_input f, :action, name: "action", value: "update_password" %>
+
+ <div class="mb-3">
+ <%= label f, :password, gettext("New password"), class: "form-label" %>
+ <%= password_input f, :password, class: "form-control", required: true %>
+ <%= error_tag f, :password %>
+ </div>
+
+ <div class="mb-3">
+ <%= label f, :password_confirmation, gettext("Confirm new password"), class: "form-label" %>
+ <%= password_input f, :password_confirmation, class: "form-control", required: true %>
+ <%= error_tag f, :password_confirmation %>
+ </div>
+
+ <div class="mb-3">
+ <%= label f, :current_password, for: "current_password_for_password", class: "form-label" %>
+ <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_password", class: "form-control" %>
+ <%= error_tag f, :current_password %>
+ </div>
+
+ <div class="mb-3">
+ <%= submit gettext("Submit"), class: "btn btn-primary" %>
+ </div>
+ </.form>
+
+ <hr/>
+ <h2><%= gettext "Two factor authentification (2FA)" %></h2>
+
+ <%= if @user.totp_last != 0 do %>
+ <p>
+ <%= gettext "2FA enabled (TOTP)" %>
+ </p>
+ <p>
+ <%= link gettext("Disable TOTP (Google Authenticator)"), to: Routes.user_path(@conn, :totp_delete, User.login(@user)), method: :delete, data: [confirm: gettext("Are you sure you want to disable TOTP (Google Authenticator) for %{site} ?", site: "kmxgit")], class: "btn btn-danger" %>
+ </p>
+ <% else %>
+ <p>
+ <%= link gettext("Enable TOTP (Google Authenticator)"), to: Routes.user_path(@conn, :totp, User.login(@user)), class: "btn btn-danger" %>
+ </p>
+ <% end %>
+
+ <br />
+
+ <hr/>
+
+ <br/>
+ <br/>
+</div>
diff --git a/lib/kmxcrm_web/templates/user/show.html.heex b/lib/kmxcrm_web/templates/user/show.html.heex
new file mode 100644
index 0000000..ead47c8
--- /dev/null
+++ b/lib/kmxcrm_web/templates/user/show.html.heex
@@ -0,0 +1,107 @@
+<div class="container-fluid">
+ <div class="row">
+ <div class="col col-12 col-sm-7">
+ <h1><%= @user.slug.slug %></h1>
+ </div>
+ <div class="col col-12 col-sm-4">
+ <%= if @current_user && @user.id == @current_user.id do %>
+ <%= link gettext("New repository"),
+ to: Routes.repository_path(@conn, :new, @user.slug.slug),
+ class: "btn btn-primary" %>
+ <%= link gettext("Edit"),
+ to: Routes.user_path(@conn, :edit, @user.slug.slug),
+ class: "btn btn-primary" %>
+ <% end %>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col col-12">
+ <hr/>
+ <%= render("avatar.html", conn: @conn, email: @user.email, size: 256, title: User.login(@user), class: "avatar-lg") %>
+ <h1><%= @user.name %></h1>
+ <div class="col col-12 col-md-8">
+ <%= if @user.description do %>
+ <%= raw Earmark.as_html!(@user.description) %>
+ <% end %>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col col-12 col-md-7">
+ <hr/>
+ <h2><%= gettext "Repositories" %></h2>
+ <ul>
+ <%= for repo <- @repos do %>
+ <li>
+ <%= link Repository.full_slug(repo), to: Routes.repository_path(@conn, :show, Repository.owner_slug(repo), Repository.splat(repo)) %>
+ </li>
+ <% end %>
+ </ul>
+ </div>
+ <div class="col col-12 col-md-4">
+ <hr/>
+ <h2><%= gettext "Properties" %></h2>
+ <table class="table admin-properties">
+ <tr>
+ <th><%= gettext "Name" %></th>
+ <td><%= @user.name %></td>
+ </tr>
+ <tr>
+ <th><%= gettext "Email" %></th>
+ <td><%= link @user.email, to: "mailto:#{@user.email}" %></td>
+ </tr>
+ <tr>
+ <th><%= gettext "Login" %></th>
+ <td><%= @user.slug.slug %></td>
+ </tr>
+ <tr>
+ <th><%= gettext "Description" %></th>
+ <td>
+ </td>
+ </tr>
+ <tr>
+ <th><%= gettext "Deploy only" %></th>
+ <td><%= @user.deploy_only %></td>
+ </tr>
+ <tr>
+ <th>
+ <%= gettext "Organisations" %><br/>
+ <%= if @current_user && @user.id == @current_user.id do %>
+ <%= link("+", to: Routes.organisation_path(@conn, :new), class: "btn btn-primary btn-sm") %>
+ <% end %>
+ </th>
+ <td>
+ <ul>
+ <%= for org <- Enum.sort_by(@user.organisations, fn o -> o.slug.slug end) do %>
+ <li>
+ <%= link(org.name || org.slug.slug,
+ to: Routes.slug_path(@conn, :show, org.slug.slug),
+ class: "org") %>
+ </li>
+ <% end %>
+ </ul>
+ </td>
+ </tr>
+ <tr>
+ <th><%= gettext "SSH keys" %></th>
+ <td class="scroll-x">
+ <pre class="ssh_keys">
+ <%= @user.ssh_keys %>
+ </pre>
+ </td>
+ </tr>
+ <tr>
+ <th><%= gettext "Disk usage" %></th>
+ <td><%= disk_usage(@disk_usage) %></td>
+ </tr>
+ <tr>
+ <th><%= gettext "Accessible" %></th>
+ <td><%= disk_usage(@disk_usage_all) %></td>
+ </tr>
+ </table>
+ </div>
+ </div>
+ <p>
+ </p>
+
+</div>
diff --git a/lib/kmxcrm_web/templates/user/totp.html.heex b/lib/kmxcrm_web/templates/user/totp.html.heex
new file mode 100644
index 0000000..1369f29
--- /dev/null
+++ b/lib/kmxcrm_web/templates/user/totp.html.heex
@@ -0,0 +1,17 @@
+<div class="container-fluid">
+ <h1><%= @page_title %></h1>
+
+ <img src={@totp_enrolment_qrcode_src} alt="" class="qrcode" />
+
+ <%= form_for @changeset, Routes.user_path(@conn, :totp_update, User.login(@user)), fn f -> %>
+ <div class="mb-3">
+ <%= label f, :otp_last, gettext("Token"), class: "form-label" %>
+ <%= number_input f, :otp_last, class: "form-control" %>
+ <%= error_tag f, :otp_last %>
+ </div>
+
+ <div class="mb-3">
+ <%= submit gettext("Submit"), class: "btn btn-primary" %>
+ </div>
+ <% end %>
+</div>
diff --git a/lib/kmxcrm_web/templates/user_confirmation/edit.html.heex b/lib/kmxcrm_web/templates/user_confirmation/edit.html.heex
new file mode 100644
index 0000000..e9bf443
--- /dev/null
+++ b/lib/kmxcrm_web/templates/user_confirmation/edit.html.heex
@@ -0,0 +1,12 @@
+<h1>Confirm account</h1>
+
+<.form let={_f} for={:user} action={Routes.user_confirmation_path(@conn, :update, @token)}>
+ <div>
+ <%= submit "Confirm my account" %>
+ </div>
+</.form>
+
+<p>
+ <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
+ <%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
+</p>
diff --git a/lib/kmxcrm_web/templates/user_confirmation/new.html.heex b/lib/kmxcrm_web/templates/user_confirmation/new.html.heex
new file mode 100644
index 0000000..4d9bee3
--- /dev/null
+++ b/lib/kmxcrm_web/templates/user_confirmation/new.html.heex
@@ -0,0 +1,15 @@
+<h1>Resend confirmation instructions</h1>
+
+<.form let={f} for={:user} action={Routes.user_confirmation_path(@conn, :create)}>
+ <%= label f, :email %>
+ <%= email_input f, :email, required: true %>
+
+ <div>
+ <%= submit "Resend confirmation instructions" %>
+ </div>
+</.form>
+
+<p>
+ <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
+ <%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
+</p>
diff --git a/lib/kmxcrm_web/templates/user_registration/new.html.heex b/lib/kmxcrm_web/templates/user_registration/new.html.heex
new file mode 100644
index 0000000..fac2f16
--- /dev/null
+++ b/lib/kmxcrm_web/templates/user_registration/new.html.heex
@@ -0,0 +1,26 @@
+<h1>Register</h1>
+
+<.form let={f} for={@changeset} action={Routes.user_registration_path(@conn, :create)}>
+ <%= if @changeset.action do %>
+ <div class="alert alert-danger">
+ <p>Oops, something went wrong! Please check the errors below.</p>
+ </div>
+ <% end %>
+
+ <%= label f, :email %>
+ <%= email_input f, :email, required: true %>
+ <%= error_tag f, :email %>
+
+ <%= label f, :password %>
+ <%= password_input f, :password, required: true %>
+ <%= error_tag f, :password %>
+
+ <div>
+ <%= submit "Register" %>
+ </div>
+</.form>
+
+<p>
+ <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> |
+ <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>
+</p>
diff --git a/lib/kmxcrm_web/templates/user_reset_password/edit.html.heex b/lib/kmxcrm_web/templates/user_reset_password/edit.html.heex
new file mode 100644
index 0000000..d8efb4b
--- /dev/null
+++ b/lib/kmxcrm_web/templates/user_reset_password/edit.html.heex
@@ -0,0 +1,26 @@
+<h1>Reset password</h1>
+
+<.form let={f} for={@changeset} action={Routes.user_reset_password_path(@conn, :update, @token)}>
+ <%= if @changeset.action do %>
+ <div class="alert alert-danger">
+ <p>Oops, something went wrong! Please check the errors below.</p>
+ </div>
+ <% end %>
+
+ <%= label f, :password, "New password" %>
+ <%= password_input f, :password, required: true %>
+ <%= error_tag f, :password %>
+
+ <%= label f, :password_confirmation, "Confirm new password" %>
+ <%= password_input f, :password_confirmation, required: true %>
+ <%= error_tag f, :password_confirmation %>
+
+ <div>
+ <%= submit "Reset password" %>
+ </div>
+</.form>
+
+<p>
+ <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
+ <%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
+</p>
diff --git a/lib/kmxcrm_web/templates/user_reset_password/new.html.heex b/lib/kmxcrm_web/templates/user_reset_password/new.html.heex
new file mode 100644
index 0000000..126cdba
--- /dev/null
+++ b/lib/kmxcrm_web/templates/user_reset_password/new.html.heex
@@ -0,0 +1,15 @@
+<h1>Forgot your password?</h1>
+
+<.form let={f} for={:user} action={Routes.user_reset_password_path(@conn, :create)}>
+ <%= label f, :email %>
+ <%= email_input f, :email, required: true %>
+
+ <div>
+ <%= submit "Send instructions to reset password" %>
+ </div>
+</.form>
+
+<p>
+ <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
+ <%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
+</p>
diff --git a/lib/kmxcrm_web/templates/user_session/new.html.heex b/lib/kmxcrm_web/templates/user_session/new.html.heex
new file mode 100644
index 0000000..49a7d79
--- /dev/null
+++ b/lib/kmxcrm_web/templates/user_session/new.html.heex
@@ -0,0 +1,27 @@
+<h1>Log in</h1>
+
+<.form let={f} for={@conn} action={Routes.user_session_path(@conn, :create)} as={:user}>
+ <%= if @error_message do %>
+ <div class="alert alert-danger">
+ <p><%= @error_message %></p>
+ </div>
+ <% end %>
+
+ <%= label f, :email %>
+ <%= email_input f, :email, required: true %>
+
+ <%= label f, :password %>
+ <%= password_input f, :password, required: true %>
+
+ <%= label f, :remember_me, "Keep me logged in for 60 days" %>
+ <%= checkbox f, :remember_me %>
+
+ <div>
+ <%= submit "Log in" %>
+ </div>
+</.form>
+
+<p>
+ <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
+ <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>
+</p>
diff --git a/lib/kmxcrm_web/templates/user_settings/edit.html.heex b/lib/kmxcrm_web/templates/user_settings/edit.html.heex
new file mode 100644
index 0000000..9863bc5
--- /dev/null
+++ b/lib/kmxcrm_web/templates/user_settings/edit.html.heex
@@ -0,0 +1,53 @@
+<h1>Settings</h1>
+
+<h3>Change email</h3>
+
+<.form let={f} for={@email_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_email">
+ <%= if @email_changeset.action do %>
+ <div class="alert alert-danger">
+ <p>Oops, something went wrong! Please check the errors below.</p>
+ </div>
+ <% end %>
+
+ <%= hidden_input f, :action, name: "action", value: "update_email" %>
+
+ <%= label f, :email %>
+ <%= email_input f, :email, required: true %>
+ <%= error_tag f, :email %>
+
+ <%= label f, :current_password, for: "current_password_for_email" %>
+ <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_email" %>
+ <%= error_tag f, :current_password %>
+
+ <div>
+ <%= submit "Change email" %>
+ </div>
+</.form>
+
+<h3>Change password</h3>
+
+<.form let={f} for={@password_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_password">
+ <%= if @password_changeset.action do %>
+ <div class="alert alert-danger">
+ <p>Oops, something went wrong! Please check the errors below.</p>
+ </div>
+ <% end %>
+
+ <%= hidden_input f, :action, name: "action", value: "update_password" %>
+
+ <%= label f, :password, "New password" %>
+ <%= password_input f, :password, required: true %>
+ <%= error_tag f, :password %>
+
+ <%= label f, :password_confirmation, "Confirm new password" %>
+ <%= password_input f, :password_confirmation, required: true %>
+ <%= error_tag f, :password_confirmation %>
+
+ <%= label f, :current_password, for: "current_password_for_password" %>
+ <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_password" %>
+ <%= error_tag f, :current_password %>
+
+ <div>
+ <%= submit "Change password" %>
+ </div>
+</.form>
diff --git a/lib/kmxcrm_web/views/user_confirmation_view.ex b/lib/kmxcrm_web/views/user_confirmation_view.ex
new file mode 100644
index 0000000..cb4fae8
--- /dev/null
+++ b/lib/kmxcrm_web/views/user_confirmation_view.ex
@@ -0,0 +1,3 @@
+defmodule KmxcrmWeb.UserConfirmationView do
+ use KmxcrmWeb, :view
+end
diff --git a/lib/kmxcrm_web/views/user_registration_view.ex b/lib/kmxcrm_web/views/user_registration_view.ex
new file mode 100644
index 0000000..7bfbf5e
--- /dev/null
+++ b/lib/kmxcrm_web/views/user_registration_view.ex
@@ -0,0 +1,3 @@
+defmodule KmxcrmWeb.UserRegistrationView do
+ use KmxcrmWeb, :view
+end
diff --git a/lib/kmxcrm_web/views/user_reset_password_view.ex b/lib/kmxcrm_web/views/user_reset_password_view.ex
new file mode 100644
index 0000000..01302a2
--- /dev/null
+++ b/lib/kmxcrm_web/views/user_reset_password_view.ex
@@ -0,0 +1,3 @@
+defmodule KmxcrmWeb.UserResetPasswordView do
+ use KmxcrmWeb, :view
+end
diff --git a/lib/kmxcrm_web/views/user_session_view.ex b/lib/kmxcrm_web/views/user_session_view.ex
new file mode 100644
index 0000000..13f2b06
--- /dev/null
+++ b/lib/kmxcrm_web/views/user_session_view.ex
@@ -0,0 +1,3 @@
+defmodule KmxcrmWeb.UserSessionView do
+ use KmxcrmWeb, :view
+end
diff --git a/lib/kmxcrm_web/views/user_settings_view.ex b/lib/kmxcrm_web/views/user_settings_view.ex
new file mode 100644
index 0000000..f80179d
--- /dev/null
+++ b/lib/kmxcrm_web/views/user_settings_view.ex
@@ -0,0 +1,3 @@
+defmodule KmxcrmWeb.UserSettingsView do
+ use KmxcrmWeb, :view
+end
diff --git a/lib/markdown.ex b/lib/markdown.ex
new file mode 100644
index 0000000..869d50b
--- /dev/null
+++ b/lib/markdown.ex
@@ -0,0 +1,17 @@
+defmodule Markdown do
+
+ def validate_markdown(changeset, field) do
+ data = Map.get(changeset.changes, field,
+ Map.get(changeset.data, field))
+ if data do
+ case Earmark.as_html(data) do
+ {:ok, _, _} ->
+ changeset
+ {:error, _, error_messages} ->
+ Ecto.Changeset.add_error(changeset, field, error_messages)
+ end
+ else
+ changeset
+ end
+ end
+end
diff --git a/lib/plug_recaptcha2.ex b/lib/plug_recaptcha2.ex
new file mode 100644
index 0000000..ca91234
--- /dev/null
+++ b/lib/plug_recaptcha2.ex
@@ -0,0 +1,45 @@
+defmodule PlugRecaptcha2 do
+ import Plug.Conn
+
+ def init(default), do: default
+
+ def call(conn, [recaptcha_secret: secret,
+ redirect: redirect]) do
+ case conn do
+ %Plug.Conn{params: %{"recaptcha" => signature}} ->
+ case verify_signature(signature, secret) do
+ {:ok} -> conn
+ _ -> halt_connection(conn, redirect)
+ end
+ _ ->
+ # IO.puts "no recaptcha param"
+ halt_connection(conn, redirect)
+ end
+ end
+ def call(conn, [recaptcha_secret: secret]) do
+ call(conn, [recaptcha_secret: secret,
+ redirect: nil])
+ end
+ def call(_conn, _opts), do: raise "Recaptcha Secret is missing"
+
+ defp verify_signature(signature, secret) do
+ post_url = "https://www.google.com/recaptcha/api/siteverify?secret=#{secret}&response=#{signature}"
+ resp = HTTPoison.post(post_url, "", [{"Content-Type", "application/json"}])
+ IO.inspect(recaptcha: resp)
+ case resp do
+ {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
+ Poison.decode(body)
+ |> verify_response
+ _ -> {:fail}
+ end
+ end
+
+ defp verify_response({:ok, %{"success" => true}}), do: {:ok}
+ defp verify_response(_), do: {:fail}
+
+ defp halt_connection(conn, redirect) do
+ url = hd(get_req_header(conn, "referer")) || redirect || "/"
+ conn
+ |> Phoenix.Controller.redirect(external: url)
+ end
+end
diff --git a/mix.exs b/mix.exs
index 89d61e3..9e7bb37 100644
--- a/mix.exs
+++ b/mix.exs
@@ -33,22 +33,29 @@ defmodule Kmxcrm.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
+ {:bcrypt_elixir, "~> 2.0"},
+ {:earmark, "~> 1.4.5"},
+ {:ecto_sql, "~> 3.6"},
+ {:esbuild, "~> 0.2", runtime: Mix.env() == :dev},
+ {:exgravatar, "~> 2.0"},
+ {:floki, ">= 0.30.0", only: :test},
+ {:gettext, "~> 0.18"},
+ {:jason, "~> 1.2"},
+ {:mogrify, "~> 0.9.1"},
{:phoenix, "~> 1.6.2"},
{:phoenix_ecto, "~> 4.4"},
- {:ecto_sql, "~> 3.6"},
- {:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 3.0"},
+ {:phoenix_live_dashboard, "~> 0.5"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 0.16.0"},
- {:floki, ">= 0.30.0", only: :test},
- {:phoenix_live_dashboard, "~> 0.5"},
- {:esbuild, "~> 0.2", runtime: Mix.env() == :dev},
+ {:plug_cowboy, "~> 2.5"},
+ {:plug_recaptcha, git: "https://github.com/thodg/plug_recaptcha.git"},
+ {:postgrex, ">= 0.0.0"},
+ {:pot, "~> 1.0"},
+ {:qrcode_ex, "~> 0.1.0"},
{:swoosh, "~> 1.3"},
{:telemetry_metrics, "~> 0.6"},
- {:telemetry_poller, "~> 1.0"},
- {:gettext, "~> 0.18"},
- {:jason, "~> 1.2"},
- {:plug_cowboy, "~> 2.5"}
+ {:telemetry_poller, "~> 1.0"}
]
end
diff --git a/mix.lock b/mix.lock
index c2d5580..fbf56f5 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,20 +1,34 @@
%{
+ "bcrypt_elixir": {:hex, :bcrypt_elixir, "2.3.1", "5114d780459a04f2b4aeef52307de23de961b69e13a5cd98a911e39fda13f420", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "42182d5f46764def15bf9af83739e3bf4ad22661b1c34fc3e88558efced07279"},
"castore": {:hex, :castore, "0.1.14", "3f6d7c7c1574c402fef29559d3f1a7389ba3524bc6a090a5e9e6abc3af65dcca", [:mix], [], "hexpm", "b34af542eadb727e6c8b37fdf73e18b2e02eb483a4ea0b52fd500bc23f052b7b"},
+ "certifi": {:hex, :certifi, "2.8.0", "d4fb0a6bb20b7c9c3643e22507e42f356ac090a1dcea9ab99e27e0376d695eba", [:rebar3], [], "hexpm", "6ac7efc1c6f8600b08d625292d4bbf584e14847ce1b6b5c44d983d273e1097ea"},
+ "comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"},
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
"db_connection": {:hex, :db_connection, "2.4.1", "6411f6e23f1a8b68a82fa3a36366d4881f21f47fc79a9efb8c615e62050219da", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ea36d226ec5999781a9a8ad64e5d8c4454ecedc7a4d643e4832bf08efca01f00"},
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
+ "earmark": {:hex, :earmark, "1.4.20", "d5097b1c7417a03c73a2985fcf01c3f72192c427b8a498719737dca5273938cb", [:mix], [{:earmark_parser, "== 1.4.18", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "7be744242dbde74c858279f4a65d9d31f37d163190d739340015c30038c1edb3"},
+ "earmark_parser": {:hex, :earmark_parser, "1.4.18", "e1b2be73eb08a49fb032a0208bf647380682374a725dfb5b9e510def8397f6f2", [:mix], [], "hexpm", "114a0e85ec3cf9e04b811009e73c206394ffecfcc313e0b346de0d557774ee97"},
"ecto": {:hex, :ecto, "3.7.1", "a20598862351b29f80f285b21ec5297da1181c0442687f9b8329f0445d228892", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d36e5b39fc479e654cffd4dbe1865d9716e4a9b6311faff799b6f90ab81b8638"},
"ecto_sql": {:hex, :ecto_sql, "3.7.2", "55c60aa3a06168912abf145c6df38b0295c34118c3624cf7a6977cd6ce043081", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0 or ~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c218ea62f305dcaef0b915fb56583195e7b91c91dcfb006ba1f669bfacbff2a"},
+ "elixir_make": {:hex, :elixir_make, "0.6.3", "bc07d53221216838d79e03a8019d0839786703129599e9619f4ab74c8c096eac", [:mix], [], "hexpm", "f5cbd651c5678bcaabdbb7857658ee106b12509cd976c2c2fca99688e1daf716"},
"esbuild": {:hex, :esbuild, "0.4.0", "9f17db148aead4cf1e6e6a584214357287a93407b5fb51a031f122b61385d4c2", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "b61e4e6b92ffe45e4ee4755a22de6211a67c67987dc02afb35a425a0add1d447"},
+ "exgravatar": {:hex, :exgravatar, "2.0.3", "2349709832ee535f826f48db98cddd294ae62b01acb44d539a16419bd8ebc3e5", [:mix], [], "hexpm", "aca18ff9bd8991d3be3e5446d3bdefc051be084c1ffc9ab2d43b3e65339300e1"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"floki": {:hex, :floki, "0.32.0", "f915dc15258bc997d49be1f5ef7d3992f8834d6f5695270acad17b41f5bcc8e2", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "1c5a91cae1fd8931c26a4826b5e2372c284813904c8bacb468b5de39c7ececbd"},
"gettext": {:hex, :gettext, "0.19.0", "6909d61b38bb33339558f128f8af5913d5d5fe304a770217bf352b1620fb7ec4", [:mix], [], "hexpm", "3f7a274f52ebda9bb6655dfeda3d6b0dc4537ae51ce41dcccc7f73ca7379ad5e"},
+ "hackney": {:hex, :hackney, "1.18.0", "c4443d960bb9fba6d01161d01cd81173089686717d9490e5d3606644c48d121f", [:rebar3], [{:certifi, "~>2.8.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "9afcda620704d720db8c6a3123e9848d09c87586dc1c10479c42627b905b5c5e"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
+ "httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"},
+ "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"},
+ "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"},
+ "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
+ "mogrify": {:hex, :mogrify, "0.9.1", "a26f107c4987477769f272bd0f7e3ac4b7b75b11ba597fd001b877beffa9c068", [:mix], [], "hexpm", "134edf189337d2125c0948bf0c228fdeef975c594317452d536224069a5b7f05"},
+ "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
"phoenix": {:hex, :phoenix, "1.6.6", "281c8ce8dccc9f60607346b72cdfc597c3dde134dd9df28dff08282f0b751754", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "807bd646e64cd9dc83db016199715faba72758e6db1de0707eef0a2da4924364"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"},
"phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"},
@@ -26,10 +40,16 @@
"plug": {:hex, :plug, "1.12.1", "645678c800601d8d9f27ad1aebba1fdb9ce5b2623ddb961a074da0b96c35187d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d57e799a777bc20494b784966dc5fbda91eb4a09f571f76545b72a634ce0d30b"},
"plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"},
"plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
+ "plug_recaptcha": {:git, "https://github.com/thodg/plug_recaptcha.git", "69e69c1cd5934f98b728b31ce35542042a46373e", []},
+ "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"},
"postgrex": {:hex, :postgrex, "0.16.1", "f94628a32c571266f53cd1e5fca705e626e2417bf1eee6f868985d14e874160a", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "6b225df32c857b9430619dbe30200a7ae664e23415a771ae9209396ee8eeee64"},
+ "pot": {:hex, :pot, "1.0.2", "13abb849139fdc04ab8154986abbcb63bdee5de6ed2ba7e1713527e33df923dd", [:rebar3], [], "hexpm", "78fe127f5a4f5f919d6ea5a2a671827bd53eb9d37e5b4128c0ad3df99856c2e0"},
+ "qrcode_ex": {:hex, :qrcode_ex, "0.1.1", "8907a7558325babd30f7f43ff85a0169ef65c30820d68e90d792802318f9a062", [:mix], [], "hexpm", "9eb0b397fb3a1c3b16e55b6de6f845a0b4e7b7100ade39eb59fad98fb62455a7"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
+ "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"swoosh": {:hex, :swoosh, "1.6.1", "7c4b0903876ca4b93992a3225d02c9a5e906aff2610a8d1b78020563a6fd9a6c", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3e79cc8df5eb3b375f4456cd60af083126111cc4929a06ba3839162efb57862e"},
"telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
+ "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
}
diff --git a/priv/repo/migrations/20220131220437_create_users_auth_tables.exs b/priv/repo/migrations/20220131220437_create_users_auth_tables.exs
new file mode 100644
index 0000000..95b000c
--- /dev/null
+++ b/priv/repo/migrations/20220131220437_create_users_auth_tables.exs
@@ -0,0 +1,27 @@
+defmodule Kmxcrm.Repo.Migrations.CreateUsersAuthTables do
+ use Ecto.Migration
+
+ def change do
+ execute "CREATE EXTENSION IF NOT EXISTS citext", ""
+
+ create table(:users) do
+ add :email, :citext, null: false
+ add :hashed_password, :string, null: false
+ add :confirmed_at, :naive_datetime
+ timestamps()
+ end
+
+ create unique_index(:users, [:email])
+
+ create table(:users_tokens) do
+ add :user_id, references(:users, on_delete: :delete_all), null: false
+ add :token, :binary, null: false
+ add :context, :string, null: false
+ add :sent_to, :string
+ timestamps(updated_at: false)
+ end
+
+ create index(:users_tokens, [:user_id])
+ create unique_index(:users_tokens, [:context, :token])
+ end
+end
diff --git a/priv/repo/migrations/20220131234430_add_fields_to_users.exs b/priv/repo/migrations/20220131234430_add_fields_to_users.exs
new file mode 100644
index 0000000..8dc9557
--- /dev/null
+++ b/priv/repo/migrations/20220131234430_add_fields_to_users.exs
@@ -0,0 +1,14 @@
+defmodule Kmxcrm.Repo.Migrations.AddFieldsToUsers do
+ use Ecto.Migration
+
+ def change do
+ alter table(:users) do
+ add :description, :text
+ add :is_admin, :boolean, default: false, null: false
+ add :login, :string, null: false, unique: true
+ add :name, :string
+ add :totp_last, :integer, default: 0
+ add :totp_secret, :string
+ end
+ end
+end
diff --git a/test/kmxcrm/user_manager_test.exs b/test/kmxcrm/user_manager_test.exs
new file mode 100644
index 0000000..4b94cec
--- /dev/null
+++ b/test/kmxcrm/user_manager_test.exs
@@ -0,0 +1,508 @@
+defmodule Kmxcrm.UserManagerTest do
+ use Kmxcrm.DataCase
+
+ alias Kmxcrm.UserManager
+
+ import Kmxcrm.UserManagerFixtures
+ alias Kmxcrm.UserManager.{User, UserToken}
+
+ describe "get_user_by_email/1" do
+ test "does not return the user if the email does not exist" do
+ refute UserManager.get_user_by_email("unknown@example.com")
+ end
+
+ test "returns the user if the email exists" do
+ %{id: id} = user = user_fixture()
+ assert %User{id: ^id} = UserManager.get_user_by_email(user.email)
+ end
+ end
+
+ describe "get_user_by_email_and_password/2" do
+ test "does not return the user if the email does not exist" do
+ refute UserManager.get_user_by_email_and_password("unknown@example.com", "hello world!")
+ end
+
+ test "does not return the user if the password is not valid" do
+ user = user_fixture()
+ refute UserManager.get_user_by_email_and_password(user.email, "invalid")
+ end
+
+ test "returns the user if the email and password are valid" do
+ %{id: id} = user = user_fixture()
+
+ assert %User{id: ^id} =
+ UserManager.get_user_by_email_and_password(user.email, valid_user_password())
+ end
+ end
+
+ describe "get_user!/1" do
+ test "raises if id is invalid" do
+ assert_raise Ecto.NoResultsError, fn ->
+ UserManager.get_user!(-1)
+ end
+ end
+
+ test "returns the user with the given id" do
+ %{id: id} = user = user_fixture()
+ assert %User{id: ^id} = UserManager.get_user!(user.id)
+ end
+ end
+
+ describe "register_user/1" do
+ test "requires email and password to be set" do
+ {:error, changeset} = UserManager.register_user(%{})
+
+ assert %{
+ password: ["can't be blank"],
+ email: ["can't be blank"]
+ } = errors_on(changeset)
+ end
+
+ test "validates email and password when given" do
+ {:error, changeset} = UserManager.register_user(%{email: "not valid", password: "not valid"})
+
+ assert %{
+ email: ["must have the @ sign and no spaces"],
+ password: ["should be at least 12 character(s)"]
+ } = errors_on(changeset)
+ end
+
+ test "validates maximum values for email and password for security" do
+ too_long = String.duplicate("db", 100)
+ {:error, changeset} = UserManager.register_user(%{email: too_long, password: too_long})
+ assert "should be at most 160 character(s)" in errors_on(changeset).email
+ assert "should be at most 72 character(s)" in errors_on(changeset).password
+ end
+
+ test "validates email uniqueness" do
+ %{email: email} = user_fixture()
+ {:error, changeset} = UserManager.register_user(%{email: email})
+ assert "has already been taken" in errors_on(changeset).email
+
+ # Now try with the upper cased email too, to check that email case is ignored.
+ {:error, changeset} = UserManager.register_user(%{email: String.upcase(email)})
+ assert "has already been taken" in errors_on(changeset).email
+ end
+
+ test "registers users with a hashed password" do
+ email = unique_user_email()
+ {:ok, user} = UserManager.register_user(valid_user_attributes(email: email))
+ assert user.email == email
+ assert is_binary(user.hashed_password)
+ assert is_nil(user.confirmed_at)
+ assert is_nil(user.password)
+ end
+ end
+
+ describe "change_user_registration/2" do
+ test "returns a changeset" do
+ assert %Ecto.Changeset{} = changeset = UserManager.change_user_registration(%User{})
+ assert changeset.required == [:password, :email]
+ end
+
+ test "allows fields to be set" do
+ email = unique_user_email()
+ password = valid_user_password()
+
+ changeset =
+ UserManager.change_user_registration(
+ %User{},
+ valid_user_attributes(email: email, password: password)
+ )
+
+ assert changeset.valid?
+ assert get_change(changeset, :email) == email
+ assert get_change(changeset, :password) == password
+ assert is_nil(get_change(changeset, :hashed_password))
+ end
+ end
+
+ describe "change_user_email/2" do
+ test "returns a user changeset" do
+ assert %Ecto.Changeset{} = changeset = UserManager.change_user_email(%User{})
+ assert changeset.required == [:email]
+ end
+ end
+
+ describe "apply_user_email/3" do
+ setup do
+ %{user: user_fixture()}
+ end
+
+ test "requires email to change", %{user: user} do
+ {:error, changeset} = UserManager.apply_user_email(user, valid_user_password(), %{})
+ assert %{email: ["did not change"]} = errors_on(changeset)
+ end
+
+ test "validates email", %{user: user} do
+ {:error, changeset} =
+ UserManager.apply_user_email(user, valid_user_password(), %{email: "not valid"})
+
+ assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset)
+ end
+
+ test "validates maximum value for email for security", %{user: user} do
+ too_long = String.duplicate("db", 100)
+
+ {:error, changeset} =
+ UserManager.apply_user_email(user, valid_user_password(), %{email: too_long})
+
+ assert "should be at most 160 character(s)" in errors_on(changeset).email
+ end
+
+ test "validates email uniqueness", %{user: user} do
+ %{email: email} = user_fixture()
+
+ {:error, changeset} =
+ UserManager.apply_user_email(user, valid_user_password(), %{email: email})
+
+ assert "has already been taken" in errors_on(changeset).email
+ end
+
+ test "validates current password", %{user: user} do
+ {:error, changeset} =
+ UserManager.apply_user_email(user, "invalid", %{email: unique_user_email()})
+
+ assert %{current_password: ["is not valid"]} = errors_on(changeset)
+ end
+
+ test "applies the email without persisting it", %{user: user} do
+ email = unique_user_email()
+ {:ok, user} = UserManager.apply_user_email(user, valid_user_password(), %{email: email})
+ assert user.email == email
+ assert UserManager.get_user!(user.id).email != email
+ end
+ end
+
+ describe "deliver_update_email_instructions/3" do
+ setup do
+ %{user: user_fixture()}
+ end
+
+ test "sends token through notification", %{user: user} do
+ token =
+ extract_user_token(fn url ->
+ UserManager.deliver_update_email_instructions(user, "current@example.com", url)
+ end)
+
+ {:ok, token} = Base.url_decode64(token, padding: false)
+ assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
+ assert user_token.user_id == user.id
+ assert user_token.sent_to == user.email
+ assert user_token.context == "change:current@example.com"
+ end
+ end
+
+ describe "update_user_email/2" do
+ setup do
+ user = user_fixture()
+ email = unique_user_email()
+
+ token =
+ extract_user_token(fn url ->
+ UserManager.deliver_update_email_instructions(%{user | email: email}, user.email, url)
+ end)
+
+ %{user: user, token: token, email: email}
+ end
+
+ test "updates the email with a valid token", %{user: user, token: token, email: email} do
+ assert UserManager.update_user_email(user, token) == :ok
+ changed_user = Repo.get!(User, user.id)
+ assert changed_user.email != user.email
+ assert changed_user.email == email
+ assert changed_user.confirmed_at
+ assert changed_user.confirmed_at != user.confirmed_at
+ refute Repo.get_by(UserToken, user_id: user.id)
+ end
+
+ test "does not update email with invalid token", %{user: user} do
+ assert UserManager.update_user_email(user, "oops") == :error
+ assert Repo.get!(User, user.id).email == user.email
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+
+ test "does not update email if user email changed", %{user: user, token: token} do
+ assert UserManager.update_user_email(%{user | email: "current@example.com"}, token) == :error
+ assert Repo.get!(User, user.id).email == user.email
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+
+ test "does not update email if token expired", %{user: user, token: token} do
+ {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
+ assert UserManager.update_user_email(user, token) == :error
+ assert Repo.get!(User, user.id).email == user.email
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+ end
+
+ describe "change_user_password/2" do
+ test "returns a user changeset" do
+ assert %Ecto.Changeset{} = changeset = UserManager.change_user_password(%User{})
+ assert changeset.required == [:password]
+ end
+
+ test "allows fields to be set" do
+ changeset =
+ UserManager.change_user_password(%User{}, %{
+ "password" => "new valid password"
+ })
+
+ assert changeset.valid?
+ assert get_change(changeset, :password) == "new valid password"
+ assert is_nil(get_change(changeset, :hashed_password))
+ end
+ end
+
+ describe "update_user_password/3" do
+ setup do
+ %{user: user_fixture()}
+ end
+
+ test "validates password", %{user: user} do
+ {:error, changeset} =
+ UserManager.update_user_password(user, valid_user_password(), %{
+ password: "not valid",
+ password_confirmation: "another"
+ })
+
+ assert %{
+ password: ["should be at least 12 character(s)"],
+ password_confirmation: ["does not match password"]
+ } = errors_on(changeset)
+ end
+
+ test "validates maximum values for password for security", %{user: user} do
+ too_long = String.duplicate("db", 100)
+
+ {:error, changeset} =
+ UserManager.update_user_password(user, valid_user_password(), %{password: too_long})
+
+ assert "should be at most 72 character(s)" in errors_on(changeset).password
+ end
+
+ test "validates current password", %{user: user} do
+ {:error, changeset} =
+ UserManager.update_user_password(user, "invalid", %{password: valid_user_password()})
+
+ assert %{current_password: ["is not valid"]} = errors_on(changeset)
+ end
+
+ test "updates the password", %{user: user} do
+ {:ok, user} =
+ UserManager.update_user_password(user, valid_user_password(), %{
+ password: "new valid password"
+ })
+
+ assert is_nil(user.password)
+ assert UserManager.get_user_by_email_and_password(user.email, "new valid password")
+ end
+
+ test "deletes all tokens for the given user", %{user: user} do
+ _ = UserManager.generate_user_session_token(user)
+
+ {:ok, _} =
+ UserManager.update_user_password(user, valid_user_password(), %{
+ password: "new valid password"
+ })
+
+ refute Repo.get_by(UserToken, user_id: user.id)
+ end
+ end
+
+ describe "generate_user_session_token/1" do
+ setup do
+ %{user: user_fixture()}
+ end
+
+ test "generates a token", %{user: user} do
+ token = UserManager.generate_user_session_token(user)
+ assert user_token = Repo.get_by(UserToken, token: token)
+ assert user_token.context == "session"
+
+ # Creating the same token for another user should fail
+ assert_raise Ecto.ConstraintError, fn ->
+ Repo.insert!(%UserToken{
+ token: user_token.token,
+ user_id: user_fixture().id,
+ context: "session"
+ })
+ end
+ end
+ end
+
+ describe "get_user_by_session_token/1" do
+ setup do
+ user = user_fixture()
+ token = UserManager.generate_user_session_token(user)
+ %{user: user, token: token}
+ end
+
+ test "returns user by token", %{user: user, token: token} do
+ assert session_user = UserManager.get_user_by_session_token(token)
+ assert session_user.id == user.id
+ end
+
+ test "does not return user for invalid token" do
+ refute UserManager.get_user_by_session_token("oops")
+ end
+
+ test "does not return user for expired token", %{token: token} do
+ {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
+ refute UserManager.get_user_by_session_token(token)
+ end
+ end
+
+ describe "delete_session_token/1" do
+ test "deletes the token" do
+ user = user_fixture()
+ token = UserManager.generate_user_session_token(user)
+ assert UserManager.delete_session_token(token) == :ok
+ refute UserManager.get_user_by_session_token(token)
+ end
+ end
+
+ describe "deliver_user_confirmation_instructions/2" do
+ setup do
+ %{user: user_fixture()}
+ end
+
+ test "sends token through notification", %{user: user} do
+ token =
+ extract_user_token(fn url ->
+ UserManager.deliver_user_confirmation_instructions(user, url)
+ end)
+
+ {:ok, token} = Base.url_decode64(token, padding: false)
+ assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
+ assert user_token.user_id == user.id
+ assert user_token.sent_to == user.email
+ assert user_token.context == "confirm"
+ end
+ end
+
+ describe "confirm_user/1" do
+ setup do
+ user = user_fixture()
+
+ token =
+ extract_user_token(fn url ->
+ UserManager.deliver_user_confirmation_instructions(user, url)
+ end)
+
+ %{user: user, token: token}
+ end
+
+ test "confirms the email with a valid token", %{user: user, token: token} do
+ assert {:ok, confirmed_user} = UserManager.confirm_user(token)
+ assert confirmed_user.confirmed_at
+ assert confirmed_user.confirmed_at != user.confirmed_at
+ assert Repo.get!(User, user.id).confirmed_at
+ refute Repo.get_by(UserToken, user_id: user.id)
+ end
+
+ test "does not confirm with invalid token", %{user: user} do
+ assert UserManager.confirm_user("oops") == :error
+ refute Repo.get!(User, user.id).confirmed_at
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+
+ test "does not confirm email if token expired", %{user: user, token: token} do
+ {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
+ assert UserManager.confirm_user(token) == :error
+ refute Repo.get!(User, user.id).confirmed_at
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+ end
+
+ describe "deliver_user_reset_password_instructions/2" do
+ setup do
+ %{user: user_fixture()}
+ end
+
+ test "sends token through notification", %{user: user} do
+ token =
+ extract_user_token(fn url ->
+ UserManager.deliver_user_reset_password_instructions(user, url)
+ end)
+
+ {:ok, token} = Base.url_decode64(token, padding: false)
+ assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
+ assert user_token.user_id == user.id
+ assert user_token.sent_to == user.email
+ assert user_token.context == "reset_password"
+ end
+ end
+
+ describe "get_user_by_reset_password_token/1" do
+ setup do
+ user = user_fixture()
+
+ token =
+ extract_user_token(fn url ->
+ UserManager.deliver_user_reset_password_instructions(user, url)
+ end)
+
+ %{user: user, token: token}
+ end
+
+ test "returns the user with valid token", %{user: %{id: id}, token: token} do
+ assert %User{id: ^id} = UserManager.get_user_by_reset_password_token(token)
+ assert Repo.get_by(UserToken, user_id: id)
+ end
+
+ test "does not return the user with invalid token", %{user: user} do
+ refute UserManager.get_user_by_reset_password_token("oops")
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+
+ test "does not return the user if token expired", %{user: user, token: token} do
+ {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
+ refute UserManager.get_user_by_reset_password_token(token)
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+ end
+
+ describe "reset_user_password/2" do
+ setup do
+ %{user: user_fixture()}
+ end
+
+ test "validates password", %{user: user} do
+ {:error, changeset} =
+ UserManager.reset_user_password(user, %{
+ password: "not valid",
+ password_confirmation: "another"
+ })
+
+ assert %{
+ password: ["should be at least 12 character(s)"],
+ password_confirmation: ["does not match password"]
+ } = errors_on(changeset)
+ end
+
+ test "validates maximum values for password for security", %{user: user} do
+ too_long = String.duplicate("db", 100)
+ {:error, changeset} = UserManager.reset_user_password(user, %{password: too_long})
+ assert "should be at most 72 character(s)" in errors_on(changeset).password
+ end
+
+ test "updates the password", %{user: user} do
+ {:ok, updated_user} = UserManager.reset_user_password(user, %{password: "new valid password"})
+ assert is_nil(updated_user.password)
+ assert UserManager.get_user_by_email_and_password(user.email, "new valid password")
+ end
+
+ test "deletes all tokens for the given user", %{user: user} do
+ _ = UserManager.generate_user_session_token(user)
+ {:ok, _} = UserManager.reset_user_password(user, %{password: "new valid password"})
+ refute Repo.get_by(UserToken, user_id: user.id)
+ end
+ end
+
+ describe "inspect/2" do
+ test "does not include password" do
+ refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
+ end
+ end
+end
diff --git a/test/kmxcrm_web/controllers/user_auth_test.exs b/test/kmxcrm_web/controllers/user_auth_test.exs
new file mode 100644
index 0000000..180d0de
--- /dev/null
+++ b/test/kmxcrm_web/controllers/user_auth_test.exs
@@ -0,0 +1,170 @@
+defmodule KmxcrmWeb.UserAuthTest do
+ use KmxcrmWeb.ConnCase, async: true
+
+ alias Kmxcrm.UserManager
+ alias KmxcrmWeb.UserAuth
+ import Kmxcrm.UserManagerFixtures
+
+ @remember_me_cookie "_kmxcrm_web_user_remember_me"
+
+ setup %{conn: conn} do
+ conn =
+ conn
+ |> Map.replace!(:secret_key_base, KmxcrmWeb.Endpoint.config(:secret_key_base))
+ |> init_test_session(%{})
+
+ %{user: user_fixture(), conn: conn}
+ end
+
+ describe "log_in_user/3" do
+ test "stores the user token in the session", %{conn: conn, user: user} do
+ conn = UserAuth.log_in_user(conn, user)
+ assert token = get_session(conn, :user_token)
+ assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}"
+ assert redirected_to(conn) == "/"
+ assert UserManager.get_user_by_session_token(token)
+ end
+
+ test "clears everything previously stored in the session", %{conn: conn, user: user} do
+ conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user)
+ refute get_session(conn, :to_be_removed)
+ end
+
+ test "redirects to the configured path", %{conn: conn, user: user} do
+ conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user)
+ assert redirected_to(conn) == "/hello"
+ end
+
+ test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do
+ conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
+ assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie]
+
+ assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
+ assert signed_token != get_session(conn, :user_token)
+ assert max_age == 5_184_000
+ end
+ end
+
+ describe "logout_user/1" do
+ test "erases session and cookies", %{conn: conn, user: user} do
+ user_token = UserManager.generate_user_session_token(user)
+
+ conn =
+ conn
+ |> put_session(:user_token, user_token)
+ |> put_req_cookie(@remember_me_cookie, user_token)
+ |> fetch_cookies()
+ |> UserAuth.log_out_user()
+
+ refute get_session(conn, :user_token)
+ refute conn.cookies[@remember_me_cookie]
+ assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
+ assert redirected_to(conn) == "/"
+ refute UserManager.get_user_by_session_token(user_token)
+ end
+
+ test "broadcasts to the given live_socket_id", %{conn: conn} do
+ live_socket_id = "users_sessions:abcdef-token"
+ KmxcrmWeb.Endpoint.subscribe(live_socket_id)
+
+ conn
+ |> put_session(:live_socket_id, live_socket_id)
+ |> UserAuth.log_out_user()
+
+ assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id}
+ end
+
+ test "works even if user is already logged out", %{conn: conn} do
+ conn = conn |> fetch_cookies() |> UserAuth.log_out_user()
+ refute get_session(conn, :user_token)
+ assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
+ assert redirected_to(conn) == "/"
+ end
+ end
+
+ describe "fetch_current_user/2" do
+ test "authenticates user from session", %{conn: conn, user: user} do
+ user_token = UserManager.generate_user_session_token(user)
+ conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([])
+ assert conn.assigns.current_user.id == user.id
+ end
+
+ test "authenticates user from cookies", %{conn: conn, user: user} do
+ logged_in_conn =
+ conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
+
+ user_token = logged_in_conn.cookies[@remember_me_cookie]
+ %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
+
+ conn =
+ conn
+ |> put_req_cookie(@remember_me_cookie, signed_token)
+ |> UserAuth.fetch_current_user([])
+
+ assert get_session(conn, :user_token) == user_token
+ assert conn.assigns.current_user.id == user.id
+ end
+
+ test "does not authenticate if data is missing", %{conn: conn, user: user} do
+ _ = UserManager.generate_user_session_token(user)
+ conn = UserAuth.fetch_current_user(conn, [])
+ refute get_session(conn, :user_token)
+ refute conn.assigns.current_user
+ end
+ end
+
+ describe "redirect_if_user_is_authenticated/2" do
+ test "redirects if user is authenticated", %{conn: conn, user: user} do
+ conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([])
+ assert conn.halted
+ assert redirected_to(conn) == "/"
+ end
+
+ test "does not redirect if user is not authenticated", %{conn: conn} do
+ conn = UserAuth.redirect_if_user_is_authenticated(conn, [])
+ refute conn.halted
+ refute conn.status
+ end
+ end
+
+ describe "require_authenticated_user/2" do
+ test "redirects if user is not authenticated", %{conn: conn} do
+ conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([])
+ assert conn.halted
+ assert redirected_to(conn) == Routes.user_session_path(conn, :new)
+ assert get_flash(conn, :error) == "You must log in to access this page."
+ end
+
+ test "stores the path to redirect to on GET", %{conn: conn} do
+ halted_conn =
+ %{conn | path_info: ["foo"], query_string: ""}
+ |> fetch_flash()
+ |> UserAuth.require_authenticated_user([])
+
+ assert halted_conn.halted
+ assert get_session(halted_conn, :user_return_to) == "/foo"
+
+ halted_conn =
+ %{conn | path_info: ["foo"], query_string: "bar=baz"}
+ |> fetch_flash()
+ |> UserAuth.require_authenticated_user([])
+
+ assert halted_conn.halted
+ assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz"
+
+ halted_conn =
+ %{conn | path_info: ["foo"], query_string: "bar", method: "POST"}
+ |> fetch_flash()
+ |> UserAuth.require_authenticated_user([])
+
+ assert halted_conn.halted
+ refute get_session(halted_conn, :user_return_to)
+ end
+
+ test "does not redirect if user is authenticated", %{conn: conn, user: user} do
+ conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([])
+ refute conn.halted
+ refute conn.status
+ end
+ end
+end
diff --git a/test/kmxcrm_web/controllers/user_confirmation_controller_test.exs b/test/kmxcrm_web/controllers/user_confirmation_controller_test.exs
new file mode 100644
index 0000000..5b0ef1a
--- /dev/null
+++ b/test/kmxcrm_web/controllers/user_confirmation_controller_test.exs
@@ -0,0 +1,105 @@
+defmodule KmxcrmWeb.UserConfirmationControllerTest do
+ use KmxcrmWeb.ConnCase, async: true
+
+ alias Kmxcrm.UserManager
+ alias Kmxcrm.Repo
+ import Kmxcrm.UserManagerFixtures
+
+ setup do
+ %{user: user_fixture()}
+ end
+
+ describe "GET /users/confirm" do
+ test "renders the resend confirmation page", %{conn: conn} do
+ conn = get(conn, Routes.user_confirmation_path(conn, :new))
+ response = html_response(conn, 200)
+ assert response =~ "<h1>Resend confirmation instructions</h1>"
+ end
+ end
+
+ describe "POST /users/confirm" do
+ @tag :capture_log
+ test "sends a new confirmation token", %{conn: conn, user: user} do
+ conn =
+ post(conn, Routes.user_confirmation_path(conn, :create), %{
+ "user" => %{"email" => user.email}
+ })
+
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :info) =~ "If your email is in our system"
+ assert Repo.get_by!(UserManager.UserToken, user_id: user.id).context == "confirm"
+ end
+
+ test "does not send confirmation token if User is confirmed", %{conn: conn, user: user} do
+ Repo.update!(UserManager.User.confirm_changeset(user))
+
+ conn =
+ post(conn, Routes.user_confirmation_path(conn, :create), %{
+ "user" => %{"email" => user.email}
+ })
+
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :info) =~ "If your email is in our system"
+ refute Repo.get_by(UserManager.UserToken, user_id: user.id)
+ end
+
+ test "does not send confirmation token if email is invalid", %{conn: conn} do
+ conn =
+ post(conn, Routes.user_confirmation_path(conn, :create), %{
+ "user" => %{"email" => "unknown@example.com"}
+ })
+
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :info) =~ "If your email is in our system"
+ assert Repo.all(UserManager.UserToken) == []
+ end
+ end
+
+ describe "GET /users/confirm/:token" do
+ test "renders the confirmation page", %{conn: conn} do
+ conn = get(conn, Routes.user_confirmation_path(conn, :edit, "some-token"))
+ response = html_response(conn, 200)
+ assert response =~ "<h1>Confirm account</h1>"
+
+ form_action = Routes.user_confirmation_path(conn, :update, "some-token")
+ assert response =~ "action=\"#{form_action}\""
+ end
+ end
+
+ describe "POST /users/confirm/:token" do
+ test "confirms the given token once", %{conn: conn, user: user} do
+ token =
+ extract_user_token(fn url ->
+ UserManager.deliver_user_confirmation_instructions(user, url)
+ end)
+
+ conn = post(conn, Routes.user_confirmation_path(conn, :update, token))
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :info) =~ "User confirmed successfully"
+ assert UserManager.get_user!(user.id).confirmed_at
+ refute get_session(conn, :user_token)
+ assert Repo.all(UserManager.UserToken) == []
+
+ # When not logged in
+ conn = post(conn, Routes.user_confirmation_path(conn, :update, token))
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :error) =~ "User confirmation link is invalid or it has expired"
+
+ # When logged in
+ conn =
+ build_conn()
+ |> log_in_user(user)
+ |> post(Routes.user_confirmation_path(conn, :update, token))
+
+ assert redirected_to(conn) == "/"
+ refute get_flash(conn, :error)
+ end
+
+ test "does not confirm email with invalid token", %{conn: conn, user: user} do
+ conn = post(conn, Routes.user_confirmation_path(conn, :update, "oops"))
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :error) =~ "User confirmation link is invalid or it has expired"
+ refute UserManager.get_user!(user.id).confirmed_at
+ end
+ end
+end
diff --git a/test/kmxcrm_web/controllers/user_registration_controller_test.exs b/test/kmxcrm_web/controllers/user_registration_controller_test.exs
new file mode 100644
index 0000000..474f5b0
--- /dev/null
+++ b/test/kmxcrm_web/controllers/user_registration_controller_test.exs
@@ -0,0 +1,54 @@
+defmodule KmxcrmWeb.UserRegistrationControllerTest do
+ use KmxcrmWeb.ConnCase, async: true
+
+ import Kmxcrm.UserManagerFixtures
+
+ describe "GET /users/register" do
+ test "renders registration page", %{conn: conn} do
+ conn = get(conn, Routes.user_registration_path(conn, :new))
+ response = html_response(conn, 200)
+ assert response =~ "<h1>Register</h1>"
+ assert response =~ "Log in</a>"
+ assert response =~ "Register</a>"
+ end
+
+ test "redirects if already logged in", %{conn: conn} do
+ conn = conn |> log_in_user(user_fixture()) |> get(Routes.user_registration_path(conn, :new))
+ assert redirected_to(conn) == "/"
+ end
+ end
+
+ describe "POST /users/register" do
+ @tag :capture_log
+ test "creates account and logs the user in", %{conn: conn} do
+ email = unique_user_email()
+
+ conn =
+ post(conn, Routes.user_registration_path(conn, :create), %{
+ "user" => valid_user_attributes(email: email)
+ })
+
+ assert get_session(conn, :user_token)
+ assert redirected_to(conn) == "/"
+
+ # Now do a logged in request and assert on the menu
+ conn = get(conn, "/")
+ response = html_response(conn, 200)
+ assert response =~ email
+ assert response =~ "Settings</a>"
+ assert response =~ "Log out</a>"
+ end
+
+ test "render errors for invalid data", %{conn: conn} do
+ conn =
+ post(conn, Routes.user_registration_path(conn, :create), %{
+ "user" => %{"email" => "with spaces", "password" => "too short"}
+ })
+
+ response = html_response(conn, 200)
+ assert response =~ "<h1>Register</h1>"
+ assert response =~ "must have the @ sign and no spaces"
+ assert response =~ "should be at least 12 character"
+ end
+ end
+end
diff --git a/test/kmxcrm_web/controllers/user_reset_password_controller_test.exs b/test/kmxcrm_web/controllers/user_reset_password_controller_test.exs
new file mode 100644
index 0000000..a017db6
--- /dev/null
+++ b/test/kmxcrm_web/controllers/user_reset_password_controller_test.exs
@@ -0,0 +1,113 @@
+defmodule KmxcrmWeb.UserResetPasswordControllerTest do
+ use KmxcrmWeb.ConnCase, async: true
+
+ alias Kmxcrm.UserManager
+ alias Kmxcrm.Repo
+ import Kmxcrm.UserManagerFixtures
+
+ setup do
+ %{user: user_fixture()}
+ end
+
+ describe "GET /users/reset_password" do
+ test "renders the reset password page", %{conn: conn} do
+ conn = get(conn, Routes.user_reset_password_path(conn, :new))
+ response = html_response(conn, 200)
+ assert response =~ "<h1>Forgot your password?</h1>"
+ end
+ end
+
+ describe "POST /users/reset_password" do
+ @tag :capture_log
+ test "sends a new reset password token", %{conn: conn, user: user} do
+ conn =
+ post(conn, Routes.user_reset_password_path(conn, :create), %{
+ "user" => %{"email" => user.email}
+ })
+
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :info) =~ "If your email is in our system"
+ assert Repo.get_by!(UserManager.UserToken, user_id: user.id).context == "reset_password"
+ end
+
+ test "does not send reset password token if email is invalid", %{conn: conn} do
+ conn =
+ post(conn, Routes.user_reset_password_path(conn, :create), %{
+ "user" => %{"email" => "unknown@example.com"}
+ })
+
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :info) =~ "If your email is in our system"
+ assert Repo.all(UserManager.UserToken) == []
+ end
+ end
+
+ describe "GET /users/reset_password/:token" do
+ setup %{user: user} do
+ token =
+ extract_user_token(fn url ->
+ UserManager.deliver_user_reset_password_instructions(user, url)
+ end)
+
+ %{token: token}
+ end
+
+ test "renders reset password", %{conn: conn, token: token} do
+ conn = get(conn, Routes.user_reset_password_path(conn, :edit, token))
+ assert html_response(conn, 200) =~ "<h1>Reset password</h1>"
+ end
+
+ test "does not render reset password with invalid token", %{conn: conn} do
+ conn = get(conn, Routes.user_reset_password_path(conn, :edit, "oops"))
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired"
+ end
+ end
+
+ describe "PUT /users/reset_password/:token" do
+ setup %{user: user} do
+ token =
+ extract_user_token(fn url ->
+ UserManager.deliver_user_reset_password_instructions(user, url)
+ end)
+
+ %{token: token}
+ end
+
+ test "resets password once", %{conn: conn, user: user, token: token} do
+ conn =
+ put(conn, Routes.user_reset_password_path(conn, :update, token), %{
+ "user" => %{
+ "password" => "new valid password",
+ "password_confirmation" => "new valid password"
+ }
+ })
+
+ assert redirected_to(conn) == Routes.user_session_path(conn, :new)
+ refute get_session(conn, :user_token)
+ assert get_flash(conn, :info) =~ "Password reset successfully"
+ assert UserManager.get_user_by_email_and_password(user.email, "new valid password")
+ end
+
+ test "does not reset password on invalid data", %{conn: conn, token: token} do
+ conn =
+ put(conn, Routes.user_reset_password_path(conn, :update, token), %{
+ "user" => %{
+ "password" => "too short",
+ "password_confirmation" => "does not match"
+ }
+ })
+
+ response = html_response(conn, 200)
+ assert response =~ "<h1>Reset password</h1>"
+ assert response =~ "should be at least 12 character(s)"
+ assert response =~ "does not match password"
+ end
+
+ test "does not reset password with invalid token", %{conn: conn} do
+ conn = put(conn, Routes.user_reset_password_path(conn, :update, "oops"))
+ assert redirected_to(conn) == "/"
+ assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired"
+ end
+ end
+end
diff --git a/test/kmxcrm_web/controllers/user_session_controller_test.exs b/test/kmxcrm_web/controllers/user_session_controller_test.exs
new file mode 100644
index 0000000..9e40bfe
--- /dev/null
+++ b/test/kmxcrm_web/controllers/user_session_controller_test.exs
@@ -0,0 +1,98 @@
+defmodule KmxcrmWeb.UserSessionControllerTest do
+ use KmxcrmWeb.ConnCase, async: true
+
+ import Kmxcrm.UserManagerFixtures
+
+ setup do
+ %{user: user_fixture()}
+ end
+
+ describe "GET /users/log_in" do
+ test "renders log in page", %{conn: conn} do
+ conn = get(conn, Routes.user_session_path(conn, :new))
+ response = html_response(conn, 200)
+ assert response =~ "<h1>Log in</h1>"
+ assert response =~ "Register</a>"
+ assert response =~ "Forgot your password?</a>"
+ end
+
+ test "redirects if already logged in", %{conn: conn, user: user} do
+ conn = conn |> log_in_user(user) |> get(Routes.user_session_path(conn, :new))
+ assert redirected_to(conn) == "/"
+ end
+ end
+
+ describe "POST /users/log_in" do
+ test "logs the user in", %{conn: conn, user: user} do
+ conn =
+ post(conn, Routes.user_session_path(conn, :create), %{
+ "user" => %{"email" => user.email, "password" => valid_user_password()}
+ })
+
+ assert get_session(conn, :user_token)
+ assert redirected_to(conn) == "/"
+
+ # Now do a logged in request and assert on the menu
+ conn = get(conn, "/")
+ response = html_response(conn, 200)
+ assert response =~ user.email
+ assert response =~ "Settings</a>"
+ assert response =~ "Log out</a>"
+ end
+
+ test "logs the user in with remember me", %{conn: conn, user: user} do
+ conn =
+ post(conn, Routes.user_session_path(conn, :create), %{
+ "user" => %{
+ "email" => user.email,
+ "password" => valid_user_password(),
+ "remember_me" => "true"
+ }
+ })
+
+ assert conn.resp_cookies["_kmxcrm_web_user_remember_me"]
+ assert redirected_to(conn) == "/"
+ end
+
+ test "logs the user in with return to", %{conn: conn, user: user} do
+ conn =
+ conn
+ |> init_test_session(user_return_to: "/foo/bar")
+ |> post(Routes.user_session_path(conn, :create), %{
+ "user" => %{
+ "email" => user.email,
+ "password" => valid_user_password()
+ }
+ })
+
+ assert redirected_to(conn) == "/foo/bar"
+ end
+
+ test "emits error message with invalid credentials", %{conn: conn, user: user} do
+ conn =
+ post(conn, Routes.user_session_path(conn, :create), %{
+ "user" => %{"email" => user.email, "password" => "invalid_password"}
+ })
+
+ response = html_response(conn, 200)
+ assert response =~ "<h1>Log in</h1>"
+ assert response =~ "Invalid email or password"
+ end
+ end
+
+ describe "DELETE /users/log_out" do
+ test "logs the user out", %{conn: conn, user: user} do
+ conn = conn |> log_in_user(user) |> delete(Routes.user_session_path(conn, :delete))
+ assert redirected_to(conn) == "/"
+ refute get_session(conn, :user_token)
+ assert get_flash(conn, :info) =~ "Logged out successfully"
+ end
+
+ test "succeeds even if the user is not logged in", %{conn: conn} do
+ conn = delete(conn, Routes.user_session_path(conn, :delete))
+ assert redirected_to(conn) == "/"
+ refute get_session(conn, :user_token)
+ assert get_flash(conn, :info) =~ "Logged out successfully"
+ end
+ end
+end
diff --git a/test/kmxcrm_web/controllers/user_settings_controller_test.exs b/test/kmxcrm_web/controllers/user_settings_controller_test.exs
new file mode 100644
index 0000000..bda3872
--- /dev/null
+++ b/test/kmxcrm_web/controllers/user_settings_controller_test.exs
@@ -0,0 +1,129 @@
+defmodule KmxcrmWeb.UserSettingsControllerTest do
+ use KmxcrmWeb.ConnCase, async: true
+
+ alias Kmxcrm.UserManager
+ import Kmxcrm.UserManagerFixtures
+
+ setup :register_and_log_in_user
+
+ describe "GET /users/settings" do
+ test "renders settings page", %{conn: conn} do
+ conn = get(conn, Routes.user_settings_path(conn, :edit))
+ response = html_response(conn, 200)
+ assert response =~ "<h1>Settings</h1>"
+ end
+
+ test "redirects if user is not logged in" do
+ conn = build_conn()
+ conn = get(conn, Routes.user_settings_path(conn, :edit))
+ assert redirected_to(conn) == Routes.user_session_path(conn, :new)
+ end
+ end
+
+ describe "PUT /users/settings (change password form)" do
+ test "updates the user password and resets tokens", %{conn: conn, user: user} do
+ new_password_conn =
+ put(conn, Routes.user_settings_path(conn, :update), %{
+ "action" => "update_password",
+ "current_password" => valid_user_password(),
+ "user" => %{
+ "password" => "new valid password",
+ "password_confirmation" => "new valid password"
+ }
+ })
+
+ assert redirected_to(new_password_conn) == Routes.user_settings_path(conn, :edit)
+ assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token)
+ assert get_flash(new_password_conn, :info) =~ "Password updated successfully"
+ assert UserManager.get_user_by_email_and_password(user.email, "new valid password")
+ end
+
+ test "does not update password on invalid data", %{conn: conn} do
+ old_password_conn =
+ put(conn, Routes.user_settings_path(conn, :update), %{
+ "action" => "update_password",
+ "current_password" => "invalid",
+ "user" => %{
+ "password" => "too short",
+ "password_confirmation" => "does not match"
+ }
+ })
+
+ response = html_response(old_password_conn, 200)
+ assert response =~ "<h1>Settings</h1>"
+ assert response =~ "should be at least 12 character(s)"
+ assert response =~ "does not match password"
+ assert response =~ "is not valid"
+
+ assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token)
+ end
+ end
+
+ describe "PUT /users/settings (change email form)" do
+ @tag :capture_log
+ test "updates the user email", %{conn: conn, user: user} do
+ conn =
+ put(conn, Routes.user_settings_path(conn, :update), %{
+ "action" => "update_email",
+ "current_password" => valid_user_password(),
+ "user" => %{"email" => unique_user_email()}
+ })
+
+ assert redirected_to(conn) == Routes.user_settings_path(conn, :edit)
+ assert get_flash(conn, :info) =~ "A link to confirm your email"
+ assert UserManager.get_user_by_email(user.email)
+ end
+
+ test "does not update email on invalid data", %{conn: conn} do
+ conn =
+ put(conn, Routes.user_settings_path(conn, :update), %{
+ "action" => "update_email",
+ "current_password" => "invalid",
+ "user" => %{"email" => "with spaces"}
+ })
+
+ response = html_response(conn, 200)
+ assert response =~ "<h1>Settings</h1>"
+ assert response =~ "must have the @ sign and no spaces"
+ assert response =~ "is not valid"
+ end
+ end
+
+ describe "GET /users/settings/confirm_email/:token" do
+ setup %{user: user} do
+ email = unique_user_email()
+
+ token =
+ extract_user_token(fn url ->
+ UserManager.deliver_update_email_instructions(%{user | email: email}, user.email, url)
+ end)
+
+ %{token: token, email: email}
+ end
+
+ test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do
+ conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token))
+ assert redirected_to(conn) == Routes.user_settings_path(conn, :edit)
+ assert get_flash(conn, :info) =~ "Email changed successfully"
+ refute UserManager.get_user_by_email(user.email)
+ assert UserManager.get_user_by_email(email)
+
+ conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token))
+ assert redirected_to(conn) == Routes.user_settings_path(conn, :edit)
+ assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired"
+ end
+
+ test "does not update email with invalid token", %{conn: conn, user: user} do
+ conn = get(conn, Routes.user_settings_path(conn, :confirm_email, "oops"))
+ assert redirected_to(conn) == Routes.user_settings_path(conn, :edit)
+ assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired"
+ assert UserManager.get_user_by_email(user.email)
+ end
+
+ test "redirects if user is not logged in", %{token: token} do
+ conn = build_conn()
+ conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token))
+ assert redirected_to(conn) == Routes.user_session_path(conn, :new)
+ end
+ end
+end
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
index 9922788..fb4107c 100644
--- a/test/support/conn_case.ex
+++ b/test/support/conn_case.ex
@@ -36,4 +36,30 @@ defmodule KmxcrmWeb.ConnCase do
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
+
+ @doc """
+ Setup helper that registers and logs in users.
+
+ setup :register_and_log_in_user
+
+ It stores an updated connection and a registered user in the
+ test context.
+ """
+ def register_and_log_in_user(%{conn: conn}) do
+ user = Kmxcrm.UserManagerFixtures.user_fixture()
+ %{conn: log_in_user(conn, user), user: user}
+ end
+
+ @doc """
+ Logs the given `user` into the `conn`.
+
+ It returns an updated `conn`.
+ """
+ def log_in_user(conn, user) do
+ token = Kmxcrm.UserManager.generate_user_session_token(user)
+
+ conn
+ |> Phoenix.ConnTest.init_test_session(%{})
+ |> Plug.Conn.put_session(:user_token, token)
+ end
end
diff --git a/test/support/fixtures/user_manager_fixtures.ex b/test/support/fixtures/user_manager_fixtures.ex
new file mode 100644
index 0000000..298f827
--- /dev/null
+++ b/test/support/fixtures/user_manager_fixtures.ex
@@ -0,0 +1,31 @@
+defmodule Kmxcrm.UserManagerFixtures do
+ @moduledoc """
+ This module defines test helpers for creating
+ entities via the `Kmxcrm.UserManager` context.
+ """
+
+ def unique_user_email, do: "user#{System.unique_integer()}@example.com"
+ def valid_user_password, do: "hello world!"
+
+ def valid_user_attributes(attrs \\ %{}) do
+ Enum.into(attrs, %{
+ email: unique_user_email(),
+ password: valid_user_password()
+ })
+ end
+
+ def user_fixture(attrs \\ %{}) do
+ {:ok, user} =
+ attrs
+ |> valid_user_attributes()
+ |> Kmxcrm.UserManager.register_user()
+
+ user
+ end
+
+ def extract_user_token(fun) do
+ {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]")
+ [_, token | _] = String.split(captured_email.text_body, "[TOKEN]")
+ token
+ end
+end