improve invites, record usage
This commit is contained in:
		
							
								
								
									
										63
									
								
								lib/memex/accounts/invite.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								lib/memex/accounts/invite.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| 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 | ||||
|   `:uses_left` is defined. | ||||
|   """ | ||||
|  | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   alias Ecto.{Association, Changeset, UUID} | ||||
|   alias Memex.Accounts.User | ||||
|  | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
|   schema "invites" do | ||||
|     field :name, :string | ||||
|     field :token, :string | ||||
|     field :uses_left, :integer, default: nil | ||||
|     field :disabled_at, :naive_datetime | ||||
|  | ||||
|     belongs_to :created_by, User | ||||
|  | ||||
|     has_many :users, User | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   @type t :: %__MODULE__{ | ||||
|           id: id(), | ||||
|           name: String.t(), | ||||
|           token: token(), | ||||
|           uses_left: integer() | nil, | ||||
|           disabled_at: NaiveDateTime.t(), | ||||
|           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 :: %__MODULE__{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_invite()) | ||||
|   @type token :: String.t() | ||||
|  | ||||
|   @doc false | ||||
|   @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() | ||||
|   def update_changeset(invite, attrs) do | ||||
|     invite | ||||
|     |> cast(attrs, [:name, :uses_left, :disabled_at]) | ||||
|     |> validate_required([:name]) | ||||
|     |> validate_number(:uses_left, greater_than_or_equal_to: 0) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										196
									
								
								lib/memex/accounts/invites.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										196
									
								
								lib/memex/accounts/invites.ex
									
									
									
									
									
										Normal 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 | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user