Skip to content

Commit

Permalink
Implement service for inviting to a team (#4948)
Browse files Browse the repository at this point in the history
* Implement service for inviting to a team

* Test and improve implementation of new service

* Extend tests fro RegisterForm
  • Loading branch information
zoldar authored Jan 8, 2025
1 parent 36fe62e commit 937d36f
Show file tree
Hide file tree
Showing 11 changed files with 497 additions and 24 deletions.
6 changes: 5 additions & 1 deletion lib/plausible/teams/invitation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ defmodule Plausible.Teams.Invitation do

import Ecto.Changeset

@roles Plausible.Teams.Membership.roles()

@type t() :: %__MODULE__{}

schema "team_invitations" do
field :invitation_id, :string
field :email, :string
field :role, Ecto.Enum, values: [:guest, :viewer, :editor, :admin, :owner]
field :role, Ecto.Enum, values: @roles

belongs_to :inviter, Plausible.Auth.User
belongs_to :team, Plausible.Teams.Team
Expand All @@ -22,6 +24,8 @@ defmodule Plausible.Teams.Invitation do
timestamps()
end

def roles(), do: @roles

def changeset(team, opts) do
email = Keyword.fetch!(opts, :email)
role = Keyword.fetch!(opts, :role)
Expand Down
76 changes: 69 additions & 7 deletions lib/plausible/teams/invitations.ex
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ defmodule Plausible.Teams.Invitations do
end
end

def invite(site, invitee_email, role, inviter) do
def invite(%Teams.Team{} = team, invitee_email, role, inviter) do
create_team_invitation(team, invitee_email, inviter, role: role)
end

def invite(%Plausible.Site{} = site, invitee_email, role, inviter) do
site = Teams.load_for_site(site)

if role == :owner do
Expand Down Expand Up @@ -376,7 +380,27 @@ defmodule Plausible.Teams.Invitations do
end

@doc false
def check_invitation_permissions(site, inviter, invitation_role, opts) do
def check_invitation_permissions(%Teams.Team{} = team, inviter, invitation_role, opts) do
check_permissions? = Keyword.get(opts, :check_permissions, true)

if check_permissions? do
case Teams.Memberships.team_role(team, inviter) do
{:ok, :owner} when invitation_role == :owner ->
:ok

{:ok, inviter_role}
when inviter_role in [:owner, :admin] and invitation_role != :owner ->
:ok

_ ->
{:error, :forbidden}
end
else
:ok
end
end

def check_invitation_permissions(%Plausible.Site{} = site, inviter, invitation_role, opts) do
check_permissions? = Keyword.get(opts, :check_permissions, true)

if check_permissions? do
Expand Down Expand Up @@ -411,11 +435,19 @@ defmodule Plausible.Teams.Invitations do
end

@doc false
def ensure_new_membership(_site, nil, _role), do: :ok
def ensure_new_membership(_site_or_team, nil, _role), do: :ok

def ensure_new_membership(_site_or_team, _invitee, :owner), do: :ok

def ensure_new_membership(_site, _invitee, :owner), do: :ok
def ensure_new_membership(%Teams.Team{} = team, invitee, _role) do
case Teams.Memberships.team_role(team, invitee) do
{:ok, :guest} -> :ok
{:error, :not_a_member} -> :ok
{:ok, _} -> {:error, :already_a_member}
end
end

def ensure_new_membership(site, invitee, _role) do
def ensure_new_membership(%Plausible.Site{} = site, invitee, _role) do
if Teams.Memberships.site_role(site, invitee) == {:error, :not_a_member} do
:ok
else
Expand All @@ -439,23 +471,33 @@ defmodule Plausible.Teams.Invitations do

defp create_team_invitation(team, invitee_email, inviter, opts \\ []) do
now = NaiveDateTime.utc_now(:second)
role = Keyword.get(opts, :role, :guest)

result =
Ecto.Multi.new()
|> Ecto.Multi.put(
:changeset,
Teams.Invitation.changeset(team, email: invitee_email, role: :guest, inviter: inviter)
Teams.Invitation.changeset(team, email: invitee_email, role: role, inviter: inviter)
)
|> Ecto.Multi.run(:ensure_no_site_transfers, fn _repo, %{changeset: changeset} ->
ensure_no_site_transfers(changeset, opts[:ensure_no_site_transfers_for], invitee_email)
end)
|> Ecto.Multi.insert(
:team_invitation,
& &1.changeset,
on_conflict: [set: [updated_at: now]],
on_conflict: [set: [updated_at: now, role: role]],
conflict_target: [:team_id, :email],
returning: true
)
|> Ecto.Multi.run(:prune_guest_entries, fn _repo, %{team_invitation: team_invitation} ->
if team_invitation.role != :guest do
team_invitation
|> Ecto.assoc(:guest_invitations)
|> Repo.delete_all()
end

{:ok, nil}
end)
|> Repo.transaction()

case result do
Expand Down Expand Up @@ -493,6 +535,26 @@ defmodule Plausible.Teams.Invitations do
Plausible.Mailer.send(email)
end

def send_invitation_email(%Teams.Invitation{} = team_invitation, invitee) do
email =
if invitee do
PlausibleWeb.Email.existing_user_team_invitation(
team_invitation.email,
team_invitation.team,
team_invitation.inviter
)
else
PlausibleWeb.Email.new_user_team_invitation(
team_invitation.email,
team_invitation.invitation_id,
team_invitation.team,
team_invitation.inviter
)
end

Plausible.Mailer.send(email)
end

def send_invitation_email(%Teams.GuestInvitation{} = guest_invitation, invitee) do
team_invitation = guest_invitation.team_invitation

Expand Down
53 changes: 53 additions & 0 deletions lib/plausible/teams/invitations/invite_to_team.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
defmodule Plausible.Teams.Invitations.InviteToTeam do
@moduledoc """
Service for inviting new or existing users to team.
"""

alias Plausible.Teams
alias Plausible.Repo

@valid_roles Plausible.Teams.Invitation.roles() -- [:guest]
@valid_roles @valid_roles ++ Enum.map(@valid_roles, &to_string/1)

def invite(team, inviter, invitee_email, role, opts \\ [])

def invite(team, inviter, invitee_email, role, opts) when role in @valid_roles do
with team <- Repo.preload(team, [:owner]),
:ok <-
Teams.Invitations.check_invitation_permissions(
team,
inviter,
role,
opts
),
:ok <-
Teams.Invitations.check_team_member_limit(
team,
role,
invitee_email
),
invitee = Plausible.Auth.find_user_by(email: invitee_email),
:ok <-
Teams.Invitations.ensure_new_membership(
team,
invitee,
role
),
{:ok, invitation} <-
Teams.Invitations.invite(team, invitee_email, role, inviter) do
send_invitation_email(invitation, invitee)

{:ok, invitation}
end
end

def invite(_team, _inviter, _invitee_email, role, _opts) do
raise "Invalid role passed: #{inspect(role)}"
end

defp send_invitation_email(invitation, invitee) do
invitation
|> Repo.preload([:team, :inviter])
|> Teams.Invitations.send_invitation_email(invitee)
end
end
6 changes: 5 additions & 1 deletion lib/plausible/teams/membership.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ defmodule Plausible.Teams.Membership do

import Ecto.Changeset

@roles [:guest, :viewer, :editor, :admin, :owner]

@type t() :: %__MODULE__{}

schema "team_memberships" do
field :role, Ecto.Enum, values: [:guest, :viewer, :editor, :admin, :owner]
field :role, Ecto.Enum, values: @roles

belongs_to :user, Plausible.Auth.User
belongs_to :team, Plausible.Teams.Team
Expand All @@ -20,6 +22,8 @@ defmodule Plausible.Teams.Membership do
timestamps()
end

def roles(), do: @roles

def changeset(team, user, role) do
%__MODULE__{}
|> change()
Expand Down
23 changes: 23 additions & 0 deletions lib/plausible_web/email.ex
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,29 @@ defmodule PlausibleWeb.Email do
)
end

def new_user_team_invitation(email, invitation_id, team, inviter) do
priority_email()
|> to(email)
|> tag("new-user-team-invitation")
|> subject("[#{Plausible.product_name()}] You've been invited to \"#{team.name}\" team")
|> render("new_user_team_invitation.html",
invitation_id: invitation_id,
team: team,
inviter: inviter
)
end

def existing_user_team_invitation(email, team, inviter) do
priority_email()
|> to(email)
|> tag("existing-user-team-invitation")
|> subject("[#{Plausible.product_name()}] You've been invited to \"#{team.name}\" team")
|> render("existing_user_team_invitation.html",
team: team,
inviter: inviter
)
end

def ownership_transfer_request(email, invitation_id, site, inviter, new_owner_account) do
priority_email()
|> to(email)
Expand Down
27 changes: 24 additions & 3 deletions lib/plausible_web/live/register_form.ex
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ defmodule PlausibleWeb.Live.RegisterForm do
|> Map.put("email", invitation.email)
|> Auth.User.new()

with_team? = invitation.role == :owner
with_team? = invitation.type == :site_transfer

add_user(socket, user, with_team?: with_team?)
else
Expand Down Expand Up @@ -349,6 +349,8 @@ defmodule PlausibleWeb.Live.RegisterForm do
defp find_by_id_unified(invitation_or_transfer_id) do
result =
with {:error, :invitation_not_found} <-
find_team_invitation_by_id_unified(invitation_or_transfer_id),
{:error, :invitation_not_found} <-
find_invitation_by_id_unified(invitation_or_transfer_id) do
find_transfer_by_id_unified(invitation_or_transfer_id)
end
Expand All @@ -359,6 +361,25 @@ defmodule PlausibleWeb.Live.RegisterForm do
end
end

defp find_team_invitation_by_id_unified(id) do
invitation =
Teams.Invitation
|> Repo.get_by(invitation_id: id)
|> Repo.preload(:inviter)

case invitation do
nil ->
{:error, :invitation_not_found}

team_invitation ->
{:ok,
%{
type: :team_invitation,
email: team_invitation.email
}}
end
end

defp find_invitation_by_id_unified(id) do
invitation =
Teams.GuestInvitation
Expand All @@ -372,7 +393,7 @@ defmodule PlausibleWeb.Live.RegisterForm do
guest_invitation ->
{:ok,
%{
role: guest_invitation.role,
type: :guest_invitation,
email: guest_invitation.team_invitation.email
}}
end
Expand All @@ -391,7 +412,7 @@ defmodule PlausibleWeb.Live.RegisterForm do
transfer ->
{:ok,
%{
role: :owner,
type: :site_transfer,
email: transfer.email
}}
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<%= @inviter.email %> has invited you to the "<%= @team.name %>" team on <%= Plausible.product_name() %>.
<a href={Routes.site_url(PlausibleWeb.Endpoint, :index)}>Click here</a> to view and respond to the invitation. The invitation
will expire 48 hours after this email is sent.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<%= @inviter.email %> has invited you to join the "<%= @team.name %>" team on <%= Plausible.product_name() %>.
<a href={
Routes.auth_url(
PlausibleWeb.Endpoint,
:register_from_invitation_form,
@invitation_id
)
}>Click here</a> to create your account. The link is valid for 48 hours after this email is sent.
<br /><br />
Plausible is a lightweight and open-source website analytics tool. We hope you like our simple and ethical approach to tracking website visitors.
Loading

0 comments on commit 937d36f

Please sign in to comment.