diff --git a/lib/lokal/accounts.ex b/lib/lokal/accounts.ex index fdaa9ee..00b79d4 100644 --- a/lib/lokal/accounts.ex +++ b/lib/lokal/accounts.ex @@ -4,8 +4,9 @@ defmodule Lokal.Accounts do """ import Ecto.Query, warn: false - alias Lokal.Repo - alias Lokal.Accounts.{User, UserNotifier, UserToken} + alias Lokal.{Mailer, Repo} + alias Lokal.Accounts.{User, UserToken} + alias Ecto.{Changeset, Multi} ## Database getters @@ -21,9 +22,8 @@ defmodule Lokal.Accounts do nil """ - def get_user_by_email(email) when is_binary(email) do - Repo.get_by(User, email: email) - end + @spec get_user_by_email(String.t()) :: User.t() | nil + def get_user_by_email(email) when is_binary(email), do: Repo.get_by(User, email: email) @doc """ Gets a user by email and password. @@ -37,6 +37,8 @@ defmodule Lokal.Accounts do nil """ + @spec get_user_by_email_and_password(String.t(), String.t()) :: + User.t() | nil def get_user_by_email_and_password(email, password) when is_binary(email) and is_binary(password) do user = Repo.get_by(User, email: email) @@ -57,8 +59,38 @@ defmodule Lokal.Accounts do ** (Ecto.NoResultsError) """ + @spec get_user!(User.t()) :: User.t() def get_user!(id), do: Repo.get!(User, id) + @doc """ + Returns all users grouped by role. + + ## Examples + + iex> list_users_by_role(%User{id: 123, role: :admin}) + [admin: [%User{}], user: [%User{}, %User{}]] + + """ + @spec list_all_users_by_role(User.t()) :: %{String.t() => [User.t()]} + def list_all_users_by_role(%User{role: :admin}) do + Repo.all(from u in User, order_by: u.email) |> Enum.group_by(fn user -> user.role end) + end + + @doc """ + Returns all users for a certain role. + + ## Examples + + iex> list_users_by_role(%User{id: 123, role: :admin}) + [%User{}] + + """ + @spec list_users_by_role(:admin | :user) :: [User.t()] + def list_users_by_role(role) do + role = role |> to_string() + Repo.all(from u in User, where: u.role == ^role, order_by: u.email) + end + ## User registration @doc """ @@ -70,42 +102,61 @@ defmodule Lokal.Accounts do {:ok, %User{}} iex> register_user(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + {:error, %Changeset{}} """ + @spec register_user(map()) :: {:ok, User.t()} | {:error, Changeset.t(User.new_user())} def register_user(attrs) do - %User{} - |> User.registration_changeset(attrs) - |> Repo.insert() + # if no registered users, make first user an admin + role = + if Repo.one!(from u in User, select: count(u.id), distinct: true) == 0, + do: "admin", + else: "user" + + %User{} |> User.registration_changeset(attrs |> Map.put("role", role)) |> Repo.insert() end @doc """ - Returns an `%Ecto.Changeset{}` for tracking user changes. + Returns an `%Changeset{}` for tracking user changes. ## Examples iex> change_user_registration(user) - %Ecto.Changeset{data: %User{}} + %Changeset{data: %User{}} """ - def change_user_registration(%User{} = user, attrs \\ %{}) do - User.registration_changeset(user, attrs, hash_password: false) - end + @spec change_user_registration(User.t() | User.new_user()) :: + Changeset.t(User.t() | User.new_user()) + @spec change_user_registration(User.t() | User.new_user(), map()) :: + Changeset.t(User.t() | User.new_user()) + def change_user_registration(user, attrs \\ %{}), + do: User.registration_changeset(user, attrs, hash_password: false) ## Settings @doc """ - Returns an `%Ecto.Changeset{}` for changing the user email. + Returns an `%Changeset{}` for changing the user email. ## Examples iex> change_user_email(user) - %Ecto.Changeset{data: %User{}} + %Changeset{data: %User{}} """ - def change_user_email(user, attrs \\ %{}) do - User.email_changeset(user, attrs) - end + @spec change_user_email(User.t(), map()) :: Changeset.t(User.t()) + def change_user_email(user, attrs \\ %{}), do: User.email_changeset(user, attrs) + + @doc """ + Returns an `%Changeset{}` for changing the user role. + + ## Examples + + iex> change_user_role(user) + %Changeset{data: %User{}} + + """ + @spec change_user_role(User.t(), atom()) :: Changeset.t(User.t()) + def change_user_role(user, role), do: User.role_changeset(user, role) @doc """ Emulates that the email will change without actually changing @@ -117,14 +168,16 @@ defmodule Lokal.Accounts do {:ok, %User{}} iex> apply_user_email(user, "invalid password", %{email: ...}) - {:error, %Ecto.Changeset{}} + {:error, %Changeset{}} """ + @spec apply_user_email(User.t(), String.t(), map()) :: + {:ok, User.t()} | {:error, Changeset.t(User.t())} def apply_user_email(user, password, attrs) do user |> User.email_changeset(attrs) |> User.validate_current_password(password) - |> Ecto.Changeset.apply_action(:update) + |> Changeset.apply_action(:update) end @doc """ @@ -133,6 +186,7 @@ defmodule Lokal.Accounts do If the token matches, the user email is updated and the token is deleted. The confirmed_at date is also updated to the current time. """ + @spec update_user_email(User.t(), String.t()) :: :ok | :error def update_user_email(user, token) do context = "change:#{user.email}" @@ -145,12 +199,13 @@ defmodule Lokal.Accounts do end end + @spec user_email_multi(User.t(), String.t(), String.t()) :: Multi.t() defp user_email_multi(user, email, context) do changeset = user |> User.email_changeset(%{email: email}) |> User.confirm_changeset() - Ecto.Multi.new() - |> Ecto.Multi.update(:user, changeset) - |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context])) + Multi.new() + |> Multi.update(:user, changeset) + |> Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context])) end @doc """ @@ -162,26 +217,26 @@ defmodule Lokal.Accounts do {:ok, %{to: ..., body: ...}} """ - def deliver_update_email_instructions(%User{} = user, current_email, update_email_url_fun) + @spec deliver_update_email_instructions(User.t(), String.t(), function) :: Job.t() + def deliver_update_email_instructions(user, current_email, update_email_url_fun) when is_function(update_email_url_fun, 1) do {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") - Repo.insert!(user_token) - UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) + Mailer.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) end @doc """ - Returns an `%Ecto.Changeset{}` for changing the user password. + Returns an `%Changeset{}` for changing the user password. ## Examples iex> change_user_password(user) - %Ecto.Changeset{data: %User{}} + %Changeset{data: %User{}} """ - def change_user_password(user, attrs \\ %{}) do - User.password_changeset(user, attrs, hash_password: false) - end + @spec change_user_password(User.t(), map()) :: Changeset.t(User.t()) + def change_user_password(user, attrs \\ %{}), + do: User.password_changeset(user, attrs, hash_password: false) @doc """ Updates the user password. @@ -192,18 +247,20 @@ defmodule Lokal.Accounts do {:ok, %User{}} iex> update_user_password(user, "invalid password", %{password: ...}) - {:error, %Ecto.Changeset{}} + {:error, %Changeset{}} """ + @spec update_user_password(User.t(), String.t(), map()) :: + {:ok, User.t()} | {:error, Changeset.t(User.t())} def update_user_password(user, password, attrs) do changeset = user |> User.password_changeset(attrs) |> User.validate_current_password(password) - Ecto.Multi.new() - |> Ecto.Multi.update(:user, changeset) - |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) + Multi.new() + |> Multi.update(:user, changeset) + |> Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) |> Repo.transaction() |> case do {:ok, %{user: user}} -> {:ok, user} @@ -211,11 +268,28 @@ defmodule Lokal.Accounts do end end + @doc """ + Deletes a user. must be performed by an admin or the same user! + + ## Examples + + iex> delete_user!(user_to_delete, %User{id: 123, role: :admin}) + %User{} + + iex> delete_user!(%User{id: 123}, %User{id: 123}) + %User{} + + """ + @spec delete_user!(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!() + ## Session @doc """ Generates a session token. """ + @spec generate_user_session_token(User.t()) :: String.t() def generate_user_session_token(user) do {token, user_token} = UserToken.build_session_token(user) Repo.insert!(user_token) @@ -225,6 +299,7 @@ defmodule Lokal.Accounts do @doc """ Gets the user with the given signed token. """ + @spec get_user_by_session_token(String.t()) :: User.t() def get_user_by_session_token(token) do {:ok, query} = UserToken.verify_session_token_query(token) Repo.one(query) @@ -233,11 +308,30 @@ defmodule Lokal.Accounts do @doc """ Deletes the signed token with the given context. """ + @spec delete_session_token(String.t()) :: :ok def delete_session_token(token) do Repo.delete_all(UserToken.token_and_context_query(token, "session")) :ok end + @doc """ + Returns a boolean if registration is allowed or not + """ + @spec allow_registration?() :: boolean() + def allow_registration? do + Application.get_env(:lokal, LokalWeb.Endpoint)[:registration] == "public" or + list_users_by_role(:admin) |> Enum.empty?() + end + + @doc """ + Checks if user is an admin + """ + @spec is_admin?(User.t()) :: boolean() + def is_admin?(%User{id: user_id}) do + Repo.one(from u in User, where: u.id == ^user_id and u.role == :admin) + |> is_nil() + end + ## Confirmation @doc """ @@ -252,14 +346,15 @@ defmodule Lokal.Accounts do {:error, :already_confirmed} """ - def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) + @spec deliver_user_confirmation_instructions(User.t(), function) :: Job.t() + def deliver_user_confirmation_instructions(user, confirmation_url_fun) when is_function(confirmation_url_fun, 1) do if user.confirmed_at do {:error, :already_confirmed} else {encoded_token, user_token} = UserToken.build_email_token(user, "confirm") Repo.insert!(user_token) - UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) + Mailer.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) end end @@ -269,6 +364,7 @@ defmodule Lokal.Accounts do If the token matches, the user account is marked as confirmed and the token is deleted. """ + @spec confirm_user(String.t()) :: {:ok, User.t()} | atom() def confirm_user(token) do with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), %User{} = user <- Repo.one(query), @@ -279,10 +375,11 @@ defmodule Lokal.Accounts do end end + @spec confirm_user_multi(User.t()) :: Multi.t() def confirm_user_multi(user) do - Ecto.Multi.new() - |> Ecto.Multi.update(:user, User.confirm_changeset(user)) - |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"])) + Multi.new() + |> Multi.update(:user, User.confirm_changeset(user)) + |> Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"])) end ## Reset password @@ -296,11 +393,12 @@ defmodule Lokal.Accounts do {:ok, %{to: ..., body: ...}} """ - def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun) + @spec deliver_user_reset_password_instructions(User.t(), function()) :: Job.t() + def deliver_user_reset_password_instructions(user, reset_password_url_fun) when is_function(reset_password_url_fun, 1) do {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") Repo.insert!(user_token) - UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) + Mailer.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) end @doc """ @@ -315,6 +413,7 @@ defmodule Lokal.Accounts do nil """ + @spec get_user_by_reset_password_token(String.t()) :: User.t() | nil def get_user_by_reset_password_token(token) do with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), %User{} = user <- Repo.one(query) do @@ -333,13 +432,14 @@ defmodule Lokal.Accounts do {:ok, %User{}} iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) - {:error, %Ecto.Changeset{}} + {:error, %Changeset{}} """ + @spec reset_user_password(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t(User.t())} def reset_user_password(user, attrs) do - Ecto.Multi.new() - |> Ecto.Multi.update(:user, User.password_changeset(user, attrs)) - |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) + Multi.new() + |> Multi.update(:user, User.password_changeset(user, attrs)) + |> Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) |> Repo.transaction() |> case do {:ok, %{user: user}} -> {:ok, user} diff --git a/lib/lokal/invites.ex b/lib/lokal/invites.ex new file mode 100644 index 0000000..1ae9688 --- /dev/null +++ b/lib/lokal/invites.ex @@ -0,0 +1,173 @@ +defmodule Lokal.Invites do + @moduledoc """ + The Invites context. + """ + + import Ecto.Query, warn: false + alias Lokal.{Accounts.User, Invites.Invite, Repo} + alias Ecto.Changeset + + @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/lokal/invites/invite.ex b/lib/lokal/invites/invite.ex new file mode 100644 index 0000000..f130158 --- /dev/null +++ b/lib/lokal/invites/invite.ex @@ -0,0 +1,57 @@ +defmodule Lokal.Invites.Invite do + @moduledoc """ + An invite, created by an admin to allow someone to join their instance. An + invite can be enabled or disabled, and can have an optional number of uses if + `:uses_left` is defined. + """ + + use Ecto.Schema + import Ecto.Changeset + alias Ecto.{Changeset, UUID} + alias Lokal.{Accounts.User, Invites.Invite} + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "invites" do + field :name, :string + field :token, :string + field :uses_left, :integer, default: nil + field :disabled_at, :naive_datetime + + belongs_to :user, User + + timestamps() + end + + @type t :: %Invite{ + id: id(), + name: String.t(), + token: String.t(), + uses_left: integer() | nil, + disabled_at: NaiveDateTime.t(), + user: User.t(), + user_id: User.id(), + inserted_at: NaiveDateTime.t(), + updated_at: NaiveDateTime.t() + } + @type new_invite :: %Invite{} + @type id :: UUID.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]) + |> 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()) + def update_changeset(invite, attrs) do + invite + |> cast(attrs, [:name, :uses_left, :disabled_at]) + |> validate_required([:name, :token, :user_id]) + |> validate_number(:uses_left, greater_than_or_equal_to: 0) + end +end diff --git a/lib/lokal_web/components/invite_card.ex b/lib/lokal_web/components/invite_card.ex new file mode 100644 index 0000000..494fd78 --- /dev/null +++ b/lib/lokal_web/components/invite_card.ex @@ -0,0 +1,52 @@ +defmodule LokalWeb.Components.InviteCard do + @moduledoc """ + Display card for an invite + """ + + use LokalWeb, :component + alias LokalWeb.Endpoint + + def invite_card(assigns) do + ~H""" +
+

+ <%= @invite.name %> +

+ + <%= if @invite.disabled_at |> is_nil() do %> +

+ <%= gettext("Uses Left:") %> + <%= @invite.uses_left || "Unlimited" %> +

+ <% else %> +

+ <%= gettext("Invite Disabled") %> +

+ <% end %> + +
+ + <%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %> + + + <%= if @code_actions do %> + <%= render_slot(@code_actions) %> + <% end %> +
+ + <%= if @inner_block do %> +
+ <%= render_slot(@inner_block) %> +
+ <% end %> +
+ """ + end +end diff --git a/lib/lokal_web/components/user_card.ex b/lib/lokal_web/components/user_card.ex new file mode 100644 index 0000000..8fad8b8 --- /dev/null +++ b/lib/lokal_web/components/user_card.ex @@ -0,0 +1,37 @@ +defmodule LokalWeb.Components.UserCard do + @moduledoc """ + Display card for a user + """ + + use LokalWeb, :component + + def user_card(assigns) do + ~H""" +
+

+ <%= @user.email %> +

+ +

+ <%= if @user.confirmed_at |> is_nil() do %> + Email unconfirmed + <% else %> +

User was confirmed at

+ <%= @user.confirmed_at |> display_datetime() %> + <% end %> +

+ + <%= if @inner_block do %> +
+ <%= render_slot(@inner_block) %> +
+ <% end %> +
+ """ + end +end diff --git a/priv/repo/migrations/20210814225804_create_users_auth_tables.exs b/priv/repo/migrations/20210814225804_create_users_auth_tables.exs index 3019d3e..71154bf 100644 --- a/priv/repo/migrations/20210814225804_create_users_auth_tables.exs +++ b/priv/repo/migrations/20210814225804_create_users_auth_tables.exs @@ -9,6 +9,7 @@ defmodule Lokal.Repo.Migrations.CreateUsersAuthTables do add :email, :citext, null: false add :hashed_password, :string, null: false add :confirmed_at, :naive_datetime + add :role, :string timestamps() end diff --git a/priv/repo/migrations/20210904211727_create_invites.exs b/priv/repo/migrations/20210904211727_create_invites.exs new file mode 100644 index 0000000..de76e13 --- /dev/null +++ b/priv/repo/migrations/20210904211727_create_invites.exs @@ -0,0 +1,19 @@ +defmodule Lokal.Repo.Migrations.CreateInvites do + use Ecto.Migration + + def change do + create table(:invites, primary_key: false) do + add :id, :binary_id, primary_key: true + add :name, :string + add :token, :string + add :uses_left, :integer, default: nil + add :disabled_at, :naive_datetime, default: nil + + add :user_id, references(:users, on_delete: :delete_all, type: :binary_id) + + timestamps() + end + + create index(:invites, [:user_id]) + end +end