improve invites, record usage

This commit is contained in:
2023-02-04 17:22:06 -05:00
parent eb75937587
commit cd7220cea3
37 changed files with 902 additions and 614 deletions

View File

@ -5,7 +5,7 @@ defmodule Memex.Accounts do
import Ecto.Query, warn: false
alias Memex.{Mailer, Repo}
alias Memex.Accounts.{User, UserToken}
alias Memex.Accounts.{Invite, Invites, User, UserToken}
alias Ecto.{Changeset, Multi}
alias Oban.Job
@ -25,7 +25,9 @@ defmodule Memex.Accounts do
"""
@spec get_user_by_email(email :: String.t()) :: User.t() | nil
def get_user_by_email(email) when is_binary(email), do: Repo.get_by(User, email: email)
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.
@ -64,7 +66,9 @@ defmodule Memex.Accounts do
"""
@spec get_user!(User.t()) :: User.t()
def get_user!(id), do: Repo.get!(User, id)
def get_user!(id) do
Repo.get!(User, id)
end
@doc """
Returns all users grouped by role.
@ -113,19 +117,27 @@ defmodule Memex.Accounts do
:passed
"""
@spec register_user(attrs :: map()) :: {:ok, User.t()} | {:error, User.changeset()}
def register_user(attrs) do
@spec register_user(attrs :: map(), Invite.token() | nil) ::
{:ok, User.t()} | {:error, :invalid_token | User.changeset()}
def register_user(attrs, invite_token \\ nil) do
Multi.new()
|> Multi.one(:users_count, from(u in User, select: count(u.id), distinct: true))
|> Multi.insert(:add_user, fn %{users_count: count} ->
|> Multi.run(:use_invite, fn _changes_so_far, _repo ->
if allow_registration?() and invite_token |> is_nil() do
{:ok, nil}
else
Invites.use_invite(invite_token)
end
end)
|> Multi.insert(:add_user, fn %{users_count: count, use_invite: invite} ->
# if no registered users, make first user an admin
role = if count == 0, do: :admin, else: :user
User.registration_changeset(attrs) |> User.role_changeset(role)
User.registration_changeset(attrs, invite) |> User.role_changeset(role)
end)
|> Repo.transaction()
|> case do
{:ok, %{add_user: user}} -> {:ok, user}
{:error, :use_invite, :invalid_token, _changes_so_far} -> {:error, :invalid_token}
{:error, :add_user, changeset, _changes_so_far} -> {:error, changeset}
end
end
@ -144,8 +156,9 @@ defmodule Memex.Accounts do
"""
@spec change_user_registration() :: User.changeset()
@spec change_user_registration(attrs :: map()) :: User.changeset()
def change_user_registration(attrs \\ %{}),
do: User.registration_changeset(attrs, hash_password: false)
def change_user_registration(attrs \\ %{}) do
User.registration_changeset(attrs, nil, hash_password: false)
end
## Settings
@ -160,7 +173,9 @@ defmodule Memex.Accounts do
"""
@spec change_user_email(User.t()) :: User.changeset()
@spec change_user_email(User.t(), attrs :: map()) :: User.changeset()
def change_user_email(user, attrs \\ %{}), do: User.email_changeset(user, attrs)
def change_user_email(user, attrs \\ %{}) do
User.email_changeset(user, attrs)
end
@doc """
Returns an `%Changeset{}` for changing the user role.
@ -172,7 +187,9 @@ defmodule Memex.Accounts do
"""
@spec change_user_role(User.t(), User.role()) :: User.changeset()
def change_user_role(user, role), do: User.role_changeset(user, role)
def change_user_role(user, role) do
User.role_changeset(user, role)
end
@doc """
Emulates that the email will change without actually changing
@ -262,8 +279,9 @@ defmodule Memex.Accounts do
"""
@spec change_user_password(User.t(), attrs :: map()) :: User.changeset()
def change_user_password(user, attrs \\ %{}),
do: User.password_changeset(user, attrs, hash_password: false)
def change_user_password(user, attrs \\ %{}) do
User.password_changeset(user, attrs, hash_password: false)
end
@doc """
Updates the user password.
@ -314,7 +332,9 @@ defmodule Memex.Accounts do
"""
@spec change_user_locale(User.t()) :: User.changeset()
def change_user_locale(%{locale: locale} = user), do: User.locale_changeset(user, locale)
def change_user_locale(%{locale: locale} = user) do
User.locale_changeset(user, locale)
end
@doc """
Updates the user locale.
@ -328,8 +348,9 @@ defmodule Memex.Accounts do
"""
@spec update_user_locale(User.t(), locale :: String.t()) ::
{:ok, User.t()} | {:error, User.changeset()}
def update_user_locale(user, locale),
do: user |> User.locale_changeset(locale) |> Repo.update()
def update_user_locale(user, locale) do
user |> User.locale_changeset(locale) |> Repo.update()
end
@doc """
Deletes a user. must be performed by an admin or the same user!
@ -346,8 +367,13 @@ defmodule Memex.Accounts do
"""
@spec delete_user!(user_to_delete :: User.t(), User.t()) :: User.t()
def delete_user!(user, %User{role: :admin}), do: user |> Repo.delete!()
def delete_user!(%User{id: user_id} = user, %User{id: user_id}), do: user |> Repo.delete!()
def delete_user!(user, %User{role: :admin}) do
user |> Repo.delete!()
end
def delete_user!(%User{id: user_id} = user, %User{id: user_id}) do
user |> Repo.delete!()
end
## Session
@ -375,7 +401,7 @@ defmodule Memex.Accounts do
"""
@spec delete_session_token(token :: String.t()) :: :ok
def delete_session_token(token) do
Repo.delete_all(UserToken.token_and_context_query(token, "session"))
UserToken.token_and_context_query(token, "session") |> Repo.delete_all()
:ok
end

View File

@ -1,4 +1,4 @@
defmodule Memex.Invites.Invite do
defmodule Memex.Accounts.Invite do
@moduledoc """
An invite, created by an admin to allow someone to join their instance. An
invite can be enabled or disabled, and can have an optional number of uses if
@ -7,8 +7,8 @@ defmodule Memex.Invites.Invite do
use Ecto.Schema
import Ecto.Changeset
alias Ecto.{Changeset, UUID}
alias Memex.{Accounts.User, Invites.Invite}
alias Ecto.{Association, Changeset, UUID}
alias Memex.Accounts.User
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@ -18,40 +18,46 @@ defmodule Memex.Invites.Invite do
field :uses_left, :integer, default: nil
field :disabled_at, :naive_datetime
belongs_to :user, User
belongs_to :created_by, User
has_many :users, User
timestamps()
end
@type t :: %Invite{
@type t :: %__MODULE__{
id: id(),
name: String.t(),
token: String.t(),
token: token(),
uses_left: integer() | nil,
disabled_at: NaiveDateTime.t(),
user: User.t(),
user_id: User.id(),
created_by: User.t() | nil | Association.NotLoaded.t(),
created_by_id: User.id() | nil,
users: [User.t()] | Association.NotLoaded.t(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_invite :: %Invite{}
@type new_invite :: %__MODULE__{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_invite())
@type token :: String.t()
@doc false
@spec create_changeset(new_invite(), attrs :: map()) :: Changeset.t(new_invite())
def create_changeset(invite, attrs) do
invite
|> cast(attrs, [:name, :token, :uses_left, :disabled_at, :user_id])
|> validate_required([:name, :token, :user_id])
@spec create_changeset(User.t(), token(), attrs :: map()) :: changeset()
def create_changeset(%User{id: user_id}, token, attrs) do
%__MODULE__{}
|> change(token: token, created_by_id: user_id)
|> cast(attrs, [:name, :uses_left, :disabled_at])
|> validate_required([:name, :token, :created_by_id])
|> validate_number(:uses_left, greater_than_or_equal_to: 0)
end
@doc false
@spec update_changeset(t() | new_invite(), attrs :: map()) :: Changeset.t(t() | new_invite())
@spec update_changeset(t() | new_invite(), attrs :: map()) :: changeset()
def update_changeset(invite, attrs) do
invite
|> cast(attrs, [:name, :uses_left, :disabled_at])
|> validate_required([:name, :token, :user_id])
|> validate_required([:name])
|> validate_number(:uses_left, greater_than_or_equal_to: 0)
end
end

View File

@ -0,0 +1,196 @@
defmodule Memex.Accounts.Invites do
@moduledoc """
The Invites context.
"""
import Ecto.Query, warn: false
alias Ecto.Multi
alias Memex.Accounts.{Invite, User}
alias Memex.Repo
@invite_token_length 20
@doc """
Returns the list of invites.
## Examples
iex> list_invites(%User{id: 123, role: :admin})
[%Invite{}, ...]
"""
@spec list_invites(User.t()) :: [Invite.t()]
def list_invites(%User{role: :admin}) do
Repo.all(from i in Invite, order_by: i.name)
end
@doc """
Gets a single invite for a user
Raises `Ecto.NoResultsError` if the Invite does not exist.
## Examples
iex> get_invite!(123, %User{id: 123, role: :admin})
%Invite{}
> get_invite!(456, %User{id: 123, role: :admin})
** (Ecto.NoResultsError)
"""
@spec get_invite!(Invite.id(), User.t()) :: Invite.t()
def get_invite!(id, %User{role: :admin}) do
Repo.get!(Invite, id)
end
@doc """
Returns if an invite token is still valid
## Examples
iex> valid_invite_token?("valid_token")
%Invite{}
iex> valid_invite_token?("invalid_token")
nil
"""
@spec valid_invite_token?(Invite.token() | nil) :: boolean()
def valid_invite_token?(token) when token in [nil, ""], do: false
def valid_invite_token?(token) do
Repo.exists?(
from i in Invite,
where: i.token == ^token,
where: i.disabled_at |> is_nil()
)
end
@doc """
Uses invite by decrementing uses_left, or marks invite invalid if it's been
completely used.
"""
@spec use_invite(Invite.token()) :: {:ok, Invite.t()} | {:error, :invalid_token}
def use_invite(invite_token) do
Multi.new()
|> Multi.run(:invite, fn _changes_so_far, _repo ->
invite_token |> get_invite_by_token()
end)
|> Multi.update(:decrement_invite, fn %{invite: invite} ->
decrement_invite_changeset(invite)
end)
|> Repo.transaction()
|> case do
{:ok, %{decrement_invite: invite}} -> {:ok, invite}
{:error, :invite, :invalid_token, _changes_so_far} -> {:error, :invalid_token}
end
end
@spec get_invite_by_token(Invite.token()) :: {:ok, Invite.t()} | {:error, :invalid_token}
defp get_invite_by_token(token) do
Repo.one(
from i in Invite,
where: i.token == ^token,
where: i.disabled_at |> is_nil()
)
|> case do
nil -> {:error, :invalid_token}
invite -> {:ok, invite}
end
end
@spec get_use_count(Invite.t(), User.t()) :: non_neg_integer()
def get_use_count(%Invite{id: invite_id}, %User{role: :admin}) do
Repo.one(
from u in User,
where: u.invite_id == ^invite_id,
select: count(u.id)
)
end
@spec decrement_invite_changeset(Invite.t()) :: Invite.changeset()
defp decrement_invite_changeset(%Invite{uses_left: nil} = invite) do
invite |> Invite.update_changeset(%{})
end
defp decrement_invite_changeset(%Invite{uses_left: 1} = invite) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
invite |> Invite.update_changeset(%{uses_left: 0, disabled_at: now})
end
defp decrement_invite_changeset(%Invite{uses_left: uses_left} = invite) do
invite |> Invite.update_changeset(%{uses_left: uses_left - 1})
end
@doc """
Creates a invite.
## Examples
iex> create_invite(%User{id: 123, role: :admin}, %{field: value})
{:ok, %Invite{}}
iex> create_invite(%User{id: 123, role: :admin}, %{field: bad_value})
{:error, %Changeset{}}
"""
@spec create_invite(User.t(), attrs :: map()) ::
{:ok, Invite.t()} | {:error, Invite.changeset()}
def create_invite(%User{role: :admin} = user, attrs) do
token =
:crypto.strong_rand_bytes(@invite_token_length)
|> Base.url_encode64()
|> binary_part(0, @invite_token_length)
Invite.create_changeset(user, token, attrs) |> Repo.insert()
end
@doc """
Updates a invite.
## Examples
iex> update_invite(invite, %{field: new_value}, %User{id: 123, role: :admin})
{:ok, %Invite{}}
iex> update_invite(invite, %{field: bad_value}, %User{id: 123, role: :admin})
{:error, %Changeset{}}
"""
@spec update_invite(Invite.t(), attrs :: map(), User.t()) ::
{:ok, Invite.t()} | {:error, Invite.changeset()}
def update_invite(invite, attrs, %User{role: :admin}) do
invite |> Invite.update_changeset(attrs) |> Repo.update()
end
@doc """
Deletes a invite.
## Examples
iex> delete_invite(invite, %User{id: 123, role: :admin})
{:ok, %Invite{}}
iex> delete_invite(invite, %User{id: 123, role: :admin})
{:error, %Changeset{}}
"""
@spec delete_invite(Invite.t(), User.t()) ::
{:ok, Invite.t()} | {:error, Invite.changeset()}
def delete_invite(invite, %User{role: :admin}) do
invite |> Repo.delete()
end
@doc """
Deletes a invite.
## Examples
iex> delete_invite(invite, %User{id: 123, role: :admin})
%Invite{}
"""
@spec delete_invite!(Invite.t(), User.t()) :: Invite.t()
def delete_invite!(invite, %User{role: :admin}) do
invite |> Repo.delete!()
end
end

View File

@ -6,8 +6,8 @@ defmodule Memex.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
import MemexWeb.Gettext
alias Ecto.{Changeset, UUID}
alias Memex.Invites.Invite
alias Ecto.{Association, Changeset, UUID}
alias Memex.Accounts.{Invite, User}
@derive {Jason.Encoder,
only: [
@ -30,27 +30,31 @@ defmodule Memex.Accounts.User do
field :role, Ecto.Enum, values: [:admin, :user], default: :user
field :locale, :string
has_many :invites, Invite, on_delete: :delete_all
has_many :created_invites, Invite, foreign_key: :created_by_id
belongs_to :invite, Invite
timestamps()
end
@type t :: %__MODULE__{
@type t :: %User{
id: id(),
email: String.t(),
password: String.t(),
hashed_password: String.t(),
confirmed_at: NaiveDateTime.t(),
role: role(),
invites: [Invite.t()],
locale: String.t() | nil,
created_invites: [Invite.t()] | Association.NotLoaded.t(),
invite: Invite.t() | nil | Association.NotLoaded.t(),
invite_id: Invite.id() | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
@type new_user :: %__MODULE__{}
@type new_user :: %User{}
@type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_user())
@type role :: :user | :admin
@type role :: :admin | :user
@doc """
A user changeset for registration.
@ -69,18 +73,18 @@ defmodule Memex.Accounts.User do
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
"""
@spec registration_changeset(attrs :: map()) :: changeset()
@spec registration_changeset(attrs :: map(), opts :: keyword()) :: changeset()
def registration_changeset(attrs, opts \\ []) do
%__MODULE__{}
@spec registration_changeset(attrs :: map(), Invite.t() | nil) :: changeset()
@spec registration_changeset(attrs :: map(), Invite.t() | nil, opts :: keyword()) :: changeset()
def registration_changeset(attrs, invite, opts \\ []) do
%User{}
|> cast(attrs, [:email, :password, :locale])
|> put_change(:invite_id, if(invite, do: invite.id))
|> validate_email()
|> validate_password(opts)
end
@doc """
A user changeset for role.
"""
@spec role_changeset(t() | new_user() | changeset(), role()) :: changeset()
def role_changeset(user, role) do
@ -99,7 +103,8 @@ defmodule Memex.Accounts.User do
|> unique_constraint(:email)
end
@spec validate_password(changeset(), opts :: keyword()) :: changeset()
@spec validate_password(changeset(), opts :: keyword()) ::
changeset()
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
@ -177,12 +182,12 @@ defmodule Memex.Accounts.User do
`Bcrypt.no_user_verify/0` to avoid timing attacks.
"""
@spec valid_password?(t(), String.t()) :: boolean()
def valid_password?(%__MODULE__{hashed_password: hashed_password}, password)
def valid_password?(%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
def valid_password?(_invalid_user, _invalid_password) do
Bcrypt.no_user_verify()
false
end

View File

@ -5,6 +5,8 @@ defmodule Memex.Accounts.UserToken do
use Ecto.Schema
import Ecto.Query
alias Ecto.{Association, UUID}
alias Memex.Accounts.User
@hash_algorithm :sha256
@rand_size 32
@ -22,11 +24,25 @@ defmodule Memex.Accounts.UserToken do
field :token, :binary
field :context, :string
field :sent_to, :string
belongs_to :user, Memex.Accounts.User
belongs_to :user, User
timestamps(updated_at: false)
end
@type t :: %__MODULE__{
id: id(),
token: token(),
context: String.t(),
sent_to: String.t(),
user: User.t() | Association.NotLoaded.t(),
user_id: User.id() | nil,
inserted_at: NaiveDateTime.t()
}
@type new_user_token :: %__MODULE__{}
@type id :: UUID.t()
@type token :: binary()
@doc """
Generates a token that will be stored in a signed place,
such as session or cookie. As they are signed, those
@ -34,7 +50,7 @@ defmodule Memex.Accounts.UserToken do
"""
def build_session_token(user) do
token = :crypto.strong_rand_bytes(@rand_size)
{token, %Memex.Accounts.UserToken{token: token, context: "session", user_id: user.id}}
{token, %__MODULE__{token: token, context: "session", user_id: user.id}}
end
@doc """
@ -69,7 +85,7 @@ defmodule Memex.Accounts.UserToken do
hashed_token = :crypto.hash(@hash_algorithm, token)
{Base.url_encode64(token, padding: false),
%Memex.Accounts.UserToken{
%__MODULE__{
token: hashed_token,
context: context,
sent_to: sent_to,
@ -129,17 +145,17 @@ defmodule Memex.Accounts.UserToken do
Returns the given token with the given context.
"""
def token_and_context_query(token, context) do
from Memex.Accounts.UserToken, where: [token: ^token, context: ^context]
from __MODULE__, 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 Memex.Accounts.UserToken, where: t.user_id == ^user.id
from t in __MODULE__, where: t.user_id == ^user.id
end
def user_and_contexts_query(user, [_ | _] = contexts) do
from t in Memex.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts
from t in __MODULE__, where: t.user_id == ^user.id and t.context in ^contexts
end
end

View File

@ -1,173 +0,0 @@
defmodule Memex.Invites do
@moduledoc """
The Invites context.
"""
import Ecto.Query, warn: false
alias Ecto.Changeset
alias Memex.{Accounts.User, Invites.Invite, Repo}
@invite_token_length 20
@doc """
Returns the list of invites.
## Examples
iex> list_invites(%User{id: 123, role: :admin})
[%Invite{}, ...]
"""
@spec list_invites(User.t()) :: [Invite.t()]
def list_invites(%User{role: :admin}) do
Repo.all(from i in Invite, order_by: i.name)
end
@doc """
Gets a single invite.
Raises `Ecto.NoResultsError` if the Invite does not exist.
## Examples
iex> get_invite!(123, %User{id: 123, role: :admin})
%Invite{}
iex> get_invite!(456, %User{id: 123, role: :admin})
** (Ecto.NoResultsError)
"""
@spec get_invite!(Invite.id(), User.t()) :: Invite.t()
def get_invite!(id, %User{role: :admin}) do
Repo.get!(Invite, id)
end
@doc """
Returns a valid invite or nil based on the attempted token
## Examples
iex> get_invite_by_token("valid_token")
%Invite{}
iex> get_invite_by_token("invalid_token")
nil
"""
@spec get_invite_by_token(token :: String.t() | nil) :: Invite.t() | nil
def get_invite_by_token(nil), do: nil
def get_invite_by_token(""), do: nil
def get_invite_by_token(token) do
Repo.one(
from(i in Invite,
where: i.token == ^token and i.disabled_at |> is_nil()
)
)
end
@doc """
Uses invite by decrementing uses_left, or marks invite invalid if it's been
completely used.
"""
@spec use_invite!(Invite.t()) :: Invite.t()
def use_invite!(%Invite{uses_left: nil} = invite), do: invite
def use_invite!(%Invite{uses_left: uses_left} = invite) do
new_uses_left = uses_left - 1
attrs =
if new_uses_left <= 0 do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
%{"uses_left" => 0, "disabled_at" => now}
else
%{"uses_left" => new_uses_left}
end
invite |> Invite.update_changeset(attrs) |> Repo.update!()
end
@doc """
Creates a invite.
## Examples
iex> create_invite(%User{id: 123, role: :admin}, %{field: value})
{:ok, %Invite{}}
iex> create_invite(%User{id: 123, role: :admin}, %{field: bad_value})
{:error, %Changeset{}}
"""
@spec create_invite(User.t(), attrs :: map()) ::
{:ok, Invite.t()} | {:error, Changeset.t(Invite.new_invite())}
def create_invite(%User{id: user_id, role: :admin}, attrs) do
token =
:crypto.strong_rand_bytes(@invite_token_length)
|> Base.url_encode64()
|> binary_part(0, @invite_token_length)
attrs = attrs |> Map.merge(%{"user_id" => user_id, "token" => token})
%Invite{} |> Invite.create_changeset(attrs) |> Repo.insert()
end
@doc """
Updates a invite.
## Examples
iex> update_invite(invite, %{field: new_value}, %User{id: 123, role: :admin})
{:ok, %Invite{}}
iex> update_invite(invite, %{field: bad_value}, %User{id: 123, role: :admin})
{:error, %Changeset{}}
"""
@spec update_invite(Invite.t(), attrs :: map(), User.t()) ::
{:ok, Invite.t()} | {:error, Changeset.t(Invite.t())}
def update_invite(invite, attrs, %User{role: :admin}),
do: invite |> Invite.update_changeset(attrs) |> Repo.update()
@doc """
Deletes a invite.
## Examples
iex> delete_invite(invite, %User{id: 123, role: :admin})
{:ok, %Invite{}}
iex> delete_invite(invite, %User{id: 123, role: :admin})
{:error, %Changeset{}}
"""
@spec delete_invite(Invite.t(), User.t()) ::
{:ok, Invite.t()} | {:error, Changeset.t(Invite.t())}
def delete_invite(invite, %User{role: :admin}), do: invite |> Repo.delete()
@doc """
Deletes a invite.
## Examples
iex> delete_invite(invite, %User{id: 123, role: :admin})
%Invite{}
"""
@spec delete_invite!(Invite.t(), User.t()) :: Invite.t()
def delete_invite!(invite, %User{role: :admin}), do: invite |> Repo.delete!()
@doc """
Returns an `%Changeset{}` for tracking invite changes.
## Examples
iex> change_invite(invite)
%Changeset{data: %Invite{}}
"""
@spec change_invite(Invite.t() | Invite.new_invite()) ::
Changeset.t(Invite.t() | Invite.new_invite())
@spec change_invite(Invite.t() | Invite.new_invite(), attrs :: map()) ::
Changeset.t(Invite.t() | Invite.new_invite())
def change_invite(invite, attrs \\ %{}), do: invite |> Invite.update_changeset(attrs)
end