Implement limits for team members (#3305)

* Refactor MembershipController.invite_member/2

This commit refactors the controller action used for creating new
invitations. It moves the code to Plausible.Sites.invite/4 and replaces
`ifs` and `cases` with `with`.

* Add team_member_limit to plan definition

* Create usage and limits functions for team members

* Apply team member limit when inviting new users

* Add team members to Usage & Limits section

* Change invite function to receive email address instead of %User{}

* Wrap invite function in a DB transaction

* Remove unnecessary joins from team member usage query

* Replace UNION ALL with UNION to remove duplicates
This commit is contained in:
Vini Brasil 2023-09-04 09:55:07 -03:00 committed by GitHub
parent b3ff695797
commit d22c011aa3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 518 additions and 113 deletions

View File

@ -2,6 +2,8 @@ defmodule Plausible.Auth.Invitation do
use Ecto.Schema
import Ecto.Changeset
@type t() :: %__MODULE__{}
@derive {Jason.Encoder, only: [:invitation_id, :role, :site]}
@required [:email, :role, :site_id, :inviter_id]
schema "invitations" do

View File

@ -2,7 +2,7 @@ defmodule Plausible.Billing.Plan do
@moduledoc false
@derive Jason.Encoder
@enforce_keys ~w(kind site_limit monthly_pageview_limit volume monthly_cost yearly_cost monthly_product_id yearly_product_id)a
@enforce_keys ~w(kind site_limit monthly_pageview_limit team_member_limit volume monthly_cost yearly_cost monthly_product_id yearly_product_id)a
defstruct @enforce_keys
@type t() ::
@ -10,6 +10,7 @@ defmodule Plausible.Billing.Plan do
kind: atom(),
monthly_pageview_limit: non_neg_integer(),
site_limit: non_neg_integer(),
team_member_limit: non_neg_integer() | :unlimited,
volume: String.t(),
monthly_cost: String.t() | nil,
yearly_cost: String.t() | nil,
@ -36,15 +37,22 @@ defmodule Plausible.Billing.Plans do
path
|> File.read!()
|> Jason.decode!(keys: :atoms!)
|> Enum.map(
&Map.put(
&1,
:volume,
PlausibleWeb.StatsView.large_number_format(&1.monthly_pageview_limit)
)
)
|> Enum.map(&Map.put(&1, :kind, String.to_atom(&1.kind)))
|> Enum.map(&struct!(Plausible.Billing.Plan, &1))
|> Enum.map(fn raw ->
team_member_limit =
case raw.team_member_limit do
number when is_integer(number) -> number
"unlimited" -> :unlimited
_any -> raise ArgumentError, "Failed to parse team member limit from plan JSON files"
end
volume = PlausibleWeb.StatsView.large_number_format(raw.monthly_pageview_limit)
raw
|> Map.put(:volume, volume)
|> Map.put(:kind, String.to_atom(raw.kind))
|> Map.put(:team_member_limit, team_member_limit)
|> then(&struct!(Plausible.Billing.Plan, &1))
end)
Module.put_attribute(__MODULE__, f, contents)
Module.put_attribute(__MODULE__, :external_resource, path)

View File

@ -3,6 +3,7 @@ defmodule Plausible.Billing.Quota do
This module provides functions to work with plans usage and limits.
"""
import Ecto.Query
alias Plausible.Billing.Plans
@limit_sites_since ~D[2021-05-05]
@ -79,6 +80,55 @@ defmodule Plausible.Billing.Quota do
|> Tuple.sum()
end
@team_member_limit_for_trials 5
@spec team_member_limit(Plausible.Auth.User.t()) :: non_neg_integer()
@doc """
Returns the limit of team members a user can have in their sites.
"""
def team_member_limit(user) do
user = Plausible.Users.with_subscription(user)
case Plans.get_subscription_plan(user.subscription) do
%Plausible.Billing.EnterprisePlan{} -> :unlimited
%Plausible.Billing.Plan{team_member_limit: limit} -> limit
:free_10k -> :unlimited
nil -> @team_member_limit_for_trials
end
end
@spec team_member_usage(Plausible.Auth.User.t()) :: integer()
@doc """
Returns the total count of team members and pending invitations associated
with the user's sites.
"""
def team_member_usage(user) do
owned_sites_query =
from sm in Plausible.Site.Membership,
where: sm.role == :owner and sm.user_id == ^user.id,
select: %{site_id: sm.site_id}
team_members_query =
from os in subquery(owned_sites_query),
inner_join: sm in Plausible.Site.Membership,
on: sm.site_id == os.site_id,
inner_join: u in assoc(sm, :user),
select: %{email: u.email}
invitations_and_team_members_query =
from i in Plausible.Auth.Invitation,
inner_join: os in subquery(owned_sites_query),
on: i.site_id == os.site_id,
select: %{email: i.email},
union: ^team_members_query
query =
from itm in subquery(invitations_and_team_members_query),
where: itm.email != ^user.email,
select: count(itm.email, :distinct)
Plausible.Repo.one(query)
end
@spec within_limit?(non_neg_integer(), non_neg_integer() | :unlimited) :: boolean()
@doc """
Returns whether the limit has been exceeded or not.

View File

@ -49,6 +49,57 @@ defmodule Plausible.Sites do
end
end
@spec invite(Site.t(), Plausible.Auth.User.t(), String.t(), atom()) ::
{:ok, Plausible.Auth.Invitation.t()}
| {:error, Ecto.Changeset.t()}
| {:error, :already_a_member}
| {:error, {:over_limit, non_neg_integer()}}
def invite(site, inviter, invitee_email, role) do
Repo.transaction(fn ->
do_invite(site, inviter, invitee_email, role)
end)
end
defp do_invite(site, inviter, invitee_email, role) do
send_invitation_email = fn invitation, invitee ->
invitation = Repo.preload(invitation, [:site, :inviter])
email =
if invitee,
do: PlausibleWeb.Email.existing_user_invitation(invitation),
else: PlausibleWeb.Email.new_user_invitation(invitation)
Plausible.Mailer.send(email)
end
ensure_new_membership = fn site, invitee ->
if invitee && is_member?(invitee.id, site), do: {:error, :already_a_member}, else: :ok
end
check_limit = fn site ->
owner = owner_for(site)
usage = Plausible.Billing.Quota.team_member_usage(owner)
limit = Plausible.Billing.Quota.team_member_limit(owner)
if Plausible.Billing.Quota.within_limit?(usage, limit),
do: :ok,
else: {:error, {:over_limit, limit}}
end
attrs = %{email: invitee_email, role: role, site_id: site.id, inviter_id: inviter.id}
with :ok <- check_limit.(site),
invitee <- Plausible.Auth.find_user_by(email: invitee_email),
:ok <- ensure_new_membership.(site, invitee),
%Ecto.Changeset{} = changeset <- Plausible.Auth.Invitation.new(attrs),
{:ok, invitation} <- Repo.insert(changeset) do
send_invitation_email.(invitation, invitee)
invitation
else
{:error, cause} -> Repo.rollback(cause)
end
end
@spec stats_start_date(Plausible.Site.t()) :: Date.t() | nil
@doc """
Returns the date of the first event of the given site, or `nil` if the site

View File

@ -27,10 +27,18 @@ defmodule PlausibleWeb.Components.Billing do
<tr {@rest}>
<td class={["py-4 text-sm whitespace-nowrap text-left", @pad && "pl-6"]}><%= @title %></td>
<td class="py-4 text-sm whitespace-nowrap text-right">
<%= Cldr.Number.to_string!(@usage) %>
<%= if is_number(@limit), do: "/ #{Cldr.Number.to_string!(@limit)}" %>
<%= render_quota(@usage) %>
<%= if @limit, do: "/ #{render_quota(@limit)}" %>
</td>
</tr>
"""
end
defp render_quota(quota) do
case quota do
quota when is_number(quota) -> Cldr.Number.to_string!(quota)
:unlimited -> ""
nil -> ""
end
end
end

View File

@ -498,6 +498,8 @@ defmodule PlausibleWeb.AuthController do
subscription: user.subscription,
invoices: Plausible.Billing.paddle_api().get_invoices(user.subscription),
theme: user.theme || "system",
team_member_limit: Plausible.Billing.Quota.team_member_limit(user),
team_member_usage: Plausible.Billing.Quota.team_member_usage(user),
site_limit: Plausible.Billing.Quota.site_limit(user),
site_usage: Plausible.Billing.Quota.site_usage(user),
total_pageview_limit: Plausible.Billing.Quota.monthly_pageview_limit(user.subscription),

View File

@ -39,62 +39,46 @@ defmodule PlausibleWeb.Site.MembershipController do
def invite_member(conn, %{"email" => email, "role" => role}) do
site_domain = conn.assigns[:site].domain
site = Sites.get_for_user!(conn.assigns[:current_user].id, site_domain)
user = Plausible.Auth.find_user_by(email: email)
if user && Sites.is_member?(user.id, site) do
msg = "Cannot send invite because #{user.email} is already a member of #{site.domain}"
case Sites.invite(site, conn.assigns.current_user, email, role) do
{:ok, invitation} ->
conn
|> put_flash(
:success,
"#{email} has been invited to #{site_domain} as #{PlausibleWeb.SiteView.with_indefinite_article("#{invitation.role}")}"
)
|> redirect(to: Routes.site_path(conn, :settings_people, site.domain))
render(conn, "invite_member_form.html",
error: msg,
site: site,
layout: {PlausibleWeb.LayoutView, "focus.html"},
skip_plausible_tracking: true
)
else
case Repo.insert(
Invitation.new(%{
email: email,
role: role,
site_id: site.id,
inviter_id: conn.assigns[:current_user].id
})
) do
{:ok, invitation} ->
invitation = Repo.preload(invitation, [:site, :inviter])
{:error, :already_a_member} ->
render(conn, "invite_member_form.html",
error: "Cannot send invite because #{email} is already a member of #{site.domain}",
site: site,
layout: {PlausibleWeb.LayoutView, "focus.html"},
skip_plausible_tracking: true
)
email_template =
if user do
PlausibleWeb.Email.existing_user_invitation(invitation)
else
PlausibleWeb.Email.new_user_invitation(invitation)
end
{:error, {:over_limit, limit}} ->
render(conn, "invite_member_form.html",
error:
"Your account is limited to #{limit} team members. You can upgrade your plan to increase this limit.",
site: site,
layout: {PlausibleWeb.LayoutView, "focus.html"},
skip_plausible_tracking: true
)
Plausible.Mailer.send(email_template)
{:error, %Ecto.Changeset{} = changeset} ->
error_msg =
case changeset.errors[:invitation] do
{"already sent", _} ->
"This invitation has been already sent. To send again, remove it from pending invitations first."
conn
|> put_flash(
:success,
"#{email} has been invited to #{site_domain} as #{PlausibleWeb.SiteView.with_indefinite_article(role)}"
)
|> redirect(to: Routes.site_path(conn, :settings_people, site.domain))
_ ->
"Something went wrong."
end
{:error, changeset} ->
error_msg =
case changeset.errors[:invitation] do
{"already sent", _} ->
"This invitation has been already sent. To send again, remove it from pending invitations first."
_ ->
"Something went wrong."
end
conn
|> put_flash(
:error,
error_msg
)
|> redirect(to: Routes.site_path(conn, :settings_people, site.domain))
end
conn
|> put_flash(:error, error_msg)
|> redirect(to: Routes.site_path(conn, :settings_people, site.domain))
end
end

View File

@ -151,6 +151,11 @@
usage={@site_usage}
limit={@site_limit}
/>
<PlausibleWeb.Components.Billing.usage_and_limits_row
title="Team members"
usage={@team_member_usage}
limit={@team_member_limit}
/>
</PlausibleWeb.Components.Billing.usage_and_limits_table>
</article>

View File

@ -6,7 +6,8 @@
"monthly_product_id":"558018",
"yearly_cost":"$48",
"yearly_product_id":"572810",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -15,7 +16,8 @@
"monthly_product_id":"558745",
"yearly_cost":"$96",
"yearly_product_id":"590752",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -24,7 +26,8 @@
"monthly_product_id":"597485",
"yearly_cost":"$144",
"yearly_product_id":"597486",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -33,7 +36,8 @@
"monthly_product_id":"597487",
"yearly_cost":"$216",
"yearly_product_id":"597488",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -42,7 +46,8 @@
"monthly_product_id":"597642",
"yearly_cost":"$384",
"yearly_product_id":"597643",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -51,7 +56,8 @@
"monthly_product_id":"597309",
"yearly_cost":"$552",
"yearly_product_id":"597310",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -60,7 +66,8 @@
"monthly_product_id":"597311",
"yearly_cost":"$792",
"yearly_product_id":"597312",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -69,7 +76,8 @@
"monthly_product_id":"642352",
"yearly_cost":"$1200",
"yearly_product_id":"642354",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -78,7 +86,8 @@
"monthly_product_id":"642355",
"yearly_cost":"$1800",
"yearly_product_id":"642356",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -87,6 +96,7 @@
"monthly_product_id":"650652",
"yearly_cost":"$2640",
"yearly_product_id":"650653",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
}
]

View File

@ -6,7 +6,8 @@
"monthly_product_id":"654177",
"yearly_cost":"$60",
"yearly_product_id":"653232",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -15,7 +16,8 @@
"monthly_product_id":"654178",
"yearly_cost":"$120",
"yearly_product_id":"653234",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -24,7 +26,8 @@
"monthly_product_id":"653237",
"yearly_cost":"$200",
"yearly_product_id":"653236",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -33,7 +36,8 @@
"monthly_product_id":"653238",
"yearly_cost":"$300",
"yearly_product_id":"653239",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -42,7 +46,8 @@
"monthly_product_id":"653240",
"yearly_cost":"$500",
"yearly_product_id":"653242",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -51,7 +56,8 @@
"monthly_product_id":"653253",
"yearly_cost":"$700",
"yearly_product_id":"653254",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -60,7 +66,8 @@
"monthly_product_id":"653255",
"yearly_cost":"$1000",
"yearly_product_id":"653256",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -69,7 +76,8 @@
"monthly_product_id":"654181",
"yearly_cost":"$1500",
"yearly_product_id":"653257",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -78,7 +86,8 @@
"monthly_product_id":"654182",
"yearly_cost":"$2250",
"yearly_product_id":"653258",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -87,6 +96,7 @@
"monthly_product_id":"654183",
"yearly_cost":"$3300",
"yearly_product_id":"653259",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
}
]

View File

@ -6,7 +6,8 @@
"monthly_product_id":"749342",
"yearly_cost":"$90",
"yearly_product_id":"749343",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -15,7 +16,8 @@
"monthly_product_id":"749344",
"yearly_cost":"$190",
"yearly_product_id":"749345",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -24,7 +26,8 @@
"monthly_product_id":"749346",
"yearly_cost":"$290",
"yearly_product_id":"749347",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -33,7 +36,8 @@
"monthly_product_id":"749348",
"yearly_cost":"$490",
"yearly_product_id":"749349",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -42,7 +46,8 @@
"monthly_product_id":"749350",
"yearly_cost":"$690",
"yearly_product_id":"749352",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -51,7 +56,8 @@
"monthly_product_id":"749353",
"yearly_cost":"$890",
"yearly_product_id":"749355",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -60,7 +66,8 @@
"monthly_product_id":"749356",
"yearly_cost":"$1290",
"yearly_product_id":"749357",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
},
{
"kind":"growth",
@ -69,6 +76,7 @@
"monthly_product_id":"749358",
"yearly_cost":"$1690",
"yearly_product_id":"749359",
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
}
]

View File

@ -6,7 +6,8 @@
"monthly_product_id":"63842",
"yearly_cost":"$90",
"yearly_product_id":"63859",
"site_limit":10
"site_limit":10,
"team_member_limit":5
},
{
"kind":"growth",
@ -15,7 +16,8 @@
"monthly_product_id":"63843",
"yearly_cost":"$190",
"yearly_product_id":"63860",
"site_limit":10
"site_limit":10,
"team_member_limit":5
},
{
"kind":"growth",
@ -24,7 +26,8 @@
"monthly_product_id":"63844",
"yearly_cost":"$290",
"yearly_product_id":"63861",
"site_limit":10
"site_limit":10,
"team_member_limit":5
},
{
"kind":"growth",
@ -33,7 +36,8 @@
"monthly_product_id":"63845",
"yearly_cost":"$490",
"yearly_product_id":"63862",
"site_limit":10
"site_limit":10,
"team_member_limit":5
},
{
"kind":"growth",
@ -42,7 +46,8 @@
"monthly_product_id":"63846",
"yearly_cost":"$690",
"yearly_product_id":"63863",
"site_limit":10
"site_limit":10,
"team_member_limit":5
},
{
"kind":"growth",
@ -51,7 +56,8 @@
"monthly_product_id":"63847",
"yearly_cost":"$890",
"yearly_product_id":"63864",
"site_limit":10
"site_limit":10,
"team_member_limit":5
},
{
"kind":"growth",
@ -60,7 +66,8 @@
"monthly_product_id":"63848",
"yearly_cost":"$1290",
"yearly_product_id":"63865",
"site_limit":10
"site_limit":10,
"team_member_limit":5
},
{
"kind":"growth",
@ -69,7 +76,8 @@
"monthly_product_id":"63849",
"yearly_cost":"$1690",
"yearly_product_id":"63866",
"site_limit":10
"site_limit":10,
"team_member_limit":5
},
{
"kind":"business",
@ -78,7 +86,8 @@
"monthly_product_id":"63850",
"yearly_cost":"$100",
"yearly_product_id":"63867",
"site_limit":50
"site_limit":50,
"team_member_limit":50
},
{
"kind":"business",
@ -87,7 +96,8 @@
"monthly_product_id":"63851",
"yearly_cost":"$200",
"yearly_product_id":"63868",
"site_limit":50
"site_limit":50,
"team_member_limit":50
},
{
"kind":"business",
@ -96,7 +106,8 @@
"monthly_product_id":"63852",
"yearly_cost":"$300",
"yearly_product_id":"63869",
"site_limit":50
"site_limit":50,
"team_member_limit":50
},
{
"kind":"business",
@ -105,7 +116,8 @@
"monthly_product_id":"63853",
"yearly_cost":"$500",
"yearly_product_id":"63870",
"site_limit":50
"site_limit":50,
"team_member_limit":50
},
{
"kind":"business",
@ -114,7 +126,8 @@
"monthly_product_id":"63854",
"yearly_cost":"$700",
"yearly_product_id":"63871",
"site_limit":50
"site_limit":50,
"team_member_limit":50
},
{
"kind":"business",
@ -123,7 +136,8 @@
"monthly_product_id":"63855",
"yearly_cost":"$900",
"yearly_product_id":"63872",
"site_limit":50
"site_limit":50,
"team_member_limit":50
},
{
"kind":"business",
@ -132,7 +146,8 @@
"monthly_product_id":"63856",
"yearly_cost":"$1300",
"yearly_product_id":"63873",
"site_limit":50
"site_limit":50,
"team_member_limit":50
},
{
"kind":"business",
@ -141,6 +156,7 @@
"monthly_product_id":"63857",
"yearly_cost":"$1700",
"yearly_product_id":"63874",
"site_limit":50
"site_limit":50,
"team_member_limit":50
}
]

View File

@ -6,6 +6,7 @@
"yearly_cost":"$4800",
"monthly_product_id":null,
"monthly_cost":null,
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
}
]

View File

@ -6,6 +6,7 @@
"monthly_cost":"$250",
"yearly_product_id":null,
"yearly_cost":null,
"site_limit":50
"site_limit":50,
"team_member_limit":"unlimited"
}
]

View File

@ -41,9 +41,6 @@ defmodule Plausible.Billing.QuotaTest do
test "returns 50 when user in on trial" do
user = insert(:user, trial_expiry_date: Timex.shift(Timex.now(), days: 7))
assert 50 == Quota.site_limit(user)
user = insert(:user, trial_expiry_date: Timex.shift(Timex.now(), days: -7))
assert 50 == Quota.site_limit(user)
end
test "returns the subscription limit for enterprise users who have not paid yet" do
@ -193,4 +190,155 @@ defmodule Plausible.Billing.QuotaTest do
assert Quota.monthly_pageview_usage(user) == 0
end
end
describe "team_member_usage/1" do
test "returns the number of members in all of the sites the user owns" do
me = insert(:user)
_site_i_own_1 =
insert(:site,
memberships: [
build(:site_membership, user: me, role: :owner),
build(:site_membership, user: build(:user), role: :viewer)
]
)
_site_i_own_2 =
insert(:site,
memberships: [
build(:site_membership, user: me, role: :owner),
build(:site_membership, user: build(:user), role: :admin),
build(:site_membership, user: build(:user), role: :viewer)
]
)
_site_i_own_3 =
insert(:site,
memberships: [
build(:site_membership, user: me, role: :owner)
]
)
_site_i_have_access =
insert(:site,
memberships: [
build(:site_membership, user: me, role: :viewer),
build(:site_membership, user: build(:user), role: :viewer),
build(:site_membership, user: build(:user), role: :viewer),
build(:site_membership, user: build(:user), role: :viewer)
]
)
assert Quota.team_member_usage(me) == 3
end
test "counts the same email address as one team member" do
me = insert(:user)
joe = insert(:user, email: "joe@plausible.test")
_site_i_own_1 =
insert(:site,
memberships: [
build(:site_membership, user: me, role: :owner),
build(:site_membership, user: joe, role: :viewer)
]
)
_site_i_own_2 =
insert(:site,
memberships: [
build(:site_membership, user: me, role: :owner),
build(:site_membership, user: build(:user), role: :admin),
build(:site_membership, user: joe, role: :viewer)
]
)
site_i_own_3 = insert(:site, memberships: [build(:site_membership, user: me, role: :owner)])
insert(:invitation, site: site_i_own_3, inviter: me, email: "joe@plausible.test")
assert Quota.team_member_usage(me) == 2
end
test "counts pending invitations as team members" do
me = insert(:user)
member = insert(:user)
site_i_own =
insert(:site,
memberships: [
build(:site_membership, user: me, role: :owner),
build(:site_membership, user: member, role: :admin)
]
)
site_i_have_access =
insert(:site, memberships: [build(:site_membership, user: me, role: :admin)])
insert(:invitation, site: site_i_own, inviter: me)
insert(:invitation, site: site_i_own, inviter: member)
insert(:invitation, site: site_i_have_access, inviter: me)
assert Quota.team_member_usage(me) == 3
end
test "returns zero when user does not have any site" do
me = insert(:user)
assert Quota.team_member_usage(me) == 0
end
test "does not count email report recipients as team members" do
me = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: me, role: :owner)])
insert(:weekly_report,
site: site,
recipients: ["adam@plausible.test", "vini@plausible.test"]
)
assert Quota.team_member_usage(me) == 0
end
end
describe "team_member_limit/1" do
test "returns unlimited when user is on an old plan" do
user_on_v1 = insert(:user, subscription: build(:subscription, paddle_plan_id: @v1_plan_id))
user_on_v2 = insert(:user, subscription: build(:subscription, paddle_plan_id: @v2_plan_id))
user_on_v3 = insert(:user, subscription: build(:subscription, paddle_plan_id: @v3_plan_id))
assert :unlimited == Quota.team_member_limit(user_on_v1)
assert :unlimited == Quota.team_member_limit(user_on_v2)
assert :unlimited == Quota.team_member_limit(user_on_v3)
end
test "returns unlimited when user is on free_10k plan" do
user = insert(:user, subscription: build(:subscription, paddle_plan_id: "free_10k"))
assert :unlimited == Quota.team_member_limit(user)
end
test "returns unlimited when user is on an enterprise plan" do
user = insert(:user)
enterprise_plan = insert(:enterprise_plan, user_id: user.id)
_subscription =
insert(:subscription, user_id: user.id, paddle_plan_id: enterprise_plan.paddle_plan_id)
assert :unlimited == Quota.team_member_limit(user)
end
test "returns 5 when user in on trial" do
user = insert(:user, trial_expiry_date: Timex.shift(Timex.now(), days: 7))
assert 5 == Quota.team_member_limit(user)
end
test "is unlimited for enterprise customers" do
user =
insert(:user,
enterprise_plan: build(:enterprise_plan, paddle_plan_id: "123321"),
subscription: build(:subscription, paddle_plan_id: "123321")
)
assert :unlimited == Quota.team_member_limit(user)
end
end
end

View File

@ -1,5 +1,6 @@
defmodule Plausible.SitesTest do
use Plausible.DataCase
use Bamboo.Test
alias Plausible.Sites
@ -67,4 +68,79 @@ defmodule Plausible.SitesTest do
assert Sites.has_stats?(site)
end
end
describe "invite/4" do
test "creates an invitation" do
inviter = insert(:user)
invitee = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:ok, %Plausible.Auth.Invitation{}} =
Sites.invite(site, inviter, invitee.email, :viewer)
end
test "returns validation errors" do
inviter = insert(:user)
invitee = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:error, changeset} = Sites.invite(site, inviter, invitee.email, :invalid_role)
assert {"is invalid", _} = changeset.errors[:role]
end
test "returns error when user is already a member" do
inviter = insert(:user)
invitee = insert(:user)
site =
insert(:site,
memberships: [
build(:site_membership, user: inviter, role: :owner),
build(:site_membership, user: invitee, role: :viewer)
]
)
assert {:error, :already_a_member} = Sites.invite(site, inviter, invitee.email, :viewer)
assert {:error, :already_a_member} = Sites.invite(site, inviter, inviter.email, :viewer)
end
test "sends invitation email for existing users" do
[inviter, invitee] = insert_list(2, :user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:ok, %Plausible.Auth.Invitation{}} =
Sites.invite(site, inviter, invitee.email, :viewer)
assert_email_delivered_with(
to: [nil: invitee.email],
subject: "[Plausible Analytics] You've been invited to #{site.domain}"
)
end
test "sends invitation email for new users" do
inviter = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:ok, %Plausible.Auth.Invitation{}} =
Sites.invite(site, inviter, "vini@plausible.test", :viewer)
assert_email_delivered_with(
to: [nil: "vini@plausible.test"],
subject: "[Plausible Analytics] You've been invited to #{site.domain}"
)
end
test "returns error when owner is over their team member limit" do
[owner, inviter, invitee] = insert_list(3, :user)
memberships =
[
build(:site_membership, user: owner, role: :owner),
build(:site_membership, user: inviter, role: :admin)
] ++ build_list(4, :site_membership)
site = insert(:site, memberships: memberships)
assert {:error, {:over_limit, 5}} = Sites.invite(site, inviter, invitee.email, :viewer)
end
end
end

View File

@ -31,6 +31,22 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do
assert redirected_to(conn) == "/#{site.domain}/settings/people"
end
test "fails to create invitation when is over limit", %{conn: conn, user: user} do
memberships =
[build(:site_membership, user: user, role: :owner)] ++ build_list(5, :site_membership)
site = insert(:site, memberships: memberships)
conn =
post(conn, "/sites/#{site.domain}/memberships/invite", %{
email: "john.doe@example.com",
role: "admin"
})
assert html_response(conn, 200) =~
"Your account is limited to 5 team members. You can upgrade your plan to increase this limit."
end
test "fails to create invitation with insufficient permissions", %{conn: conn, user: user} do
site = insert(:site, memberships: [build(:site_membership, user: user, role: :viewer)])
@ -95,7 +111,13 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do
test "renders form with error if the invitee is already a member", %{conn: conn, user: user} do
second_member = insert(:user)
site = insert(:site, members: [user, second_member])
memberships = [
build(:site_membership, user: user, role: :owner),
build(:site_membership, user: second_member)
]
site = insert(:site, memberships: memberships)
conn =
post(conn, "/sites/#{site.domain}/memberships/invite", %{

View File

@ -32,7 +32,10 @@ defmodule Plausible.Factory do
end
def site_membership_factory do
%Plausible.Site.Membership{}
%Plausible.Site.Membership{
user: build(:user),
role: :viewer
}
end
def ch_session_factory do