Extract User from Actors context

Mobilizon.Actors.User -> Mobilizon.Users.User
Also Mobilizon.Actors.Service now become Mobilizon.User.Service
And Mobilizon.Users and Mobilizon.UsersTest is introduced.

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2019-03-05 17:23:05 +01:00
parent 625d0d37d5
commit c689406114
22 changed files with 446 additions and 421 deletions

View File

@@ -0,0 +1,45 @@
defmodule Mobilizon.Users.Service.Activation do
@moduledoc false
alias Mobilizon.{Mailer, Users}
alias Mobilizon.Users.User
alias Mobilizon.Email.User, as: UserEmail
alias Mobilizon.Users.Service.Tools
require Logger
@doc false
def check_confirmation_token(token) when is_binary(token) do
with %User{} = user <- Users.get_user_by_activation_token(token),
{:ok, %User{} = user} <-
Users.update_user(user, %{
"confirmed_at" => DateTime.utc_now() |> DateTime.truncate(:second),
"confirmation_sent_at" => nil,
"confirmation_token" => nil
}) do
Logger.info("User #{user.email} has been confirmed")
{:ok, user}
else
_err ->
{:error, :invalid_token}
end
end
def resend_confirmation_email(%User{} = user, locale \\ "en") do
with :ok <- Tools.we_can_send_email(user, :confirmation_sent_at),
{:ok, user} <-
Users.update_user(user, %{
"confirmation_sent_at" => DateTime.utc_now() |> DateTime.truncate(:second)
}) do
send_confirmation_email(user, locale)
Logger.info("Sent confirmation email again to #{user.email}")
{:ok, user.email}
end
end
def send_confirmation_email(%User{} = user, locale \\ "en") do
user
|> UserEmail.confirmation_email(locale)
|> Mailer.deliver_later()
end
end

View File

@@ -0,0 +1,60 @@
defmodule Mobilizon.Users.Service.ResetPassword do
@moduledoc false
require Logger
alias Mobilizon.Users.User
alias Mobilizon.{Mailer, Repo, Users}
alias Mobilizon.Email.User, as: UserEmail
alias Mobilizon.Users.Service.Tools
@doc """
Check that the provided token is correct and update provided password
"""
@spec check_reset_password_token(String.t(), String.t()) :: tuple
def check_reset_password_token(password, token) do
with %User{} = user <- Users.get_user_by_reset_password_token(token),
{:ok, %User{} = user} <-
Repo.update(
User.password_reset_changeset(user, %{
"password" => password,
"reset_password_sent_at" => nil,
"reset_password_token" => nil
})
) do
{:ok, user}
else
{:error, %Ecto.Changeset{errors: [password: {"registration.error.password_too_short", _}]}} ->
{:error,
"The password you have choosen is too short. Please make sure your password contains at least 6 charaters."}
_err ->
{:error,
"The token you provided is invalid. Make sure that the URL is exactly the one provided inside the email you got."}
end
end
@doc """
Send the email reset password, if it's not too soon since the last send
"""
@spec send_password_reset_email(User.t(), String.t()) :: tuple
def send_password_reset_email(%User{} = user, locale \\ "en") do
with :ok <- Tools.we_can_send_email(user, :reset_password_sent_at),
{:ok, %User{} = user_updated} <-
Repo.update(
User.send_password_reset_changeset(user, %{
"reset_password_token" => Tools.random_string(30),
"reset_password_sent_at" => DateTime.utc_now() |> DateTime.truncate(:second)
})
) do
mail =
user_updated
|> UserEmail.reset_password_email(locale)
|> Mailer.deliver_later()
{:ok, mail}
else
{:error, reason} -> {:error, reason}
end
end
end

View File

@@ -0,0 +1,33 @@
defmodule Mobilizon.Users.Service.Tools do
@moduledoc """
Common functions for actors services
"""
alias Mobilizon.Users.User
@spec we_can_send_email(User.t(), atom()) :: :ok | {:error, :email_too_soon}
def we_can_send_email(%User{} = user, key \\ :reset_password_sent_at) do
case Map.get(user, key) do
nil ->
:ok
_ ->
case Timex.before?(
Timex.shift(Map.get(user, key), hours: 1),
DateTime.utc_now() |> DateTime.truncate(:second)
) do
true ->
:ok
false ->
{:error, :email_too_soon}
end
end
end
@spec random_string(integer) :: String.t()
def random_string(length) do
length
|> :crypto.strong_rand_bytes()
|> Base.url_encode64()
end
end

153
lib/mobilizon/users/user.ex Normal file
View File

@@ -0,0 +1,153 @@
defmodule Mobilizon.Users.User do
@moduledoc """
Represents a local user
"""
use Ecto.Schema
import Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Users.User
alias Mobilizon.Service.EmailChecker
schema "users" do
field(:email, :string)
field(:password_hash, :string)
field(:password, :string, virtual: true)
field(:role, :integer, default: 0)
has_many(:actors, Actor)
belongs_to(:default_actor, Actor)
field(:confirmed_at, :utc_datetime)
field(:confirmation_sent_at, :utc_datetime)
field(:confirmation_token, :string)
field(:reset_password_sent_at, :utc_datetime)
field(:reset_password_token, :string)
timestamps()
end
@doc false
def changeset(%User{} = user, attrs) do
changeset =
user
|> cast(attrs, [
:email,
:role,
:password,
:password_hash,
:confirmed_at,
:confirmation_sent_at,
:confirmation_token,
:reset_password_sent_at,
:reset_password_token
])
|> validate_required([:email])
|> unique_constraint(:email, message: "This email is already used.")
|> validate_email()
|> validate_length(
:password,
min: 6,
max: 100,
message: "The chosen password is too short."
)
if Map.has_key?(attrs, :default_actor) do
put_assoc(changeset, :default_actor, attrs.default_actor)
else
changeset
end
end
def registration_changeset(struct, params) do
struct
|> changeset(params)
|> cast_assoc(:default_actor)
|> validate_required([:email, :password])
|> hash_password()
|> save_confirmation_token()
|> unique_constraint(
:confirmation_token,
message: "The registration is already in use, this looks like an issue on our side."
)
end
def send_password_reset_changeset(%User{} = user, attrs) do
user
|> cast(attrs, [:reset_password_token, :reset_password_sent_at])
end
def password_reset_changeset(%User{} = user, attrs) do
user
|> cast(attrs, [:password, :reset_password_token, :reset_password_sent_at])
|> validate_length(
:password,
min: 6,
max: 100,
message: "registration.error.password_too_short"
)
|> hash_password()
end
defp save_confirmation_token(changeset) do
case changeset do
%Ecto.Changeset{valid?: true, changes: %{email: _email}} ->
changeset = put_change(changeset, :confirmation_token, random_string(30))
put_change(
changeset,
:confirmation_sent_at,
DateTime.utc_now() |> DateTime.truncate(:second)
)
_ ->
changeset
end
end
defp validate_email(changeset) do
case changeset do
%Ecto.Changeset{valid?: true, changes: %{email: email}} ->
case EmailChecker.valid?(email) do
false -> add_error(changeset, :email, "Email doesn't fit required format")
_ -> changeset
end
_ ->
changeset
end
end
defp random_string(length) do
length
|> :crypto.strong_rand_bytes()
|> Base.url_encode64()
end
# Hash password when it's changed
defp hash_password(changeset) do
case changeset do
%Ecto.Changeset{valid?: true, changes: %{password: password}} ->
put_change(
changeset,
:password_hash,
Argon2.hash_pwd_salt(password)
)
_ ->
changeset
end
end
def is_confirmed(%User{confirmed_at: nil} = _user) do
{:error, :unconfirmed}
end
def is_confirmed(%User{} = user) do
{:ok, user}
end
def owns_actor(%User{actors: actors}, actor_id) do
case Enum.find(actors, fn a -> a.id == actor_id end) do
nil -> {:is_owned, false}
actor -> {:is_owned, true, actor}
end
end
end

View File

@@ -0,0 +1,257 @@
defmodule Mobilizon.Users do
@moduledoc """
The Users context.
"""
import Ecto.Query, warn: false
alias Mobilizon.Repo
import Mobilizon.Ecto
alias Mobilizon.Actors.Actor
alias Mobilizon.Users.User
@doc false
def data() do
Dataloader.Ecto.new(Repo, query: &query/2)
end
@doc false
def query(queryable, _params) do
queryable
end
@doc """
Gets an user by it's email
## Examples
iex> get_user_by_email("test@test.tld", true)
{:ok, %Mobilizon.Users.User{}}
iex> get_user_by_email("test@notfound.tld", false)
{:error, :user_not_found}
"""
def get_user_by_email(email, activated \\ nil) do
query =
case activated do
nil ->
from(u in User, where: u.email == ^email, preload: :default_actor)
true ->
from(u in User,
where: u.email == ^email and not is_nil(u.confirmed_at),
preload: :default_actor
)
false ->
from(u in User,
where: u.email == ^email and is_nil(u.confirmed_at),
preload: :default_actor
)
end
case Repo.one(query) do
nil -> {:error, :user_not_found}
user -> {:ok, user}
end
end
@doc """
Get an user by it's activation token
"""
@spec get_user_by_activation_token(String.t()) :: Actor.t()
def get_user_by_activation_token(token) do
Repo.one(
from(u in User,
where: u.confirmation_token == ^token,
preload: [:default_actor]
)
)
end
@doc """
Get an user by it's reset password token
"""
@spec get_user_by_reset_password_token(String.t()) :: Actor.t()
def get_user_by_reset_password_token(token) do
Repo.one(
from(u in User,
where: u.reset_password_token == ^token,
preload: [:default_actor]
)
)
end
@doc """
Updates a user.
## Examples
iex> update_user(User{}, %{password: "coucou"})
{:ok, %Mobilizon.Users.User{}}
iex> update_user(User{}, %{password: nil})
{:error, %Ecto.Changeset{}}
"""
def update_user(%User{} = user, attrs) do
with {:ok, %User{} = user} <-
user
|> User.changeset(attrs)
|> Repo.update() do
{:ok, Repo.preload(user, [:default_actor])}
end
end
@doc """
Deletes a User.
## Examples
iex> delete_user(%User{email: "test@test.tld"})
{:ok, %Mobilizon.Users.User{}}
iex> delete_user(%User{})
{:error, %Ecto.Changeset{}}
"""
def delete_user(%User{} = user) do
Repo.delete(user)
end
# @doc """
# Returns an `%Ecto.Changeset{}` for tracking user changes.
# ## Examples
# iex> change_user(%Mobilizon.Users.User{})
# %Ecto.Changeset{data: %Mobilizon.Users.User{}}
# """
# def change_user(%User{} = user) do
# User.changeset(user, %{})
# end
@doc """
Gets a single user.
Raises `Ecto.NoResultsError` if the User does not exist.
## Examples
iex> get_user!(123)
%Mobilizon.Users.User{}
iex> get_user!(456)
** (Ecto.NoResultsError)
"""
def get_user!(id), do: Repo.get!(User, id)
@doc """
Get an user with it's actors
Raises `Ecto.NoResultsError` if the User does not exist.
"""
@spec get_user_with_actors!(integer()) :: User.t()
def get_user_with_actors!(id) do
user = Repo.get!(User, id)
Repo.preload(user, [:actors, :default_actor])
end
@doc """
Get user with it's actors by ID
"""
@spec get_user_with_actors(integer()) :: User.t()
def get_user_with_actors(id) do
case Repo.get(User, id) do
nil ->
{:error, "User with ID #{id} not found"}
user ->
user =
user
|> Repo.preload([:actors, :default_actor])
|> Map.put(:actors, get_actors_for_user(user))
{:ok, user}
end
end
@doc """
Returns the associated actor for an user, either the default set one or the first found
"""
@spec get_actor_for_user(Mobilizon.Users.User.t()) :: Mobilizon.Actors.Actor.t()
def get_actor_for_user(%Mobilizon.Users.User{} = user) do
case Repo.one(
from(a in Actor,
join: u in User,
on: u.default_actor_id == a.id,
where: u.id == ^user.id
)
) do
nil ->
case user |> get_actors_for_user() do
[] -> nil
actors -> hd(actors)
end
actor ->
actor
end
end
def get_actors_for_user(%User{id: user_id}) do
Repo.all(from(a in Actor, where: a.user_id == ^user_id))
end
@doc """
Authenticate user
"""
def authenticate(%{user: user, password: password}) do
# Does password match the one stored in the database?
case Argon2.verify_pass(password, user.password_hash) do
true ->
# Yes, create and return the token
MobilizonWeb.Guardian.encode_and_sign(user)
_ ->
# No, return an error
{:error, :unauthorized}
end
end
def update_user_default_actor(user_id, actor_id) do
with from(u in User, where: u.id == ^user_id, update: [set: [default_actor_id: ^actor_id]])
|> Repo.update_all([]) do
Repo.get!(User, user_id) |> Repo.preload([:default_actor])
end
end
@doc """
Returns the list of users.
## Examples
iex> list_users()
[%Mobilizon.Users.User{}]
"""
def list_users(page \\ nil, limit \\ nil, sort \\ nil, direction \\ nil) do
Repo.all(
User
|> paginate(page, limit)
|> sort(sort, direction)
)
end
def count_users() do
Repo.one(
from(
u in User,
select: count(u.id)
)
)
end
end