diff --git a/lib/plausible/auth/invitation.ex b/lib/plausible/auth/invitation.ex
index 5a24b11ba..d0a2eb70f 100644
--- a/lib/plausible/auth/invitation.ex
+++ b/lib/plausible/auth/invitation.ex
@@ -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
diff --git a/lib/plausible/billing/plans.ex b/lib/plausible/billing/plans.ex
index 6effc2b4b..704a75ca1 100644
--- a/lib/plausible/billing/plans.ex
+++ b/lib/plausible/billing/plans.ex
@@ -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)
diff --git a/lib/plausible/billing/quota.ex b/lib/plausible/billing/quota.ex
index bb5250bfc..94b5f080f 100644
--- a/lib/plausible/billing/quota.ex
+++ b/lib/plausible/billing/quota.ex
@@ -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.
diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex
index 122e086fa..66f39f8d3 100644
--- a/lib/plausible/sites.ex
+++ b/lib/plausible/sites.ex
@@ -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
diff --git a/lib/plausible_web/components/billing.ex b/lib/plausible_web/components/billing.ex
index fdb5724f1..b97a9d4a0 100644
--- a/lib/plausible_web/components/billing.ex
+++ b/lib/plausible_web/components/billing.ex
@@ -27,10 +27,18 @@ defmodule PlausibleWeb.Components.Billing do
<%= @title %> |
- <%= Cldr.Number.to_string!(@usage) %>
- <%= if is_number(@limit), do: "/ #{Cldr.Number.to_string!(@limit)}" %>
+ <%= render_quota(@usage) %>
+ <%= if @limit, do: "/ #{render_quota(@limit)}" %>
|
"""
end
+
+ defp render_quota(quota) do
+ case quota do
+ quota when is_number(quota) -> Cldr.Number.to_string!(quota)
+ :unlimited -> "∞"
+ nil -> ""
+ end
+ end
end
diff --git a/lib/plausible_web/controllers/auth_controller.ex b/lib/plausible_web/controllers/auth_controller.ex
index 416fe6101..5d44be0f2 100644
--- a/lib/plausible_web/controllers/auth_controller.ex
+++ b/lib/plausible_web/controllers/auth_controller.ex
@@ -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),
diff --git a/lib/plausible_web/controllers/site/membership_controller.ex b/lib/plausible_web/controllers/site/membership_controller.ex
index aed8b695e..ae721ee4f 100644
--- a/lib/plausible_web/controllers/site/membership_controller.ex
+++ b/lib/plausible_web/controllers/site/membership_controller.ex
@@ -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
diff --git a/lib/plausible_web/templates/auth/user_settings.html.heex b/lib/plausible_web/templates/auth/user_settings.html.heex
index c54352634..289bc91a5 100644
--- a/lib/plausible_web/templates/auth/user_settings.html.heex
+++ b/lib/plausible_web/templates/auth/user_settings.html.heex
@@ -151,6 +151,11 @@
usage={@site_usage}
limit={@site_limit}
/>
+
diff --git a/priv/plans_v1.json b/priv/plans_v1.json
index 9673f5637..4cfe094f5 100644
--- a/priv/plans_v1.json
+++ b/priv/plans_v1.json
@@ -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"
}
]
diff --git a/priv/plans_v2.json b/priv/plans_v2.json
index c6d193bed..d3643daf4 100644
--- a/priv/plans_v2.json
+++ b/priv/plans_v2.json
@@ -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"
}
]
diff --git a/priv/plans_v3.json b/priv/plans_v3.json
index c6677d3b7..3fa6d0736 100644
--- a/priv/plans_v3.json
+++ b/priv/plans_v3.json
@@ -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"
}
]
diff --git a/priv/sandbox_plans.json b/priv/sandbox_plans.json
index 6ff6096e1..483bf2dea 100644
--- a/priv/sandbox_plans.json
+++ b/priv/sandbox_plans.json
@@ -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
}
]
diff --git a/priv/unlisted_plans_v1.json b/priv/unlisted_plans_v1.json
index e8909e8d1..75e2cf9da 100644
--- a/priv/unlisted_plans_v1.json
+++ b/priv/unlisted_plans_v1.json
@@ -6,6 +6,7 @@
"yearly_cost":"$4800",
"monthly_product_id":null,
"monthly_cost":null,
- "site_limit":50
+ "site_limit":50,
+ "team_member_limit":"unlimited"
}
]
diff --git a/priv/unlisted_plans_v2.json b/priv/unlisted_plans_v2.json
index 8d825c6a5..9c4e9d477 100644
--- a/priv/unlisted_plans_v2.json
+++ b/priv/unlisted_plans_v2.json
@@ -6,6 +6,7 @@
"monthly_cost":"$250",
"yearly_product_id":null,
"yearly_cost":null,
- "site_limit":50
+ "site_limit":50,
+ "team_member_limit":"unlimited"
}
]
diff --git a/test/plausible/billing/quota_test.exs b/test/plausible/billing/quota_test.exs
index 89967bfdf..6a883a601 100644
--- a/test/plausible/billing/quota_test.exs
+++ b/test/plausible/billing/quota_test.exs
@@ -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
diff --git a/test/plausible/site/sites_test.exs b/test/plausible/site/sites_test.exs
index 2f69d5501..42c941b64 100644
--- a/test/plausible/site/sites_test.exs
+++ b/test/plausible/site/sites_test.exs
@@ -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
diff --git a/test/plausible_web/controllers/site/membership_controller_test.exs b/test/plausible_web/controllers/site/membership_controller_test.exs
index f87625cb4..8afbefc6b 100644
--- a/test/plausible_web/controllers/site/membership_controller_test.exs
+++ b/test/plausible_web/controllers/site/membership_controller_test.exs
@@ -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", %{
diff --git a/test/support/factory.ex b/test/support/factory.ex
index fc74a57bd..591a69918 100644
--- a/test/support/factory.ex
+++ b/test/support/factory.ex
@@ -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