From cd7220cea3e0e5474012a1910e09a93a5ba88251 Mon Sep 17 00:00:00 2001 From: shibao Date: Sat, 4 Feb 2023 17:22:06 -0500 Subject: [PATCH] improve invites, record usage --- changelog.md | 1 + lib/memex/accounts.ex | 66 ++++-- lib/memex/{invites => accounts}/invite.ex | 38 ++-- lib/memex/accounts/invites.ex | 196 ++++++++++++++++++ lib/memex/accounts/user.ex | 35 ++-- lib/memex/accounts/user_token.ex | 28 ++- lib/memex/invites.ex | 173 ---------------- lib/memex_web/components/invite_card.ex | 22 +- .../user_registration_controller.ex | 37 ++-- .../controllers/user_settings_controller.ex | 14 +- .../live/context_live/show.html.heex | 2 +- .../live/invite_live/form_component.ex | 49 +++-- lib/memex_web/live/invite_live/index.ex | 19 +- .../live/invite_live/index.html.heex | 2 +- lib/memex_web/live/live_helpers.ex | 55 ++++- .../templates/user_registration/new.html.heex | 12 +- priv/gettext/actions.pot | 6 +- priv/gettext/de/LC_MESSAGES/actions.po | 6 +- priv/gettext/de/LC_MESSAGES/default.po | 37 ++-- priv/gettext/de/LC_MESSAGES/errors.po | 57 ++--- priv/gettext/de/LC_MESSAGES/prompts.po | 90 ++++---- priv/gettext/default.pot | 37 ++-- priv/gettext/errors.pot | 57 ++--- priv/gettext/prompts.pot | 90 ++++---- .../20230204191547_record_invites.exs | 11 + test/memex/accounts/invites_text.exs | 176 ++++++++++++++++ test/memex/accounts_test.exs | 13 +- test/memex/invites_text.exs | 76 ------- .../controllers/export_controller_test.exs | 9 +- .../user_registration_controller_test.exs | 2 +- .../user_settings_controller_test.exs | 34 +-- test/memex_web/live/context_live_test.exs | 13 +- test/memex_web/live/invite_live_test.exs | 8 +- test/memex_web/live/note_live_test.exs | 10 +- test/memex_web/live/pipeline_live_test.exs | 16 +- test/support/conn_case.ex | 11 +- test/support/data_case.ex | 8 +- 37 files changed, 902 insertions(+), 614 deletions(-) rename lib/memex/{invites => accounts}/invite.ex (56%) create mode 100644 lib/memex/accounts/invites.ex delete mode 100644 lib/memex/invites.ex create mode 100644 priv/repo/migrations/20230204191547_record_invites.exs create mode 100644 test/memex/accounts/invites_text.exs delete mode 100644 test/memex/invites_text.exs diff --git a/changelog.md b/changelog.md index 64dd132..701626c 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,7 @@ # v0.1.8 - Fix bug with public registration - Improve templates +- Improve invites, record usage # v0.1.7 - Update dependencies diff --git a/lib/memex/accounts.ex b/lib/memex/accounts.ex index a47cd8e..c139a29 100644 --- a/lib/memex/accounts.ex +++ b/lib/memex/accounts.ex @@ -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 diff --git a/lib/memex/invites/invite.ex b/lib/memex/accounts/invite.ex similarity index 56% rename from lib/memex/invites/invite.ex rename to lib/memex/accounts/invite.ex index 0713fc2..d887cf2 100644 --- a/lib/memex/invites/invite.ex +++ b/lib/memex/accounts/invite.ex @@ -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 diff --git a/lib/memex/accounts/invites.ex b/lib/memex/accounts/invites.ex new file mode 100644 index 0000000..caa03ab --- /dev/null +++ b/lib/memex/accounts/invites.ex @@ -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 diff --git a/lib/memex/accounts/user.ex b/lib/memex/accounts/user.ex index 4da45b2..14d4d51 100644 --- a/lib/memex/accounts/user.ex +++ b/lib/memex/accounts/user.ex @@ -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 diff --git a/lib/memex/accounts/user_token.ex b/lib/memex/accounts/user_token.ex index eca396f..74f387d 100644 --- a/lib/memex/accounts/user_token.ex +++ b/lib/memex/accounts/user_token.ex @@ -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 diff --git a/lib/memex/invites.ex b/lib/memex/invites.ex deleted file mode 100644 index 1c68599..0000000 --- a/lib/memex/invites.ex +++ /dev/null @@ -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 diff --git a/lib/memex_web/components/invite_card.ex b/lib/memex_web/components/invite_card.ex index e71cbe1..2b3d3eb 100644 --- a/lib/memex_web/components/invite_card.ex +++ b/lib/memex_web/components/invite_card.ex @@ -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"""
<%= 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} /> +

+ <%= gettext("uses: %{uses_count}", uses_count: @use_count) %> +

+
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 diff --git a/lib/memex_web/controllers/user_settings_controller.ex b/lib/memex_web/controllers/user_settings_controller.ex index 578a5f0..1dc3c80 100644 --- a/lib/memex_web/controllers/user_settings_controller.ex +++ b/lib/memex_web/controllers/user_settings_controller.ex @@ -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 diff --git a/lib/memex_web/live/context_live/show.html.heex b/lib/memex_web/live/context_live/show.html.heex index b1ba656..96e9f7a 100644 --- a/lib/memex_web/live/context_live/show.html.heex +++ b/lib/memex_web/live/context_live/show.html.heex @@ -44,7 +44,7 @@
<.modal - if={@live_action == :edit} + :if={@live_action == :edit} return_to={Routes.context_show_path(@socket, :show, @context.slug)} > <.live_component diff --git a/lib/memex_web/live/invite_live/form_component.ex b/lib/memex_web/live/invite_live/form_component.ex index 36a597e..41cde7b 100644 --- a/lib/memex_web/live/invite_live/form_component.ex +++ b/lib/memex_web/live/invite_live/form_component.ex @@ -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} -> diff --git a/lib/memex_web/live/invite_live/index.ex b/lib/memex_web/live/invite_live/index.ex index 2e80d92..038a2f8 100644 --- a/lib/memex_web/live/invite_live/index.ex +++ b/lib/memex_web/live/invite_live/index.ex @@ -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 diff --git a/lib/memex_web/live/invite_live/index.html.heex b/lib/memex_web/live/invite_live/index.html.heex index 3e206d8..a158415 100644 --- a/lib/memex_web/live/invite_live/index.html.heex +++ b/lib/memex_web/live/invite_live/index.html.heex @@ -18,7 +18,7 @@ <% end %>
- <.invite_card :for={invite <- @invites} invite={invite}> + <.invite_card :for={invite <- @invites} invite={invite} current_user={@current_user}> <:code_actions>