forked from shibao/cannery
		
	improve invites, record usage
This commit is contained in:
		@@ -5,7 +5,7 @@ defmodule Cannery.Accounts do
 | 
			
		||||
 | 
			
		||||
  import Ecto.Query, warn: false
 | 
			
		||||
  alias Cannery.{Mailer, Repo}
 | 
			
		||||
  alias Cannery.Accounts.{User, UserToken}
 | 
			
		||||
  alias Cannery.Accounts.{Invite, Invites, User, UserToken}
 | 
			
		||||
  alias Ecto.{Changeset, Multi}
 | 
			
		||||
  alias Oban.Job
 | 
			
		||||
 | 
			
		||||
@@ -25,7 +25,9 @@ defmodule Cannery.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 Cannery.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 Cannery.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 Cannery.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 Cannery.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 Cannery.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 Cannery.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 Cannery.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 Cannery.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 Cannery.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 Cannery.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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
defmodule Cannery.Invites.Invite do
 | 
			
		||||
defmodule Cannery.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 Cannery.Invites.Invite do
 | 
			
		||||
 | 
			
		||||
  use Ecto.Schema
 | 
			
		||||
  import Ecto.Changeset
 | 
			
		||||
  alias Ecto.{Changeset, UUID}
 | 
			
		||||
  alias Cannery.{Accounts.User, Invites.Invite}
 | 
			
		||||
  alias Cannery.Accounts.User
 | 
			
		||||
  alias Ecto.{Association, Changeset, UUID}
 | 
			
		||||
 | 
			
		||||
  @primary_key {:id, :binary_id, autogenerate: true}
 | 
			
		||||
  @foreign_key_type :binary_id
 | 
			
		||||
@@ -18,33 +18,37 @@ defmodule Cannery.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(User.t(), token :: binary(), attrs :: map()) :: changeset()
 | 
			
		||||
  @spec create_changeset(User.t(), token(), attrs :: map()) :: changeset()
 | 
			
		||||
  def create_changeset(%User{id: user_id}, token, attrs) do
 | 
			
		||||
    %Invite{}
 | 
			
		||||
    |> change(token: token, user_id: user_id)
 | 
			
		||||
    %__MODULE__{}
 | 
			
		||||
    |> change(token: token, created_by_id: user_id)
 | 
			
		||||
    |> cast(attrs, [:name, :uses_left, :disabled_at])
 | 
			
		||||
    |> validate_required([:name, :token, :user_id])
 | 
			
		||||
    |> validate_required([:name, :token, :created_by_id])
 | 
			
		||||
    |> validate_number(:uses_left, greater_than_or_equal_to: 0)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										196
									
								
								lib/cannery/accounts/invites.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										196
									
								
								lib/cannery/accounts/invites.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,196 @@
 | 
			
		||||
defmodule Cannery.Accounts.Invites do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  The Invites context.
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  import Ecto.Query, warn: false
 | 
			
		||||
  alias Ecto.Multi
 | 
			
		||||
  alias Cannery.Accounts.{Invite, User}
 | 
			
		||||
  alias Cannery.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
 | 
			
		||||
@@ -1,13 +1,13 @@
 | 
			
		||||
defmodule Cannery.Accounts.User do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  A cannery user
 | 
			
		||||
  A Cannery user
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  use Ecto.Schema
 | 
			
		||||
  import Ecto.Changeset
 | 
			
		||||
  import CanneryWeb.Gettext
 | 
			
		||||
  alias Ecto.{Changeset, UUID}
 | 
			
		||||
  alias Cannery.{Accounts.User, Invites.Invite}
 | 
			
		||||
  alias Ecto.{Association, Changeset, UUID}
 | 
			
		||||
  alias Cannery.Accounts.{Invite, User}
 | 
			
		||||
 | 
			
		||||
  @derive {Jason.Encoder,
 | 
			
		||||
           only: [
 | 
			
		||||
@@ -15,7 +15,9 @@ defmodule Cannery.Accounts.User do
 | 
			
		||||
             :email,
 | 
			
		||||
             :confirmed_at,
 | 
			
		||||
             :role,
 | 
			
		||||
             :locale
 | 
			
		||||
             :locale,
 | 
			
		||||
             :inserted_at,
 | 
			
		||||
             :updated_at
 | 
			
		||||
           ]}
 | 
			
		||||
  @derive {Inspect, except: [:password]}
 | 
			
		||||
  @primary_key {:id, :binary_id, autogenerate: true}
 | 
			
		||||
@@ -28,7 +30,9 @@ defmodule Cannery.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
 | 
			
		||||
@@ -41,7 +45,9 @@ defmodule Cannery.Accounts.User do
 | 
			
		||||
          confirmed_at: NaiveDateTime.t(),
 | 
			
		||||
          role: role(),
 | 
			
		||||
          locale: String.t() | nil,
 | 
			
		||||
          invites: [Invite.t()],
 | 
			
		||||
          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()
 | 
			
		||||
        }
 | 
			
		||||
@@ -67,11 +73,12 @@ defmodule Cannery.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
 | 
			
		||||
  @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
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,12 @@
 | 
			
		||||
defmodule Cannery.Accounts.UserToken do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  Schema for serialized user session and authentication tokens
 | 
			
		||||
  Schema for a user's session token
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  use Ecto.Schema
 | 
			
		||||
  import Ecto.Query
 | 
			
		||||
  alias Ecto.{Query, UUID}
 | 
			
		||||
  alias Cannery.{Accounts.User, Accounts.UserToken}
 | 
			
		||||
  alias Cannery.Accounts.User
 | 
			
		||||
  alias Ecto.{Association, UUID}
 | 
			
		||||
 | 
			
		||||
  @hash_algorithm :sha256
 | 
			
		||||
  @rand_size 32
 | 
			
		||||
@@ -30,27 +30,27 @@ defmodule Cannery.Accounts.UserToken do
 | 
			
		||||
    timestamps(updated_at: false)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  @type t :: %UserToken{
 | 
			
		||||
  @type t :: %__MODULE__{
 | 
			
		||||
          id: id(),
 | 
			
		||||
          token: String.t(),
 | 
			
		||||
          token: token(),
 | 
			
		||||
          context: String.t(),
 | 
			
		||||
          sent_to: String.t(),
 | 
			
		||||
          user: User.t(),
 | 
			
		||||
          user_id: User.id(),
 | 
			
		||||
          user: User.t() | Association.NotLoaded.t(),
 | 
			
		||||
          user_id: User.id() | nil,
 | 
			
		||||
          inserted_at: NaiveDateTime.t()
 | 
			
		||||
        }
 | 
			
		||||
  @type new_token :: %UserToken{}
 | 
			
		||||
  @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
 | 
			
		||||
  tokens do not need to be hashed.
 | 
			
		||||
  """
 | 
			
		||||
  @spec build_session_token(User.t()) :: {token :: String.t(), new_token()}
 | 
			
		||||
  def build_session_token(%{id: user_id}) do
 | 
			
		||||
  def build_session_token(user) do
 | 
			
		||||
    token = :crypto.strong_rand_bytes(@rand_size)
 | 
			
		||||
    {token, %UserToken{token: token, context: "session", user_id: user_id}}
 | 
			
		||||
    {token, %__MODULE__{token: token, context: "session", user_id: user.id}}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  @doc """
 | 
			
		||||
@@ -58,7 +58,6 @@ defmodule Cannery.Accounts.UserToken do
 | 
			
		||||
 | 
			
		||||
  The query returns the user found by the token.
 | 
			
		||||
  """
 | 
			
		||||
  @spec verify_session_token_query(token :: String.t()) :: {:ok, Query.t()}
 | 
			
		||||
  def verify_session_token_query(token) do
 | 
			
		||||
    query =
 | 
			
		||||
      from token in token_and_context_query(token, "session"),
 | 
			
		||||
@@ -77,19 +76,16 @@ defmodule Cannery.Accounts.UserToken do
 | 
			
		||||
  The token is valid for a week as long as users don't change
 | 
			
		||||
  their email.
 | 
			
		||||
  """
 | 
			
		||||
  @spec build_email_token(User.t(), context :: String.t()) :: {token :: String.t(), new_token()}
 | 
			
		||||
  def build_email_token(user, context) do
 | 
			
		||||
    build_hashed_token(user, context, user.email)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  @spec build_hashed_token(User.t(), String.t(), String.t()) ::
 | 
			
		||||
          {String.t(), new_token()}
 | 
			
		||||
  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),
 | 
			
		||||
     %UserToken{
 | 
			
		||||
     %__MODULE__{
 | 
			
		||||
       token: hashed_token,
 | 
			
		||||
       context: context,
 | 
			
		||||
       sent_to: sent_to,
 | 
			
		||||
@@ -102,8 +98,6 @@ defmodule Cannery.Accounts.UserToken do
 | 
			
		||||
 | 
			
		||||
  The query returns the user found by the token.
 | 
			
		||||
  """
 | 
			
		||||
  @spec verify_email_token_query(token :: String.t(), context :: String.t()) ::
 | 
			
		||||
          {:ok, Query.t()} | :error
 | 
			
		||||
  def verify_email_token_query(token, context) do
 | 
			
		||||
    case Base.url_decode64(token, padding: false) do
 | 
			
		||||
      {:ok, decoded_token} ->
 | 
			
		||||
@@ -123,7 +117,6 @@ defmodule Cannery.Accounts.UserToken do
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  @spec days_for_context(context :: <<_::56>>) :: non_neg_integer()
 | 
			
		||||
  defp days_for_context("confirm"), do: @confirm_validity_in_days
 | 
			
		||||
  defp days_for_context("reset_password"), do: @reset_password_validity_in_days
 | 
			
		||||
 | 
			
		||||
@@ -132,8 +125,6 @@ defmodule Cannery.Accounts.UserToken do
 | 
			
		||||
 | 
			
		||||
  The query returns the user token record.
 | 
			
		||||
  """
 | 
			
		||||
  @spec verify_change_email_token_query(token :: String.t(), context :: String.t()) ::
 | 
			
		||||
          {:ok, Query.t()} | :error
 | 
			
		||||
  def verify_change_email_token_query(token, context) do
 | 
			
		||||
    case Base.url_decode64(token, padding: false) do
 | 
			
		||||
      {:ok, decoded_token} ->
 | 
			
		||||
@@ -153,21 +144,18 @@ defmodule Cannery.Accounts.UserToken do
 | 
			
		||||
  @doc """
 | 
			
		||||
  Returns the given token with the given context.
 | 
			
		||||
  """
 | 
			
		||||
  @spec token_and_context_query(token :: String.t(), context :: String.t()) :: Query.t()
 | 
			
		||||
  def token_and_context_query(token, context) do
 | 
			
		||||
    from 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.
 | 
			
		||||
  """
 | 
			
		||||
  @spec user_and_contexts_query(User.t(), contexts :: :all | nonempty_maybe_improper_list()) ::
 | 
			
		||||
          Query.t()
 | 
			
		||||
  def user_and_contexts_query(%{id: user_id}, :all) do
 | 
			
		||||
    from t in UserToken, where: t.user_id == ^user_id
 | 
			
		||||
  def user_and_contexts_query(user, :all) do
 | 
			
		||||
    from t in __MODULE__, where: t.user_id == ^user.id
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def user_and_contexts_query(%{id: user_id}, [_ | _] = contexts) do
 | 
			
		||||
    from t in UserToken, where: t.user_id == ^user_id and t.context in ^contexts
 | 
			
		||||
  def user_and_contexts_query(user, [_ | _] = contexts) do
 | 
			
		||||
    from t in __MODULE__, where: t.user_id == ^user.id and t.context in ^contexts
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
@@ -1,155 +0,0 @@
 | 
			
		||||
defmodule Cannery.Invites do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  The Invites context.
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  import Ecto.Query, warn: false
 | 
			
		||||
  alias Cannery.{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, 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()
 | 
			
		||||
 | 
			
		||||
  @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()
 | 
			
		||||
 | 
			
		||||
  @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
 | 
			
		||||
@@ -72,16 +72,14 @@ defmodule CanneryWeb do
 | 
			
		||||
    quote do
 | 
			
		||||
      use Phoenix.Router
 | 
			
		||||
 | 
			
		||||
      import Phoenix.{Controller, LiveView.Router}
 | 
			
		||||
      # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
 | 
			
		||||
      import Plug.Conn
 | 
			
		||||
      import Phoenix.Controller
 | 
			
		||||
      import Phoenix.LiveView.Router
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def channel do
 | 
			
		||||
    quote do
 | 
			
		||||
      # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
 | 
			
		||||
      use Phoenix.Channel
 | 
			
		||||
      # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
 | 
			
		||||
      import CanneryWeb.Gettext
 | 
			
		||||
@@ -95,15 +93,10 @@ defmodule CanneryWeb do
 | 
			
		||||
      use Phoenix.HTML
 | 
			
		||||
 | 
			
		||||
      # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
 | 
			
		||||
      # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
 | 
			
		||||
      import Phoenix.Component
 | 
			
		||||
 | 
			
		||||
      # Import basic rendering functionality (render, render_layout, etc)
 | 
			
		||||
      # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
 | 
			
		||||
      import Phoenix.View
 | 
			
		||||
 | 
			
		||||
      # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
 | 
			
		||||
      import CanneryWeb.{ErrorHelpers, Gettext, LiveHelpers, ViewHelpers}
 | 
			
		||||
      import Phoenix.{Component, View}
 | 
			
		||||
 | 
			
		||||
      alias CanneryWeb.Endpoint
 | 
			
		||||
      alias CanneryWeb.Router.Helpers, as: Routes
 | 
			
		||||
    end
 | 
			
		||||
 
 | 
			
		||||
@@ -4,23 +4,24 @@ defmodule CanneryWeb.Components.InviteCard do
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  use CanneryWeb, :component
 | 
			
		||||
  alias Cannery.Invites.Invite
 | 
			
		||||
  alias Cannery.Accounts.{Invite, Invites, User}
 | 
			
		||||
  alias CanneryWeb.Endpoint
 | 
			
		||||
 | 
			
		||||
  attr :invite, Invite, required: true
 | 
			
		||||
  attr :current_user, User, required: true
 | 
			
		||||
  slot(:inner_block)
 | 
			
		||||
  slot(:code_actions)
 | 
			
		||||
 | 
			
		||||
  def invite_card(assigns) do
 | 
			
		||||
    assigns = assigns |> assign_new(:code_actions, fn -> [] end)
 | 
			
		||||
  def invite_card(%{invite: invite, current_user: current_user} = assigns) do
 | 
			
		||||
    assigns =
 | 
			
		||||
      assigns
 | 
			
		||||
      |> assign(:use_count, Invites.get_use_count(invite, current_user))
 | 
			
		||||
      |> assign_new(:code_actions, fn -> [] end)
 | 
			
		||||
 | 
			
		||||
    ~H"""
 | 
			
		||||
    <div
 | 
			
		||||
      id={"invite-#{@invite.id}"}
 | 
			
		||||
      class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4
 | 
			
		||||
        border border-gray-400 rounded-lg shadow-lg hover:shadow-md
 | 
			
		||||
        transition-all duration-300 ease-in-out"
 | 
			
		||||
    >
 | 
			
		||||
    <div class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4
 | 
			
		||||
      border border-gray-400 rounded-lg shadow-lg hover:shadow-md
 | 
			
		||||
      transition-all duration-300 ease-in-out">
 | 
			
		||||
      <h1 class="title text-xl">
 | 
			
		||||
        <%= @invite.name %>
 | 
			
		||||
      </h1>
 | 
			
		||||
@@ -29,8 +30,8 @@ defmodule CanneryWeb.Components.InviteCard do
 | 
			
		||||
        <h2 class="title text-md">
 | 
			
		||||
          <%= if @invite.uses_left do %>
 | 
			
		||||
            <%= gettext(
 | 
			
		||||
              "Uses Left: %{uses_left}",
 | 
			
		||||
              uses_left: @invite.uses_left
 | 
			
		||||
              "Uses Left: %{uses_left_count}",
 | 
			
		||||
              uses_left_count: @invite.uses_left
 | 
			
		||||
            ) %>
 | 
			
		||||
          <% else %>
 | 
			
		||||
            <%= gettext("Uses Left: Unlimited") %>
 | 
			
		||||
@@ -47,6 +48,10 @@ defmodule CanneryWeb.Components.InviteCard do
 | 
			
		||||
        filename={@invite.name}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <h2 :if={@use_count != 0} class="title text-md">
 | 
			
		||||
        <%= gettext("Uses: %{uses_count}", uses_count: @use_count) %>
 | 
			
		||||
      </h2>
 | 
			
		||||
 | 
			
		||||
      <div class="flex flex-row flex-wrap justify-center items-center">
 | 
			
		||||
        <code
 | 
			
		||||
          id={"code-#{@invite.id}"}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,12 @@
 | 
			
		||||
defmodule CanneryWeb.UserRegistrationController do
 | 
			
		||||
  use CanneryWeb, :controller
 | 
			
		||||
  import CanneryWeb.Gettext
 | 
			
		||||
  alias Cannery.{Accounts, Invites}
 | 
			
		||||
  alias Cannery.{Accounts, Accounts.Invites}
 | 
			
		||||
  alias CanneryWeb.{Endpoint, HomeLive}
 | 
			
		||||
 | 
			
		||||
  def new(conn, %{"invite" => invite_token}) do
 | 
			
		||||
    invite = Invites.get_invite_by_token(invite_token)
 | 
			
		||||
 | 
			
		||||
    if invite do
 | 
			
		||||
      conn |> render_new(invite)
 | 
			
		||||
    if Invites.valid_invite_token?(invite_token) do
 | 
			
		||||
      conn |> render_new(invite_token)
 | 
			
		||||
    else
 | 
			
		||||
      conn
 | 
			
		||||
      |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
 | 
			
		||||
@@ -27,19 +25,17 @@ defmodule CanneryWeb.UserRegistrationController do
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # renders new user registration page
 | 
			
		||||
  defp render_new(conn, invite \\ nil) do
 | 
			
		||||
  defp render_new(conn, invite_token \\ nil) do
 | 
			
		||||
    render(conn, "new.html",
 | 
			
		||||
      changeset: Accounts.change_user_registration(),
 | 
			
		||||
      invite: invite,
 | 
			
		||||
      invite_token: invite_token,
 | 
			
		||||
      page_title: gettext("Register")
 | 
			
		||||
    )
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def create(conn, %{"user" => %{"invite_token" => invite_token}} = attrs) do
 | 
			
		||||
    invite = Invites.get_invite_by_token(invite_token)
 | 
			
		||||
 | 
			
		||||
    if invite do
 | 
			
		||||
      conn |> create_user(attrs, invite)
 | 
			
		||||
    if Invites.valid_invite_token?(invite_token) do
 | 
			
		||||
      conn |> create_user(attrs, invite_token)
 | 
			
		||||
    else
 | 
			
		||||
      conn
 | 
			
		||||
      |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
 | 
			
		||||
@@ -57,13 +53,9 @@ defmodule CanneryWeb.UserRegistrationController do
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  defp create_user(conn, %{"user" => user_params}, invite \\ nil) do
 | 
			
		||||
    case Accounts.register_user(user_params) do
 | 
			
		||||
  defp create_user(conn, %{"user" => user_params}, invite_token \\ nil) do
 | 
			
		||||
    case Accounts.register_user(user_params, invite_token) do
 | 
			
		||||
      {:ok, user} ->
 | 
			
		||||
        unless invite |> is_nil() do
 | 
			
		||||
          invite |> Invites.use_invite!()
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        Accounts.deliver_user_confirmation_instructions(
 | 
			
		||||
          user,
 | 
			
		||||
          &Routes.user_confirmation_url(conn, :confirm, &1)
 | 
			
		||||
@@ -73,8 +65,13 @@ defmodule CanneryWeb.UserRegistrationController do
 | 
			
		||||
        |> put_flash(:info, dgettext("prompts", "Please check your email to verify your account"))
 | 
			
		||||
        |> redirect(to: Routes.user_session_path(Endpoint, :new))
 | 
			
		||||
 | 
			
		||||
      {:error, :invalid_token} ->
 | 
			
		||||
        conn
 | 
			
		||||
        |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
 | 
			
		||||
        |> redirect(to: Routes.live_path(Endpoint, HomeLive))
 | 
			
		||||
 | 
			
		||||
      {:error, %Ecto.Changeset{} = changeset} ->
 | 
			
		||||
        conn |> render("new.html", changeset: changeset, invite: invite)
 | 
			
		||||
        conn |> render("new.html", changeset: changeset, invite_token: invite_token)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,11 @@
 | 
			
		||||
defmodule CanneryWeb.InviteLive.FormComponent do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  Livecomponent that can update or create an Cannery.Invites.Invite
 | 
			
		||||
  Livecomponent that can update or create an Cannery.Accounts.Invite
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  use CanneryWeb, :live_component
 | 
			
		||||
  alias Cannery.{Accounts.User, Invites, Invites.Invite}
 | 
			
		||||
  alias Ecto.Changeset
 | 
			
		||||
  alias Cannery.Accounts.{Invite, Invites, User}
 | 
			
		||||
  alias Phoenix.LiveView.Socket
 | 
			
		||||
 | 
			
		||||
  @impl true
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,12 @@
 | 
			
		||||
defmodule CanneryWeb.InviteLive.Index do
 | 
			
		||||
  @moduledoc """
 | 
			
		||||
  Liveview to show a Cannery.Invites.Invite index
 | 
			
		||||
  Liveview to show a Cannery.Accounts.Invite index
 | 
			
		||||
  """
 | 
			
		||||
 | 
			
		||||
  use CanneryWeb, :live_view
 | 
			
		||||
  import CanneryWeb.Components.{InviteCard, UserCard}
 | 
			
		||||
  alias Cannery.{Accounts, Invites, Invites.Invite}
 | 
			
		||||
  alias Cannery.Accounts
 | 
			
		||||
  alias Cannery.Accounts.{Invite, Invites}
 | 
			
		||||
  alias CanneryWeb.{Endpoint, HomeLive}
 | 
			
		||||
  alias Phoenix.LiveView.JS
 | 
			
		||||
 | 
			
		||||
@@ -17,7 +18,7 @@ defmodule CanneryWeb.InviteLive.Index do
 | 
			
		||||
      else
 | 
			
		||||
        prompt = dgettext("errors", "You are not authorized to view this page")
 | 
			
		||||
        return_to = Routes.live_path(Endpoint, HomeLive)
 | 
			
		||||
        socket |> put_flash(:error, prompt) |> push_navigate(to: return_to)
 | 
			
		||||
        socket |> put_flash(:error, prompt) |> push_redirect(to: return_to)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
    {:ok, socket}
 | 
			
		||||
@@ -50,7 +51,7 @@ defmodule CanneryWeb.InviteLive.Index do
 | 
			
		||||
    %{name: invite_name} =
 | 
			
		||||
      id |> Invites.get_invite!(current_user) |> Invites.delete_invite!(current_user)
 | 
			
		||||
 | 
			
		||||
    prompt = dgettext("prompts", "%{name} deleted succesfully", name: invite_name)
 | 
			
		||||
    prompt = dgettext("prompts", "%{invite_name} deleted succesfully", invite_name: invite_name)
 | 
			
		||||
    {:noreply, socket |> put_flash(:info, prompt) |> display_invites()}
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
@@ -61,10 +62,12 @@ defmodule CanneryWeb.InviteLive.Index do
 | 
			
		||||
      ) do
 | 
			
		||||
    socket =
 | 
			
		||||
      Invites.get_invite!(id, current_user)
 | 
			
		||||
      |> Invites.update_invite(%{"uses_left" => nil}, current_user)
 | 
			
		||||
      |> Invites.update_invite(%{uses_left: nil}, current_user)
 | 
			
		||||
      |> case do
 | 
			
		||||
        {:ok, %{name: invite_name}} ->
 | 
			
		||||
          prompt = dgettext("prompts", "%{name} updated succesfully", name: invite_name)
 | 
			
		||||
          prompt =
 | 
			
		||||
            dgettext("prompts", "%{invite_name} updated succesfully", invite_name: invite_name)
 | 
			
		||||
 | 
			
		||||
          socket |> put_flash(:info, prompt) |> display_invites()
 | 
			
		||||
 | 
			
		||||
        {:error, changeset} ->
 | 
			
		||||
@@ -81,10 +84,12 @@ defmodule CanneryWeb.InviteLive.Index do
 | 
			
		||||
      ) do
 | 
			
		||||
    socket =
 | 
			
		||||
      Invites.get_invite!(id, current_user)
 | 
			
		||||
      |> Invites.update_invite(%{"uses_left" => nil, "disabled_at" => nil}, current_user)
 | 
			
		||||
      |> Invites.update_invite(%{uses_left: nil, disabled_at: nil}, current_user)
 | 
			
		||||
      |> case do
 | 
			
		||||
        {:ok, %{name: invite_name}} ->
 | 
			
		||||
          prompt = dgettext("prompts", "%{name} enabled succesfully", name: invite_name)
 | 
			
		||||
          prompt =
 | 
			
		||||
            dgettext("prompts", "%{invite_name} enabled succesfully", invite_name: invite_name)
 | 
			
		||||
 | 
			
		||||
          socket |> put_flash(:info, prompt) |> display_invites()
 | 
			
		||||
 | 
			
		||||
        {:error, changeset} ->
 | 
			
		||||
@@ -103,10 +108,12 @@ defmodule CanneryWeb.InviteLive.Index do
 | 
			
		||||
 | 
			
		||||
    socket =
 | 
			
		||||
      Invites.get_invite!(id, current_user)
 | 
			
		||||
      |> Invites.update_invite(%{"uses_left" => 0, "disabled_at" => now}, current_user)
 | 
			
		||||
      |> Invites.update_invite(%{uses_left: 0, disabled_at: now}, current_user)
 | 
			
		||||
      |> case do
 | 
			
		||||
        {:ok, %{name: invite_name}} ->
 | 
			
		||||
          prompt = dgettext("prompts", "%{name} disabled succesfully", name: invite_name)
 | 
			
		||||
          prompt =
 | 
			
		||||
            dgettext("prompts", "%{invite_name} disabled succesfully", invite_name: invite_name)
 | 
			
		||||
 | 
			
		||||
          socket |> put_flash(:info, prompt) |> display_invites()
 | 
			
		||||
 | 
			
		||||
        {:error, changeset} ->
 | 
			
		||||
@@ -130,7 +137,7 @@ defmodule CanneryWeb.InviteLive.Index do
 | 
			
		||||
      ) do
 | 
			
		||||
    %{email: user_email} = Accounts.get_user!(id) |> Accounts.delete_user!(current_user)
 | 
			
		||||
 | 
			
		||||
    prompt = dgettext("prompts", "%{name} deleted succesfully", name: user_email)
 | 
			
		||||
    prompt = dgettext("prompts", "%{user_email} deleted succesfully", user_email: user_email)
 | 
			
		||||
 | 
			
		||||
    {:noreply, socket |> put_flash(:info, prompt) |> display_invites()}
 | 
			
		||||
  end
 | 
			
		||||
 
 | 
			
		||||
@@ -19,7 +19,7 @@
 | 
			
		||||
  <% end %>
 | 
			
		||||
 | 
			
		||||
  <div class="w-full flex flex-row flex-wrap justify-center items-stretch">
 | 
			
		||||
    <.invite_card :for={invite <- @invites} invite={invite}>
 | 
			
		||||
    <.invite_card :for={invite <- @invites} invite={invite} current_user={@current_user}>
 | 
			
		||||
      <:code_actions>
 | 
			
		||||
        <form phx-submit="copy_to_clipboard">
 | 
			
		||||
          <button
 | 
			
		||||
@@ -45,8 +45,8 @@
 | 
			
		||||
        phx-click="delete_invite"
 | 
			
		||||
        phx-value-id={invite.id}
 | 
			
		||||
        data-confirm={
 | 
			
		||||
          dgettext("prompts", "Are you sure you want to delete the invite for %{name}?",
 | 
			
		||||
            name: invite.name
 | 
			
		||||
          dgettext("prompts", "Are you sure you want to delete the invite for %{invite_name}?",
 | 
			
		||||
            invite_name: invite.name
 | 
			
		||||
          )
 | 
			
		||||
        }
 | 
			
		||||
        data-qa={"delete-#{invite.id}"}
 | 
			
		||||
@@ -70,8 +70,8 @@
 | 
			
		||||
        phx-click="set_unlimited"
 | 
			
		||||
        phx-value-id={invite.id}
 | 
			
		||||
        data-confirm={
 | 
			
		||||
          dgettext("prompts", "Are you sure you want to make %{name} unlimited?",
 | 
			
		||||
            name: invite.name
 | 
			
		||||
          dgettext("prompts", "Are you sure you want to make %{invite_name} unlimited?",
 | 
			
		||||
            invite_name: invite.name
 | 
			
		||||
          )
 | 
			
		||||
        }
 | 
			
		||||
      >
 | 
			
		||||
 
 | 
			
		||||
@@ -49,7 +49,7 @@ defmodule CanneryWeb.LiveHelpers do
 | 
			
		||||
        id="modal-content"
 | 
			
		||||
        class="fade-in-scale w-full max-w-3xl relative
 | 
			
		||||
          pointer-events-auto overflow-hidden
 | 
			
		||||
          px-8 py-4 sm:py-8 flex flex-col justify-center items-center
 | 
			
		||||
          px-8 py-4 sm:py-8
 | 
			
		||||
          flex flex-col justify-start items-center
 | 
			
		||||
          bg-white border-2 rounded-lg"
 | 
			
		||||
      >
 | 
			
		||||
 
 | 
			
		||||
@@ -9,14 +9,12 @@
 | 
			
		||||
    action={Routes.user_registration_path(@conn, :create)}
 | 
			
		||||
    class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
 | 
			
		||||
  >
 | 
			
		||||
    <div :if={@changeset.action && not @changeset.valid?()} class="alert alert-danger col-span-3">
 | 
			
		||||
      <p>
 | 
			
		||||
        <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %>
 | 
			
		||||
      </p>
 | 
			
		||||
    </div>
 | 
			
		||||
    <p :if={@changeset.action && not @changeset.valid?()} class="alert alert-danger col-span-3">
 | 
			
		||||
      <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %>
 | 
			
		||||
    </p>
 | 
			
		||||
 | 
			
		||||
    <%= if @invite do %>
 | 
			
		||||
      <%= hidden_input(f, :invite_token, value: @invite.token) %>
 | 
			
		||||
    <%= if @invite_token do %>
 | 
			
		||||
      <%= hidden_input(f, :invite_token, value: @invite_token) %>
 | 
			
		||||
    <% end %>
 | 
			
		||||
 | 
			
		||||
    <%= label(f, :email, class: "title text-lg text-primary-600") %>
 | 
			
		||||
@@ -31,7 +29,7 @@
 | 
			
		||||
    <%= select(
 | 
			
		||||
      f,
 | 
			
		||||
      :locale,
 | 
			
		||||
      [{gettext("English"), "en_US"}, {gettext("German"), "de"}, {gettext("French"), "fr"}],
 | 
			
		||||
      [{gettext("English"), "en_US"}],
 | 
			
		||||
      class: "input input-primary col-span-2"
 | 
			
		||||
    ) %>
 | 
			
		||||
    <%= error_tag(f, :locale) %>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user