From 2c0a4dd7ca4cceeebd8e19bb7eaa7f1e4b0822f3 Mon Sep 17 00:00:00 2001 From: shibao Date: Sat, 4 Feb 2023 16:46:37 -0500 Subject: [PATCH] improve invites, record usage --- CHANGELOG.md | 1 + lib/cannery/accounts.ex | 66 ++++-- lib/cannery/{invites => accounts}/invite.ex | 30 +-- lib/cannery/accounts/invites.ex | 196 ++++++++++++++++++ lib/cannery/accounts/user.ex | 25 ++- lib/cannery/accounts/user_token.ex | 46 ++-- lib/cannery/invites.ex | 155 -------------- lib/cannery_web.ex | 13 +- lib/cannery_web/components/invite_card.ex | 27 ++- .../user_registration_controller.ex | 33 ++- .../live/invite_live/form_component.ex | 4 +- lib/cannery_web/live/invite_live/index.ex | 29 ++- .../live/invite_live/index.html.heex | 10 +- lib/cannery_web/live/live_helpers.ex | 2 +- .../templates/user_registration/new.html.heex | 14 +- priv/gettext/actions.pot | 6 +- priv/gettext/de/LC_MESSAGES/actions.po | 6 +- priv/gettext/de/LC_MESSAGES/default.po | 33 +-- priv/gettext/de/LC_MESSAGES/errors.po | 21 +- priv/gettext/de/LC_MESSAGES/prompts.po | 66 +++--- priv/gettext/default.pot | 33 +-- priv/gettext/en/LC_MESSAGES/actions.po | 6 +- priv/gettext/en/LC_MESSAGES/default.po | 33 +-- priv/gettext/en/LC_MESSAGES/errors.po | 21 +- priv/gettext/en/LC_MESSAGES/prompts.po | 66 +++--- priv/gettext/errors.pot | 21 +- priv/gettext/es/LC_MESSAGES/actions.po | 6 +- priv/gettext/es/LC_MESSAGES/default.po | 33 +-- priv/gettext/es/LC_MESSAGES/errors.po | 21 +- priv/gettext/es/LC_MESSAGES/prompts.po | 66 +++--- priv/gettext/fr/LC_MESSAGES/actions.po | 6 +- priv/gettext/fr/LC_MESSAGES/default.po | 33 +-- priv/gettext/fr/LC_MESSAGES/errors.po | 21 +- priv/gettext/fr/LC_MESSAGES/prompts.po | 66 +++--- priv/gettext/ga/LC_MESSAGES/actions.po | 6 +- priv/gettext/ga/LC_MESSAGES/default.po | 33 +-- priv/gettext/ga/LC_MESSAGES/errors.po | 21 +- priv/gettext/ga/LC_MESSAGES/prompts.po | 66 +++--- priv/gettext/prompts.pot | 66 +++--- .../20230204191547_record_invites.exs | 11 + test/cannery/accounts/invites_test.exs | 176 ++++++++++++++++ test/cannery/accounts_test.exs | 19 +- test/cannery/invites_test.exs | 72 ------- .../controllers/export_controller_test.exs | 13 +- test/cannery_web/live/invite_live_test.exs | 13 +- test/support/data_case.ex | 2 +- 46 files changed, 988 insertions(+), 725 deletions(-) rename lib/cannery/{invites => accounts}/invite.ex (66%) create mode 100644 lib/cannery/accounts/invites.ex delete mode 100644 lib/cannery/invites.ex create mode 100644 priv/repo/migrations/20230204191547_record_invites.exs create mode 100644 test/cannery/accounts/invites_test.exs delete mode 100644 test/cannery/invites_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index c541fb9..6122f2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # v0.8.2 - Fix bug with public registration - Improve templates +- Improve invites, record usage # v0.8.1 - Update dependencies diff --git a/lib/cannery/accounts.ex b/lib/cannery/accounts.ex index a04e232..fbe31e1 100644 --- a/lib/cannery/accounts.ex +++ b/lib/cannery/accounts.ex @@ -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 diff --git a/lib/cannery/invites/invite.ex b/lib/cannery/accounts/invite.ex similarity index 66% rename from lib/cannery/invites/invite.ex rename to lib/cannery/accounts/invite.ex index 30060dd..0db4f7a 100644 --- a/lib/cannery/invites/invite.ex +++ b/lib/cannery/accounts/invite.ex @@ -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 diff --git a/lib/cannery/accounts/invites.ex b/lib/cannery/accounts/invites.ex new file mode 100644 index 0000000..88be85e --- /dev/null +++ b/lib/cannery/accounts/invites.ex @@ -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 diff --git a/lib/cannery/accounts/user.ex b/lib/cannery/accounts/user.ex index 237e5f3..2662599 100644 --- a/lib/cannery/accounts/user.ex +++ b/lib/cannery/accounts/user.ex @@ -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 diff --git a/lib/cannery/accounts/user_token.ex b/lib/cannery/accounts/user_token.ex index 8dacd30..9d4ebb2 100644 --- a/lib/cannery/accounts/user_token.ex +++ b/lib/cannery/accounts/user_token.ex @@ -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 diff --git a/lib/cannery/invites.ex b/lib/cannery/invites.ex deleted file mode 100644 index 97090a9..0000000 --- a/lib/cannery/invites.ex +++ /dev/null @@ -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 diff --git a/lib/cannery_web.ex b/lib/cannery_web.ex index fbc3736..acedd85 100644 --- a/lib/cannery_web.ex +++ b/lib/cannery_web.ex @@ -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 diff --git a/lib/cannery_web/components/invite_card.ex b/lib/cannery_web/components/invite_card.ex index e4cd254..d57c183 100644 --- a/lib/cannery_web/components/invite_card.ex +++ b/lib/cannery_web/components/invite_card.ex @@ -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""" -
+

<%= @invite.name %>

@@ -29,8 +30,8 @@ defmodule CanneryWeb.Components.InviteCard do

<%= 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} /> +

+ <%= 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 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 diff --git a/lib/cannery_web/live/invite_live/form_component.ex b/lib/cannery_web/live/invite_live/form_component.ex index 38b1bbd..f04782c 100644 --- a/lib/cannery_web/live/invite_live/form_component.ex +++ b/lib/cannery_web/live/invite_live/form_component.ex @@ -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 diff --git a/lib/cannery_web/live/invite_live/index.ex b/lib/cannery_web/live/invite_live/index.ex index d56a985..1f3fdf2 100644 --- a/lib/cannery_web/live/invite_live/index.ex +++ b/lib/cannery_web/live/invite_live/index.ex @@ -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 diff --git a/lib/cannery_web/live/invite_live/index.html.heex b/lib/cannery_web/live/invite_live/index.html.heex index 8d6e9f5..92bb2f7 100644 --- a/lib/cannery_web/live/invite_live/index.html.heex +++ b/lib/cannery_web/live/invite_live/index.html.heex @@ -19,7 +19,7 @@ <% end %>
- <.invite_card :for={invite <- @invites} invite={invite}> + <.invite_card :for={invite <- @invites} invite={invite} current_user={@current_user}> <:code_actions>