Skip to content

Commit

Permalink
Implement team member and invitation management actions (plausible#4977)
Browse files Browse the repository at this point in the history
* Implement scaffolding for team member and invite mgmt actions

* Implement updating team role

* Prevent changing role if the subject is the only remaining owner

* Implement removing team membership

* Fix only remaining owner removal checks

* Fix remove team membership service

* Fix and clean up imports

* Implement team invitation removal

* Fix errors surfaced by dialyzer

* Test and fix removing team invitations

* Make accept invitation action work for team invitations

* Test rejecting team invitation

* Test team membership role update and removal actions

* Fix flash message interpolation and missing team in transfer result

* Implement migration adding UUID identifier to team

* Set UUID identifier on team creation

* Implement get team by identifier

* Display team invitations on /sites

* Test rendering team invitations on /sites

* Add team management notices on /settings/people

* Test showing team management notices on /settings/people

* Stop drawing double horizontal rule

* Add modueldoc

* Handle guest member trying to call team membership endpoints gracefully

---------

Co-authored-by: Adam Rutkowski <[email protected]>
  • Loading branch information
zoldar and aerosol authored Jan 20, 2025
1 parent ea7b50d commit a45bc1c
Show file tree
Hide file tree
Showing 21 changed files with 863 additions and 21 deletions.
2 changes: 1 addition & 1 deletion lib/plausible/site/memberships/accept_invitation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do

site = site |> Repo.reload!() |> Repo.preload(ownership: :user)

{:ok, %{team_membership: site.ownership, site: site}}
{:ok, %{team: team, team_membership: site.ownership, site: site}}
end
end

Expand Down
14 changes: 10 additions & 4 deletions lib/plausible/teams.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@ defmodule Plausible.Teams do
not is_nil(team) and FunWithFlags.enabled?(:teams, for: team)
end

@spec get!(pos_integer()) :: Teams.Team.t()
def get!(team_id) do
@spec get!(pos_integer() | binary()) :: Teams.Team.t()
def get!(team_id) when is_integer(team_id) do
Repo.get!(Teams.Team, team_id)
end

def get!(team_identifier) when is_binary(team_identifier) do
Repo.get_by!(Teams.Team, identifier: team_identifier)
end

@spec get_owner(Teams.Team.t()) ::
{:ok, Plausible.Auth.User.t()} | {:error, :no_owner | :multiple_owners}
def get_owner(team) do
Expand Down Expand Up @@ -68,10 +72,11 @@ defmodule Plausible.Teams do

def owned_sites_ids(team) do
Repo.all(
from s in Plausible.Site,
from(s in Plausible.Site,
where: s.team_id == ^team.id,
select: s.id,
order_by: [desc: s.id]
)
)
end

Expand All @@ -81,9 +86,10 @@ defmodule Plausible.Teams do

def owned_sites_locked?(team) do
Repo.exists?(
from s in Plausible.Site,
from(s in Plausible.Site,
where: s.team_id == ^team.id,
where: s.locked == true
)
)
end

Expand Down
27 changes: 26 additions & 1 deletion lib/plausible/teams/invitations.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ defmodule Plausible.Teams.Invitations do
alias Plausible.Repo
alias Plausible.Teams

def get_team_invitation(team, invitation_id) do
invitation = Repo.get_by(Teams.Invitation, team_id: team.id, invitation_id: invitation_id)

if invitation do
{:ok, invitation}
else
{:error, :invitation_not_found}
end
end

def find_for_user(invitation_or_transfer_id, user) do
with {:error, :invitation_not_found} <-
find_team_invitation_for_user(invitation_or_transfer_id, user),
Expand All @@ -25,6 +35,17 @@ defmodule Plausible.Teams.Invitations do
end
end

def find_team_invitations(user) do
Repo.all(
from ti in Teams.Invitation,
inner_join: inviter in assoc(ti, :inviter),
inner_join: team in assoc(ti, :team),
where: ti.email == ^user.email,
where: ti.role != :guest,
preload: [inviter: inviter, team: team]
)
end

defp find_team_invitation_for_user(team_invitation_id, user) do
invitation_query =
from ti in Teams.Invitation,
Expand Down Expand Up @@ -280,7 +301,11 @@ defmodule Plausible.Teams.Invitations do
send_invitation_accepted_email(team_invitation, guest_invitations)
end

%{team_membership: team_membership, guest_memberships: guest_memberships}
%{
team: team_invitation.team,
team_membership: team_membership,
guest_memberships: guest_memberships
}
else
{:error, changeset} -> Repo.rollback(changeset)
end
Expand Down
31 changes: 31 additions & 0 deletions lib/plausible/teams/invitations/remove.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
defmodule Plausible.Teams.Invitations.Remove do
@moduledoc """
Service for removing a team invitation.
"""

alias Plausible.Repo
alias Plausible.Teams.Invitations
alias Plausible.Teams.Memberships

def remove(nil, _invitation_id, _current_user) do
{:error, :permission_denied}
end

def remove(team, invitation_id, current_user) do
with {:ok, team_invitation} <- Invitations.get_team_invitation(team, invitation_id),
{:ok, current_user_role} <- Memberships.team_role(team, current_user),
:ok <- check_can_remove_invitation(current_user_role) do
Repo.delete!(team_invitation)

{:ok, team_invitation}
end
end

defp check_can_remove_invitation(role) do
if role in [:owner, :admin] do
:ok
else
{:error, :permission_denied}
end
end
end
24 changes: 24 additions & 0 deletions lib/plausible/teams/memberships.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ defmodule Plausible.Teams.Memberships do
|> Repo.all()
end

def owners_count(team) do
Repo.aggregate(
from(tm in Teams.Membership, where: tm.team_id == ^team.id and tm.role == :owner),
:count
)
end

def team_role(team, user) do
result =
from(u in Auth.User,
Expand Down Expand Up @@ -145,6 +152,23 @@ defmodule Plausible.Teams.Memberships do
:ok
end

def get_team_membership(team, %Auth.User{} = user) do
get_team_membership(team, user.id)
end

def get_team_membership(team, user_id) do
query =
from(
tm in Teams.Membership,
where: tm.team_id == ^team.id and tm.user_id == ^user_id
)

case Repo.one(query) do
nil -> {:error, :membership_not_found}
membership -> {:ok, membership}
end
end

defp get_guest_membership(site_id, user_id) do
query =
from(
Expand Down
43 changes: 43 additions & 0 deletions lib/plausible/teams/memberships/remove.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
defmodule Plausible.Teams.Memberships.Remove do
@moduledoc """
Service for removing a team member.
"""

alias Plausible.Repo
alias Plausible.Teams.Memberships

def remove(nil, _, _), do: {:error, :permission_denied}

def remove(team, user_id, current_user) do
with {:ok, team_membership} <- Memberships.get_team_membership(team, user_id),
{:ok, current_user_role} <- Memberships.team_role(team, current_user),
:ok <- check_can_remove_membership(current_user_role, team_membership.role),
:ok <- check_owner_can_get_removed(team, team_membership.role) do
team_membership = Repo.preload(team_membership, [:team, :user])
Repo.delete!(team_membership)
send_team_member_removed_email(team_membership)

{:ok, team_membership}
end
end

defp check_can_remove_membership(:owner, _), do: :ok
defp check_can_remove_membership(:admin, role) when role != :owner, do: :ok
defp check_can_remove_membership(_, _), do: {:error, :permission_denied}

defp check_owner_can_get_removed(team, :owner) do
if Memberships.owners_count(team) > 1 do
:ok
else
{:error, :only_one_owner}
end
end

defp check_owner_can_get_removed(_team, _role), do: :ok

defp send_team_member_removed_email(team_membership) do
team_membership
|> PlausibleWeb.Email.team_member_removed()
|> Plausible.Mailer.send()
end
end
89 changes: 89 additions & 0 deletions lib/plausible/teams/memberships/update_role.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
defmodule Plausible.Teams.Memberships.UpdateRole do
@moduledoc """
Service for updating role of a team member.
"""

alias Plausible.Repo
alias Plausible.Teams
alias Plausible.Teams.Memberships

def update(nil, _, _, _), do: {:error, :permission_denied}

def update(team, user_id, new_role_str, current_user) do
new_role = String.to_existing_atom(new_role_str)

with :ok <- check_valid_role(new_role),
{:ok, team_membership} <- Memberships.get_team_membership(team, user_id),
{:ok, current_user_role} <- Memberships.team_role(team, current_user),
granting_to_self? = team_membership.user_id == user_id,
:ok <-
check_can_grant_role(
current_user_role,
team_membership.role,
new_role,
granting_to_self?
),
:ok <- check_owner_can_get_demoted(team, team_membership.role, new_role) do
team_membership =
team_membership
|> Ecto.Changeset.change(role: new_role)
|> Repo.update!()
|> Repo.preload(:user)

{:ok, team_membership}
end
end

defp check_valid_role(role) do
if role in (Teams.Membership.roles() -- [:guest]) do
:ok
else
{:error, :invalid_role}
end
end

defp check_owner_can_get_demoted(team, :owner, new_role) when new_role != :owner do
if Memberships.owners_count(team) > 1 do
:ok
else
{:error, :only_one_owner}
end
end

defp check_owner_can_get_demoted(_team, _current_role, _new_role), do: :ok

defp check_can_grant_role(user_role, _from_role, to_role, true) do
if can_grant_role_to_self?(user_role, to_role) do
:ok
else
{:error, :permission_denied}
end
end

defp check_can_grant_role(user_role, from_role, to_role, false) do
if can_grant_role_to_other?(user_role, from_role, to_role) do
:ok
else
{:error, :permission_denied}
end
end

defp can_grant_role_to_self?(:owner, :admin), do: true
defp can_grant_role_to_self?(:owner, :editor), do: true
defp can_grant_role_to_self?(:owner, :viewer), do: true
defp can_grant_role_to_self?(:admin, :editor), do: true
defp can_grant_role_to_self?(:admin, :viewer), do: true
defp can_grant_role_to_self?(_, _), do: false

defp can_grant_role_to_other?(:owner, _, _), do: true
defp can_grant_role_to_other?(:admin, :admin, :admin), do: true
defp can_grant_role_to_other?(:admin, :admin, :editor), do: true
defp can_grant_role_to_other?(:admin, :admin, :viewer), do: true
defp can_grant_role_to_other?(:admin, :editor, :admin), do: true
defp can_grant_role_to_other?(:admin, :editor, :editor), do: true
defp can_grant_role_to_other?(:admin, :editor, :viewer), do: true
defp can_grant_role_to_other?(:admin, :viewer, :admin), do: true
defp can_grant_role_to_other?(:admin, :viewer, :editor), do: true
defp can_grant_role_to_other?(:admin, :viewer, :viewer), do: true
defp can_grant_role_to_other?(_, _, _), do: false
end
10 changes: 10 additions & 0 deletions lib/plausible/teams/team.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ defmodule Plausible.Teams.Team do
@subscription_accept_traffic_until_offset_days 30

schema "teams" do
field :identifier, Ecto.UUID
field :name, :string
field :trial_expiry_date, :date
field :accept_traffic_until, :date
Expand Down Expand Up @@ -53,6 +54,7 @@ defmodule Plausible.Teams.Team do
|> validate_required(:name)
|> start_trial(today)
|> maybe_bump_accept_traffic_until()
|> maybe_set_identifier()
end

def name_changeset(team, attrs \\ %{}) do
Expand Down Expand Up @@ -84,6 +86,14 @@ defmodule Plausible.Teams.Team do
change(team, trial_expiry_date: Date.utc_today() |> Date.shift(day: -1))
end

defp maybe_set_identifier(changeset) do
if get_field(changeset, :identifier) do
changeset
else
put_change(changeset, :identifier, Ecto.UUID.generate())
end
end

defp maybe_bump_accept_traffic_until(changeset) do
expiry_change = get_change(changeset, :trial_expiry_date)

Expand Down
64 changes: 64 additions & 0 deletions lib/plausible_web/components/team/notice.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
defmodule PlausibleWeb.Team.Notice do
@moduledoc """
Components with teams related notices.
"""
use PlausibleWeb, :component

def inviting_banner(assigns) do
~H"""
<aside class="mt-4 mb-4">
<.notice title="Inviting people to your team" class="shadow-md dark:shadow-none mt-4">
<p>
You can also invite people to your team and give them different roles like admin, editor, viewer or billing. Team members can have full access to all sites.
</p>
</.notice>
</aside>
"""
end

def team_members_notice(assigns) do
~H"""
<aside class="mt-4 mb-4">
<.notice theme={:gray} class="rounded border border-gray-300 text-sm mt-4">
<p>
Team members automatically have access to this site.
<.styled_link href={Routes.settings_path(PlausibleWeb.Endpoint, :team_general)}>
View team members
</.styled_link>
</p>
</.notice>
</aside>
"""
end

def team_invitations(assigns) do
~H"""
<aside :if={not Enum.empty?(@team_invitations)} class="mt-4 mb-4">
<.notice
:for={i <- @team_invitations}
id={"invitation-#{i.invitation_id}"}
title="You have received team invitation"
class="shadow-md dark:shadow-none mt-4"
>
{i.inviter.name} has invited you to join the "{i.team.name}" as {i.role} member.
<.link
method="post"
href={Routes.invitation_path(PlausibleWeb.Endpoint, :accept_invitation, i.invitation_id)}
class="whitespace-nowrap font-semibold"
>
Accept
</.link>
or
<.link
method="post"
href={Routes.invitation_path(PlausibleWeb.Endpoint, :reject_invitation, i.invitation_id)}
phx-value-invitation-id={i.invitation_id}
class="whitespace-nowrap font-semibold"
>
Reject
</.link>
</.notice>
</aside>
"""
end
end
Loading

0 comments on commit a45bc1c

Please sign in to comment.