defmodule Haytni.RegisterablePlugin do
@default_registration_path "/registration"
@registration_path_key :registration_path
@new_registration_path_key :new_registration_path
@edit_registration_path_key :edit_registration_path
@default_email_regexp ~R/^[^@\s]+@[^@\s]+$/
@default_case_insensitive_keys ~W[email]a
@default_strip_whitespace_keys ~W[email]a
@default_registration_disabled? false
@default_email_index_name nil
@default_with_delete false
@default_logout_on_deletion true
@moduledoc """
This plugin allows the user to register and edit their account.
Change *your_app*/lib/*your_app*/user.ex to add two functions: `create_registration_changeset` and `update_registration_changeset`.
Example:
defmodule YourApp.User do
require YourApp.Haytni
@derive {Inspect, except: [:password]}
schema "users" do
YourApp.Haytni.fields()
# ...
end
# ...
# called when a user try to register himself
def create_registration_changeset(%__MODULE__{} = struct, params) do
struct
|> cast(params, ~W[email password]a) # add any field you'll may need (but only fields that user is allowed to define!)
|> YourApp.Haytni.validate_password()
# add any custom validation here
|> YourApp.Haytni.validate_create_registration()
end
# called when a user try to edit its own account (logic is completely different)
def update_registration_changeset(%__MODULE__{} = struct, params) do
struct
|> cast(params, ~W[]a) # add any field in the list you'll may need (but only fields that user is allowed to redefine!)
# add any custom validation here
|> YourApp.Haytni.validate_update_registration()
end
# ...
end
Fields: none
Configuration:
* `email_regexp` (default: `#{inspect(@default_email_regexp)}`): the `Regex` that an email at registration or profile edition needs to match
* `case_insensitive_keys` (default: `#{inspect(@default_case_insensitive_keys)}`): list of fields to automatically downcase on registration. May be unneeded depending on your
database (eg: *citext* columns for PostgreSQL or columns with a collation suffixed by "\_ci" for MySQL). You **SHOULD NOT** include the
password field here!
* `strip_whitespace_keys` (default: `#{inspect(@default_strip_whitespace_keys)}`): list of fields to automatically strip from whitespaces. You **SHOULD NEITHER** include the
password field here, to exclude any involuntary mistake, you should instead consider using a custom validation.
* `email_index_name` (default: `#{inspect(@default_email_index_name)}`, translated to `<source>_email_index` by `Ecto.Changeset.unique_constraint/3`): the name of the unique
index/constraint on email field
* `registration_disabled?` (default: `#{inspect(@default_registration_disabled?)}`): disable any new registration (existing users are still able to login, edit their profile, ...)
* `with_delete` (default: `#{inspect(@default_with_delete)}`): `true` to allow users to delete their own account (this mainly (en|dis)ables the route to reach the delete action of
the `HaytniWeb.Registerable.RegistrationController` controller)
* `logout_on_deletion` (default: `#{inspect(@default_logout_on_deletion)}`): when `true` the user is also logged out. Set it to `false` if the user should keep its session active
and navigating as this user
stack #{inspect(__MODULE__)},
registration_disabled?: #{inspect(@default_registration_disabled?)},
strip_whitespace_keys: #{inspect(@default_strip_whitespace_keys)},
case_insensitive_keys: #{inspect(@default_case_insensitive_keys)},
email_regexp: #{inspect(@default_email_regexp)},
email_index_name: #{inspect(@default_email_index_name)}
Routes:
* `haytni_<scope>_registration_path` (actions: new/create, edit/update): paths used by the generated routes for this plugin can be customized on YourAppWeb.Haytni.routes/1 call in your router by the following options:
- #{@registration_path_key} (default: `#{inspect(@default_registration_path)}`): the base/default path for all the actions
- #{@new_registration_path_key} (default: `registration_path <> "/new"`): define this option to define a specific path for the *new* action (sign up/account creation)
- #{@edit_registration_path_key} (default: `registration_path <> "/edit"`): same for *edit* action (profile edition)
"""
require Haytni.Gettext
import Haytni.Helpers
defmodule Config do
defstruct registration_disabled?: false,
strip_whitespace_keys: ~W[email]a,
case_insensitive_keys: ~W[email]a,
email_regexp: ~R/^[^@\s]+@[^@\s]+$/,
email_index_name: nil,
with_delete: false,
logout_on_deletion: true
@typep index_name :: atom | String.t | nil
@type t :: %__MODULE__{
email_regexp: Regex.t,
email_index_name: index_name,
strip_whitespace_keys: [atom],
case_insensitive_keys: [atom],
with_delete: boolean,
logout_on_deletion: boolean,
}
end
use Haytni.Plugin
@impl Haytni.Plugin
def build_config(options \\ %{}) do
%Haytni.RegisterablePlugin.Config{}
|> Haytni.Helpers.merge_config(options)
end
@impl Haytni.Plugin
def files_to_install(_base_path, web_path, scope, _timestamp) do
[
{:eex, "views/registration_view.ex", Path.join([web_path, "views", "haytni", scope, "registration_view.ex"])},
{:eex, "templates/registration/new.html.heex", Path.join([web_path, "templates", "haytni", scope, "registration", "new.html.heex"])},
{:eex, "templates/registration/edit.html.heex", Path.join([web_path, "templates", "haytni", scope, "registration", "edit.html.heex"])},
]
end
@impl Haytni.Plugin
def routes(config = %Config{}, prefix_name, options) do
registration_prefix_name = :"#{prefix_name}_registration"
registration_path = Keyword.get(options, @registration_path_key, @default_registration_path)
new_registration_path = Keyword.get(options, @new_registration_path_key, registration_path <> "/new")
edit_registration_path = Keyword.get(options, @edit_registration_path_key, registration_path <> "/edit")
delete_registration_path = config.with_delete && Keyword.get(options, :delete_registration_path, registration_path)
quote bind_quoted: [
registration_prefix_name: registration_prefix_name,
registration_path: registration_path,
new_registration_path: new_registration_path,
edit_registration_path: edit_registration_path,
delete_registration_path: delete_registration_path
] do
#resources "/registration", HaytniWeb.Registerable.RegistrationController, singleton: true, only: ~W[new create edit update]a, as: registration_prefix_name
get new_registration_path, HaytniWeb.Registerable.RegistrationController, :new, as: registration_prefix_name
post registration_path, HaytniWeb.Registerable.RegistrationController, :create, as: registration_prefix_name
get edit_registration_path, HaytniWeb.Registerable.RegistrationController, :edit, as: registration_prefix_name
put registration_path, HaytniWeb.Registerable.RegistrationController, :update, as: registration_prefix_name
patch registration_path, HaytniWeb.Registerable.RegistrationController, :update, as: registration_prefix_name
if delete_registration_path do
delete delete_registration_path, HaytniWeb.Registerable.RegistrationController, :delete, as: registration_prefix_name
end
end
end
defp validate_email(changeset = %Ecto.Changeset{}, module, config = %Config{}) do
changeset
|> Ecto.Changeset.validate_required([:email])
|> Ecto.Changeset.unsafe_validate_unique(:email, module.repo())
|> Ecto.Changeset.validate_format(:email, config.email_regexp)
|> Ecto.Changeset.unique_constraint(:email, name: config.email_index_name)
end
defp base_validate_password(changeset = %Ecto.Changeset{}) do
changeset
|> Ecto.Changeset.validate_required([:password])
|> Ecto.Changeset.validate_confirmation(:password, required: true)
end
@doc ~S"""
The translated string to display when user's current password is incorrect
"""
@spec invalid_current_password_message() :: String.t
def invalid_current_password_message do
Haytni.Gettext.dgettext("haytni", "is invalid")
end
@doc ~S"""
The translated string to display when email hasn't changed
"""
@spec has_not_changed_message() :: String.t
def has_not_changed_message do
Haytni.Gettext.dgettext("haytni", "has not changed")
end
defp validate_current_password(changeset = %Ecto.Changeset{}, password, module) do
config = module.fetch_config(Haytni.AuthenticablePlugin)
if Haytni.AuthenticablePlugin.valid_password?(changeset.data, password, config) do
changeset
else
Ecto.Changeset.add_error(changeset, :current_password, invalid_current_password_message())
end
end
@impl Haytni.Plugin
def validate_create_registration(changeset = %Ecto.Changeset{}, module, config) do
changeset
# <normalization>
|> strip_whitespace_changes(config)
|> case_insensitive_changes(config)
# </normalization>
|> Ecto.Changeset.validate_confirmation(:email, required: true)
|> validate_email(module, config)
|> base_validate_password()
end
@spec validate_change(changeset :: Ecto.Changeset.t, field :: atom) :: Ecto.Changeset.t
defp validate_change(changeset = %Ecto.Changeset{}, field)
when is_atom(field)
do
if true do
# NOTE: we can't distinguish an empty value to an unchanged value since both generates no changes
# to "leverage" it, we first check the field doesn't already have an error associated to it
if is_nil(changeset.errors[field]) and :error == Ecto.Changeset.fetch_change(changeset, field) do
Ecto.Changeset.add_error(changeset, field, has_not_changed_message())
else
changeset
end
else
changeset
|> Ecto.Changeset.fetch_change(field)
|> case do
{:ok, _value} ->
changeset
:error ->
Ecto.Changeset.add_error(changeset, field, has_not_changed_message())
end
end
end
@spec email_changeset(module :: module, config :: Config.t, user :: Haytni.user, attrs :: Haytni.params) :: Ecto.Changeset.t
defp email_changeset(module, config = %Config{}, user = %_{}, attrs = %{}) do
user
|> Ecto.Changeset.cast(attrs, [:email])
|> validate_email(module, config)
|> validate_change(:email)
end
@doc ~S"""
Returns an `%Ecto.Changeset{}` to modify its email address.
"""
@spec change_email(module :: module, config :: Config.t, user :: Haytni.user, attrs :: Haytni.params) :: Ecto.Changeset.t
def change_email(module, config = %Config{}, user = %_{}, attrs \\ %{}) do
email_changeset(module, config, user, attrs)
end
@doc ~S"""
Updates *user*'s email address if *current_password* matches *user*'s actual password.
"""
@spec update_email(module :: module, config :: Config.t, user :: Haytni.user, current_password :: String.t, attrs :: Haytni.params) :: Haytni.repo_nobang_operation(Haytni.user)
def update_email(module, config = %Config{}, user = %_{}, current_password, attrs = %{}) do
module
|> email_changeset(config, user, attrs)
|> validate_current_password(current_password, module)
|> Ecto.Changeset.apply_action(:update)
|> case do
{:ok, changeset_user} ->
module
|> Haytni.email_changed(user, changeset_user.email)
|> multi_to_regular_result(:user)
error = {:error, %Ecto.Changeset{}} ->
error
end
end
@spec password_changeset(module :: module, user :: Haytni.user, attrs :: Haytni.params) :: Ecto.Changeset.t
defp password_changeset(module, user = %_{}, attrs = %{}) do
changeset =
user
|> Ecto.Changeset.cast(attrs, [:password])
|> base_validate_password()
Haytni.validate_password(module, changeset)
end
@doc ~S"""
Returns an `%Ecto.Changeset{}` to modify its password.
"""
@spec change_password(module :: module, user :: Haytni.user, attrs :: Haytni.params) :: Ecto.Changeset.t
def change_password(module, user = %_{}, attrs \\ %{}) do
password_changeset(module, user, attrs)
end
defp maybe_hash_password(changeset = %Ecto.Changeset{valid?: true, changes: %{password: new_password}}, module) do
config = module.fetch_config(Haytni.AuthenticablePlugin)
changeset
|> Ecto.Changeset.put_change(:encrypted_password, Haytni.AuthenticablePlugin.hash_password(new_password, config))
|> Ecto.Changeset.delete_change(:password)
end
defp maybe_hash_password(changeset = %Ecto.Changeset{}, _module), do: changeset
@doc ~S"""
Updates *user*'s password if:
+ *current_password* matches *user*'s actual password
+ the new password meets the requirements against the active plugins implementing the `c:Haytni.Plugin.validate_password/3` callback
When the password is changed, the tokens associated to *user* are also deleted.
"""
@spec update_password(module :: module, user :: Haytni.user, current_password :: String.t, attrs :: Haytni.params) :: Haytni.repo_nobang_operation(Haytni.user)
def update_password(module, user = %_{}, current_password, attrs = %{}) do
changeset =
module
|> password_changeset(user, attrs)
|> validate_current_password(current_password, module)
|> maybe_hash_password(module)
changeset
|> Ecto.Changeset.apply_action(:update)
|> case do
{:ok, _changeset_user} ->
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Haytni.Token.delete_tokens_in_multi(:tokens, user, :all)
|> module.repo().transaction()
|> multi_to_regular_result(:user)
error = {:error, %Ecto.Changeset{}} ->
error
end
end
if false do
@doc ~S"""
The translated string to display when account deletion is disabled
"""
@spec account_deletion_disabled_message() :: String.t
def account_deletion_disabled_message do
Haytni.Gettext.dgettext("haytni", "account deletion is not available")
end
defp check_account_deletion(changeset, %Config{with_delete: true}), do: changeset
defp check_account_deletion(changeset, %Config{with_delete: false}) do
Haytni.Helpers.add_base_error(changeset, account_deletion_disabled_message())
end
end
@spec deletion_changeset(module :: module, config :: Config.t, user :: Haytni.user, attrs :: Haytni.params) :: Ecto.Changeset.t
defp deletion_changeset(_module, _config = %Config{}, user = %_{}, attrs = %{}) do
user
|> Ecto.Changeset.cast(attrs, [])
|> Ecto.Changeset.validate_acceptance(:accept_deletion)
#|> check_account_deletion(config)
end
@doc ~S"""
Returns an `%Ecto.Changeset{}` to delete its own account.
"""
@spec change_deletion(module :: module, config :: Config.t, user :: Haytni.user, attrs :: Haytni.params) :: Ecto.Changeset.t
def change_deletion(module, config = %Config{}, user = %_{}, attrs \\ %{}) do
deletion_changeset(module, config, user, attrs)
end
@doc ~S"""
Triggers *user*'s account deletion, by calling `Haytni.delete_user/2`, if *current_password* matches *user*'s password.
Returns the result of `Haytni.delete_user/2` or `{:error, :validation_failed, %Ecto.Changeset{}, %{}}` if *current_password* is incorrect
and/or user has not accepted the terms
"""
@spec delete_account(module :: module, config :: Config.t, user :: Haytni.user, current_password :: String.t, attrs :: Haytni.params) :: Haytni.multi_result
def delete_account(module, config = %Config{}, user = %_{}, current_password, attrs = %{}) do
changeset =
module
|> deletion_changeset(config, user, attrs)
|> validate_current_password(current_password, module)
changeset
|> Ecto.Changeset.apply_action(:delete)
|> case do
{:ok, _} ->
Haytni.delete_user(module, user)
{:error, changeset = %Ecto.Changeset{}} ->
{:error, :validation_failed, changeset, %{}}
end
end
if false do
@doc ~S"""
Apply a function/1 to the given fields of a changeset
"""
end
@spec apply_to_fields(fields :: [atom], changeset :: Ecto.Changeset.t, fun :: (any -> any)) :: Ecto.Changeset.t
defp apply_to_fields(fields, changeset = %Ecto.Changeset{}, fun) do
fields
|> Enum.reduce(changeset, &Ecto.Changeset.update_change(&2, &1, fun))
end
@doc ~S"""
Trim values of a changeset to keys configured as *strip_whitespace_keys*
"""
@spec strip_whitespace_changes(changeset :: Ecto.Changeset.t, config :: Config.t) :: Ecto.Changeset.t
def strip_whitespace_changes(changeset = %Ecto.Changeset{}, config) do
config.strip_whitespace_keys
|> apply_to_fields(changeset, &String.trim/1)
end
@doc ~S"""
Downcase values of a changeset to keys configured as *case_insensitive_keys*
"""
@spec case_insensitive_changes(changeset :: Ecto.Changeset.t, config :: Config.t) :: Ecto.Changeset.t
def case_insensitive_changes(changeset = %Ecto.Changeset{}, config) do
config.case_insensitive_keys
|> apply_to_fields(changeset, &String.downcase/1)
end
end