improve invites, record usage
This commit is contained in:
		| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
							
								
								
									
										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 | ||||
|   | ||||
| @@ -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 | ||||
| @@ -4,9 +4,19 @@ defmodule MemexWeb.Components.InviteCard do | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :component | ||||
|   alias Memex.Accounts.{Invite, Invites, User} | ||||
|   alias MemexWeb.Endpoint | ||||
|  | ||||
|   def invite_card(assigns) do | ||||
|     assigns = assigns |> assign_new(:code_actions, fn -> [] end) | ||||
|   attr :invite, Invite, required: true | ||||
|   attr :current_user, User, required: true | ||||
|   slot(:inner_block) | ||||
|   slot(:code_actions) | ||||
|  | ||||
|   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 class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4 | ||||
| @@ -20,8 +30,8 @@ defmodule MemexWeb.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") %> | ||||
| @@ -38,6 +48,10 @@ defmodule MemexWeb.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 MemexWeb.UserRegistrationController do | ||||
|   use MemexWeb, :controller | ||||
|   import MemexWeb.Gettext | ||||
|   alias Memex.{Accounts, Invites} | ||||
|   alias MemexWeb.HomeLive | ||||
|   alias Memex.{Accounts, Accounts.Invites} | ||||
|   alias MemexWeb.{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 MemexWeb.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,24 +53,25 @@ defmodule MemexWeb.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) | ||||
|         ) | ||||
|  | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "Please check your email to verify your account")) | ||||
|         |> 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 | ||||
|   | ||||
| @@ -7,7 +7,7 @@ defmodule MemexWeb.UserSettingsController do | ||||
|   plug :assign_email_and_password_changesets | ||||
|  | ||||
|   def edit(conn, _params) do | ||||
|     render(conn, "edit.html", page_title: gettext("Settings")) | ||||
|     render(conn, "edit.html", page_title: gettext("settings")) | ||||
|   end | ||||
|  | ||||
|   def update(%{assigns: %{current_user: user}} = conn, %{ | ||||
| @@ -28,7 +28,7 @@ defmodule MemexWeb.UserSettingsController do | ||||
|           :info, | ||||
|           dgettext( | ||||
|             "prompts", | ||||
|             "A link to confirm your email change has been sent to the new address." | ||||
|             "a link to confirm your email change has been sent to the new address." | ||||
|           ) | ||||
|         ) | ||||
|         |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
| @@ -46,7 +46,7 @@ defmodule MemexWeb.UserSettingsController do | ||||
|     case Accounts.update_user_password(user, password, user_params) do | ||||
|       {:ok, user} -> | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "Password updated successfully.")) | ||||
|         |> put_flash(:info, dgettext("prompts", "password updated successfully.")) | ||||
|         |> put_session(:user_return_to, Routes.user_settings_path(conn, :edit)) | ||||
|         |> UserAuth.log_in_user(user) | ||||
|  | ||||
| @@ -74,14 +74,14 @@ defmodule MemexWeb.UserSettingsController do | ||||
|     case Accounts.update_user_email(user, token) do | ||||
|       :ok -> | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "Email changed successfully.")) | ||||
|         |> put_flash(:info, dgettext("prompts", "email changed successfully.")) | ||||
|         |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|  | ||||
|       :error -> | ||||
|         conn | ||||
|         |> put_flash( | ||||
|           :error, | ||||
|           dgettext("errors", "Email change link is invalid or it has expired.") | ||||
|           dgettext("errors", "email change link is invalid or it has expired.") | ||||
|         ) | ||||
|         |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|     end | ||||
| @@ -92,11 +92,11 @@ defmodule MemexWeb.UserSettingsController do | ||||
|       current_user |> Accounts.delete_user!(current_user) | ||||
|  | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("prompts", "Your account has been deleted")) | ||||
|       |> put_flash(:error, dgettext("prompts", "your account has been deleted")) | ||||
|       |> redirect(to: Routes.live_path(conn, HomeLive)) | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Unable to delete user")) | ||||
|       |> put_flash(:error, dgettext("errors", "unable to delete user")) | ||||
|       |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|     end | ||||
|   end | ||||
|   | ||||
| @@ -44,7 +44,7 @@ | ||||
| </div> | ||||
|  | ||||
| <.modal | ||||
|   if={@live_action == :edit} | ||||
|   :if={@live_action == :edit} | ||||
|   return_to={Routes.context_show_path(@socket, :show, @context.slug)} | ||||
| > | ||||
|   <.live_component | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| defmodule MemexWeb.InviteLive.FormComponent do | ||||
|   @moduledoc """ | ||||
|   Livecomponent that can update or create an Memex.Invites.Invite | ||||
|   Livecomponent that can update or create an Memex.Accounts.Invite | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :live_component | ||||
|   alias Ecto.Changeset | ||||
|   alias Memex.{Accounts.User, Invites, Invites.Invite} | ||||
|   alias Memex.Accounts.{Invite, Invites, User} | ||||
|   alias Phoenix.LiveView.Socket | ||||
|  | ||||
|   @impl true | ||||
| @@ -13,23 +13,44 @@ defmodule MemexWeb.InviteLive.FormComponent do | ||||
|           %{:invite => Invite.t(), :current_user => User.t(), optional(any) => any}, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update(%{invite: invite} = assigns, socket) do | ||||
|     {:ok, socket |> assign(assigns) |> assign(:changeset, Invites.change_invite(invite))} | ||||
|   def update(%{invite: _invite} = assigns, socket) do | ||||
|     {:ok, socket |> assign(assigns) |> assign_changeset(%{})} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "validate", | ||||
|         %{"invite" => invite_params}, | ||||
|         %{assigns: %{invite: invite}} = socket | ||||
|       ) do | ||||
|     {:noreply, socket |> assign(:changeset, invite |> Invites.change_invite(invite_params))} | ||||
|   def handle_event("validate", %{"invite" => invite_params}, socket) do | ||||
|     {:noreply, socket |> assign_changeset(invite_params)} | ||||
|   end | ||||
|  | ||||
|   def handle_event("save", %{"invite" => invite_params}, %{assigns: %{action: action}} = socket) do | ||||
|     save_invite(socket, action, invite_params) | ||||
|   end | ||||
|  | ||||
|   defp assign_changeset( | ||||
|          %{assigns: %{action: action, current_user: user, invite: invite}} = socket, | ||||
|          invite_params | ||||
|        ) do | ||||
|     changeset_action = | ||||
|       case action do | ||||
|         :new -> :insert | ||||
|         :edit -> :update | ||||
|       end | ||||
|  | ||||
|     changeset = | ||||
|       case action do | ||||
|         :new -> Invite.create_changeset(user, "example_token", invite_params) | ||||
|         :edit -> invite |> Invite.update_changeset(invite_params) | ||||
|       end | ||||
|  | ||||
|     changeset = | ||||
|       case changeset |> Changeset.apply_action(changeset_action) do | ||||
|         {:ok, _data} -> changeset | ||||
|         {:error, changeset} -> changeset | ||||
|       end | ||||
|  | ||||
|     socket |> assign(:changeset, changeset) | ||||
|   end | ||||
|  | ||||
|   defp save_invite( | ||||
|          %{assigns: %{current_user: current_user, invite: invite, return_to: return_to}} = socket, | ||||
|          :edit, | ||||
| @@ -38,9 +59,7 @@ defmodule MemexWeb.InviteLive.FormComponent do | ||||
|     socket = | ||||
|       case invite |> Invites.update_invite(invite_params, current_user) do | ||||
|         {:ok, %{name: invite_name}} -> | ||||
|           prompt = | ||||
|             dgettext("prompts", "%{invite_name} updated successfully", invite_name: invite_name) | ||||
|  | ||||
|           prompt = dgettext("prompts", "%{name} updated successfully", name: invite_name) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Changeset{} = changeset} -> | ||||
| @@ -58,9 +77,7 @@ defmodule MemexWeb.InviteLive.FormComponent do | ||||
|     socket = | ||||
|       case current_user |> Invites.create_invite(invite_params) do | ||||
|         {:ok, %{name: invite_name}} -> | ||||
|           prompt = | ||||
|             dgettext("prompts", "%{invite_name} created successfully", invite_name: invite_name) | ||||
|  | ||||
|           prompt = dgettext("prompts", "%{name} created successfully", name: invite_name) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Changeset{} = changeset} -> | ||||
|   | ||||
| @@ -1,12 +1,13 @@ | ||||
| defmodule MemexWeb.InviteLive.Index do | ||||
|   @moduledoc """ | ||||
|   Liveview to show a Memex.Invites.Invite index | ||||
|   Liveview to show a Memex.Accounts.Invite index | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :live_view | ||||
|   import MemexWeb.Components.{InviteCard, UserCard} | ||||
|   alias Memex.{Accounts, Invites, Invites.Invite} | ||||
|   alias MemexWeb.HomeLive | ||||
|   alias Memex.Accounts | ||||
|   alias Memex.Accounts.{Invite, Invites} | ||||
|   alias MemexWeb.{Endpoint, HomeLive} | ||||
|   alias Phoenix.LiveView.JS | ||||
|  | ||||
|   @impl true | ||||
| @@ -15,9 +16,9 @@ defmodule MemexWeb.InviteLive.Index do | ||||
|       if current_user |> Map.get(:role) == :admin do | ||||
|         socket |> display_invites() | ||||
|       else | ||||
|         prompt = dgettext("errors", "You are not authorized to view this page") | ||||
|         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} | ||||
| @@ -61,7 +62,7 @@ defmodule MemexWeb.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 = | ||||
| @@ -83,7 +84,7 @@ defmodule MemexWeb.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 = | ||||
| @@ -107,7 +108,7 @@ defmodule MemexWeb.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 = | ||||
| @@ -124,7 +125,7 @@ defmodule MemexWeb.InviteLive.Index do | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("copy_to_clipboard", _, socket) do | ||||
|     prompt = dgettext("prompts", "Copied to clipboard") | ||||
|     prompt = dgettext("prompts", "copied to clipboard") | ||||
|     {:noreply, socket |> put_flash(:info, prompt)} | ||||
|   end | ||||
|  | ||||
|   | ||||
| @@ -18,7 +18,7 @@ | ||||
|   <% end %> | ||||
|  | ||||
|   <div class="w-full flex flex-row flex-wrap justify-center items-center"> | ||||
|     <.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 | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| defmodule MemexWeb.LiveHelpers do | ||||
|   @moduledoc """ | ||||
|   Contains resuable methods for all liveviews | ||||
|   Contains common helper functions for liveviews | ||||
|   """ | ||||
|  | ||||
|   import Phoenix.Component | ||||
| @@ -31,7 +31,7 @@ defmodule MemexWeb.LiveHelpers do | ||||
|       patch={@return_to} | ||||
|       id="modal-bg" | ||||
|       class="fade-in fixed z-10 left-0 top-0 | ||||
|         w-screen h-screen overflow-hidden | ||||
|         w-full h-full overflow-hidden | ||||
|         p-8 flex flex-col justify-center items-center cursor-auto" | ||||
|       style="background-color: rgba(0,0,0,0.4);" | ||||
|       phx-remove={hide_modal()} | ||||
| @@ -63,7 +63,7 @@ defmodule MemexWeb.LiveHelpers do | ||||
|           <i class="fa-fw fa-lg fas fa-times"></i> | ||||
|         </.link> | ||||
|  | ||||
|         <div class="overflow-x-hidden overflow-y-visible p-8 flex flex-col space-y-4 justify-start items-stretch"> | ||||
|         <div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-stretch"> | ||||
|           <%= render_slot(@inner_block) %> | ||||
|         </div> | ||||
|       </div> | ||||
| @@ -71,10 +71,57 @@ defmodule MemexWeb.LiveHelpers do | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   def hide_modal(js \\ %JS{}) do | ||||
|   defp hide_modal(js \\ %JS{}) do | ||||
|     js | ||||
|     |> JS.hide(to: "#modal", transition: "fade-out") | ||||
|     |> JS.hide(to: "#modal-bg", transition: "fade-out") | ||||
|     |> JS.hide(to: "#modal-content", transition: "fade-out-scale") | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   A toggle button element that can be directed to a liveview or a | ||||
|   live_component's `handle_event/3`. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|   <.toggle_button action="my_liveview_action" value={@some_value}> | ||||
|     <span>Toggle me!</span> | ||||
|   </.toggle_button> | ||||
|   <.toggle_button action="my_live_component_action" target={@myself} value={@some_value}> | ||||
|     <span>Whatever you want</span> | ||||
|   </.toggle_button> | ||||
|   """ | ||||
|   def toggle_button(assigns) do | ||||
|     assigns = assigns |> assign_new(:id, fn -> assigns.action end) | ||||
|  | ||||
|     ~H""" | ||||
|     <label for={@id} class="inline-flex relative items-center cursor-pointer"> | ||||
|       <input | ||||
|         id={@id} | ||||
|         type="checkbox" | ||||
|         value={@value} | ||||
|         checked={@value} | ||||
|         class="sr-only peer" | ||||
|         data-qa={@id} | ||||
|         { | ||||
|           if assigns |> Map.has_key?(:target), | ||||
|             do: %{"phx-click": @action, "phx-value-value": @value, "phx-target": @target}, | ||||
|             else: %{"phx-click": @action, "phx-value-value": @value} | ||||
|         } | ||||
|       /> | ||||
|       <div class="w-11 h-6 bg-gray-300 rounded-full peer | ||||
|         peer-focus:ring-4 peer-focus:ring-teal-300 dark:peer-focus:ring-teal-800 | ||||
|         peer-checked:bg-gray-600 | ||||
|         peer-checked:after:translate-x-full peer-checked:after:border-white | ||||
|         after:content-[''] after:absolute after:top-1 after:left-[2px] after:bg-white after:border-gray-300 | ||||
|         after:border after:rounded-full after:h-5 after:w-5 | ||||
|         after:transition-all after:duration-250 after:ease-in-out | ||||
|         transition-colors duration-250 ease-in-out"> | ||||
|       </div> | ||||
|       <span class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"> | ||||
|         <%= render_slot(@inner_block) %> | ||||
|       </span> | ||||
|     </label> | ||||
|     """ | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -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-400") %> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user