add invites

This commit is contained in:
shibao 2022-02-25 21:52:17 -05:00 committed by oliviasculley
parent c3f5744ad6
commit 41090c46d0
7 changed files with 486 additions and 47 deletions

View File

@ -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}

173
lib/lokal/invites.ex Normal file
View File

@ -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

View File

@ -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

View File

@ -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"""
<div
class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<h1 class="title text-xl">
<%= @invite.name %>
</h1>
<%= if @invite.disabled_at |> is_nil() do %>
<h2 class="title text-md">
<%= gettext("Uses Left:") %>
<%= @invite.uses_left || "Unlimited" %>
</h2>
<% else %>
<h2 class="title text-md">
<%= gettext("Invite Disabled") %>
</h2>
<% end %>
<div class="flex flex-row flex-wrap justify-center items-center">
<code
id={"code-#{@invite.id}"}
class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all text-gray-100 bg-primary-800"
>
<%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %>
</code>
<%= if @code_actions do %>
<%= render_slot(@code_actions) %>
<% end %>
</div>
<%= if @inner_block do %>
<div class="flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %>
</div>
<% end %>
</div>
"""
end
end

View File

@ -0,0 +1,37 @@
defmodule LokalWeb.Components.UserCard do
@moduledoc """
Display card for a user
"""
use LokalWeb, :component
def user_card(assigns) do
~H"""
<div
id={"user-#{@user.id}"}
class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center text-center
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out"
>
<h1 class="px-4 py-2 rounded-lg title text-xl break-all">
<%= @user.email %>
</h1>
<h3 class="px-4 py-2 rounded-lg title text-lg">
<%= if @user.confirmed_at |> is_nil() do %>
Email unconfirmed
<% else %>
<p>User was confirmed at</p>
<%= @user.confirmed_at |> display_datetime() %>
<% end %>
</h3>
<%= if @inner_block do %>
<div class="px-4 py-2 flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %>
</div>
<% end %>
</div>
"""
end
end

View File

@ -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

View File

@ -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