mirror of
https://github.com/plausible/analytics.git
synced 2024-12-22 09:01:40 +03:00
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:
parent
b3ff695797
commit
d22c011aa3
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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),
|
||||
|
@ -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
|
||||
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
@ -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
|
||||
}
|
||||
]
|
||||
|
@ -6,6 +6,7 @@
|
||||
"yearly_cost":"$4800",
|
||||
"monthly_product_id":null,
|
||||
"monthly_cost":null,
|
||||
"site_limit":50
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
}
|
||||
]
|
||||
|
@ -6,6 +6,7 @@
|
||||
"monthly_cost":"$250",
|
||||
"yearly_product_id":null,
|
||||
"yearly_cost":null,
|
||||
"site_limit":50
|
||||
"site_limit":50,
|
||||
"team_member_limit":"unlimited"
|
||||
}
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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", %{
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user