Implement support for multiple team owners and multiple teams per user (#5008)

* Add tests for `Teams.get_or_create/1` and `Teams.get_by_owner/1`

* Start populating `current_team` in assigns fetching value from session

* Clean up team passing in invitation services

* Make site transfer service handle multi-team scenario

* Handle multi-team and permission transfer errors on controller level

* Handle multi-teams in site creation on service and controller level

* Drop validation limiting full membership to a single team

* Make user deletion account for public team ownership

* Adjust feature availability checks for Stats API key

* Use current_team when determining limits on site transfer invitation

* Adjust trial upgrade email submission to account for multiple owners

* Remove unnecessary `Teams.load_for_site/1`

* Spike renaming `owner` and `ownership` relationships to plural versions

* Make HelpScout integration handle owner of multiple teams gracefully

* Add FIXME note

* Resolve paddle callback issue by always provisioning a new team when none passed

* Set `current_team` as `my_team` only when user is an owner

* Implement basics of Teams CRM

* Extend Teams CRM

* Further adjust User and Site CRM and refine Team CRM

* Convert Enterprise Plan CRM to refer to team directly and not via user

* Remove unused virtual fields from User schema

* Add note to HelpScout integration

* Allow listing multiple owners under Site Settings / People

* Remove unused User schema relations

* Fix current team fetch in auth plug and context

* Implement basic team switcher

* Ensure (site) editor role is properly handled in site actions auth

* Don't set `site_limit_exceeded` error marker on `permission_denied` error

* Link from HS integration to Team CRM instead of User CRM when available

* Ensure consistent ordering of preloaded owners

* Add `with_subscription` preload for optimisitation

* Add ability to search sites by team identifier

* Add ability to pick team when transferring ownership directly

* Fix failing HelpScout tests

* Scope by team when listing sites in dashboard and via API (optional)

* Add ability to search by team identifier in plans CRM lookup widget

* Add subscription plan, status and grace period to team status info

* Expose teams list in user CRM edit form and fix team details CRM view

* Fix Team Switcher styling

* Reorganise header nav menu

* Avoid additional queries when authenticating user

* Hide the pay/site transfer message on lock screen when teams FF is on

---------

Co-authored-by: Adam Rutkowski <hq@mtod.org>
This commit is contained in:
Adrian Gruntkowski 2025-02-19 10:33:25 +01:00 committed by GitHub
parent 7ae88c2c97
commit bf010a1537
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
91 changed files with 1980 additions and 654 deletions

View File

@ -802,6 +802,11 @@ if config_env() in [:dev, :staging, :prod, :test] do
api_key: [schema: Plausible.Auth.ApiKey, admin: Plausible.Auth.ApiKeyAdmin] api_key: [schema: Plausible.Auth.ApiKey, admin: Plausible.Auth.ApiKeyAdmin]
] ]
], ],
teams: [
resources: [
team: [schema: Plausible.Teams.Team, admin: Plausible.Teams.TeamAdmin]
]
],
sites: [ sites: [
resources: [ resources: [
site: [schema: Plausible.Site, admin: Plausible.SiteAdmin] site: [schema: Plausible.Site, admin: Plausible.SiteAdmin]

View File

@ -90,24 +90,45 @@ defmodule Plausible.HelpScout do
plan = Billing.Plans.get_subscription_plan(team.subscription) plan = Billing.Plans.get_subscription_plan(team.subscription)
{team, team.subscription, plan} {team, team.subscription, plan}
{:error, :multiple_teams} ->
# NOTE: We might consider exposing the other teams later on
[team | _] = Plausible.Teams.Users.owned_teams(user)
team = Plausible.Teams.with_subscription(team)
plan = Billing.Plans.get_subscription_plan(team.subscription)
{team, team.subscription, plan}
{:error, :no_team} -> {:error, :no_team} ->
{nil, nil, nil} {nil, nil, nil}
end end
status_link =
if team do
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :teams, :team, team.id)
else
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :auth, :user, user.id)
end
sites_link =
if team do
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site,
custom_search: team.identifier
)
else
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site,
custom_search: user.email
)
end
{:ok, {:ok,
%{ %{
email: user.email, email: user.email,
notes: user.notes, notes: user.notes,
status_label: status_label(team, subscription), status_label: status_label(team, subscription),
status_link: status_link: status_link,
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :auth, :user, user.id),
plan_label: plan_label(subscription, plan), plan_label: plan_label(subscription, plan),
plan_link: plan_link(subscription), plan_link: plan_link(subscription),
sites_count: Plausible.Teams.owned_sites_count(team), sites_count: Plausible.Teams.owned_sites_count(team),
sites_link: sites_link: sites_link
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site,
custom_search: user.email
)
}} }}
end end
end end

View File

@ -8,16 +8,18 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
alias Plausible.Sites alias Plausible.Sites
alias Plausible.Goal alias Plausible.Goal
alias Plausible.Goals alias Plausible.Goals
alias Plausible.Teams
alias PlausibleWeb.Api.Helpers, as: H alias PlausibleWeb.Api.Helpers, as: H
@pagination_opts [cursor_fields: [{:id, :desc}], limit: 100, maximum_limit: 1000] @pagination_opts [cursor_fields: [{:id, :desc}], limit: 100, maximum_limit: 1000]
def index(conn, params) do def index(conn, params) do
team = Teams.get(params["team_id"])
user = conn.assigns.current_user user = conn.assigns.current_user
page = page =
user user
|> Sites.for_user_query() |> Sites.for_user_query(team)
|> paginate(params, @pagination_opts) |> paginate(params, @pagination_opts)
json(conn, %{ json(conn, %{
@ -30,7 +32,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
user = conn.assigns.current_user user = conn.assigns.current_user
with {:ok, site_id} <- expect_param_key(params, "site_id"), with {:ok, site_id} <- expect_param_key(params, "site_id"),
{:ok, site} <- get_site(user, site_id, [:owner, :admin, :viewer]) do {:ok, site} <- get_site(user, site_id, [:owner, :admin, :editor, :viewer]) do
page = page =
site site
|> Plausible.Goals.for_site_query() |> Plausible.Goals.for_site_query()
@ -60,8 +62,9 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
def create_site(conn, params) do def create_site(conn, params) do
user = conn.assigns.current_user user = conn.assigns.current_user
team = Plausible.Teams.get(params["team_id"])
case Sites.create(user, params) do case Sites.create(user, params, team) do
{:ok, %{site: site}} -> {:ok, %{site: site}} ->
json(conn, site) json(conn, site)
@ -73,6 +76,20 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
"Your account has reached the limit of #{limit} sites. To unlock more sites, please upgrade your subscription." "Your account has reached the limit of #{limit} sites. To unlock more sites, please upgrade your subscription."
}) })
{:error, _, :permission_denied, _} ->
conn
|> put_status(403)
|> json(%{
error: "You can't add sites to the selected team."
})
{:error, _, :multiple_teams, _} ->
conn
|> put_status(400)
|> json(%{
error: "You must select a team with 'team_id' parameter."
})
{:error, _, changeset, _} -> {:error, _, changeset, _} ->
conn conn
|> put_status(400) |> put_status(400)
@ -81,7 +98,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end end
def get_site(conn, %{"site_id" => site_id}) do def get_site(conn, %{"site_id" => site_id}) do
case get_site(conn.assigns.current_user, site_id, [:owner, :admin, :viewer]) do case get_site(conn.assigns.current_user, site_id, [:owner, :admin, :editor, :viewer]) do
{:ok, site} -> {:ok, site} ->
json(conn, %{ json(conn, %{
domain: site.domain, domain: site.domain,
@ -107,7 +124,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
def update_site(conn, %{"site_id" => site_id} = params) do def update_site(conn, %{"site_id" => site_id} = params) do
# for now this only allows to change the domain # for now this only allows to change the domain
with {:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin]), with {:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin, :editor]),
{:ok, site} <- Plausible.Site.Domain.change(site, params["domain"]) do {:ok, site} <- Plausible.Site.Domain.change(site, params["domain"]) do
json(conn, site) json(conn, site)
else else
@ -124,7 +141,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
def find_or_create_shared_link(conn, params) do def find_or_create_shared_link(conn, params) do
with {:ok, site_id} <- expect_param_key(params, "site_id"), with {:ok, site_id} <- expect_param_key(params, "site_id"),
{:ok, link_name} <- expect_param_key(params, "name"), {:ok, link_name} <- expect_param_key(params, "name"),
{:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin]) do {:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin, :editor]) do
shared_link = Repo.get_by(Plausible.Site.SharedLink, site_id: site.id, name: link_name) shared_link = Repo.get_by(Plausible.Site.SharedLink, site_id: site.id, name: link_name)
shared_link = shared_link =
@ -158,7 +175,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
def find_or_create_goal(conn, params) do def find_or_create_goal(conn, params) do
with {:ok, site_id} <- expect_param_key(params, "site_id"), with {:ok, site_id} <- expect_param_key(params, "site_id"),
{:ok, _} <- expect_param_key(params, "goal_type"), {:ok, _} <- expect_param_key(params, "goal_type"),
{:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin]), {:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin, :editor]),
{:ok, goal} <- Goals.find_or_create(site, params) do {:ok, goal} <- Goals.find_or_create(site, params) do
json(conn, goal) json(conn, goal)
else else
@ -176,7 +193,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
def delete_goal(conn, params) do def delete_goal(conn, params) do
with {:ok, site_id} <- expect_param_key(params, "site_id"), with {:ok, site_id} <- expect_param_key(params, "site_id"),
{:ok, goal_id} <- expect_param_key(params, "goal_id"), {:ok, goal_id} <- expect_param_key(params, "goal_id"),
{:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin]), {:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin, :editor]),
:ok <- Goals.delete(goal_id, site) do :ok <- Goals.delete(goal_id, site) do
json(conn, %{"deleted" => true}) json(conn, %{"deleted" => true})
else else

View File

@ -19,6 +19,7 @@ defmodule PlausibleWeb.Live.FunnelSettings do
Plausible.Sites.get_for_user!(current_user, domain, [ Plausible.Sites.get_for_user!(current_user, domain, [
:owner, :owner,
:admin, :admin,
:editor,
:super_admin :super_admin
]) ])
end) end)
@ -110,7 +111,7 @@ defmodule PlausibleWeb.Live.FunnelSettings do
Plausible.Sites.get_for_user!( Plausible.Sites.get_for_user!(
socket.assigns.current_user, socket.assigns.current_user,
socket.assigns.domain, socket.assigns.domain,
[:owner, :admin] [:owner, :admin, :editor]
) )
id = String.to_integer(id) id = String.to_integer(id)

View File

@ -16,6 +16,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
Plausible.Sites.get_for_user!(socket.assigns.current_user, domain, [ Plausible.Sites.get_for_user!(socket.assigns.current_user, domain, [
:owner, :owner,
:admin, :admin,
:editor,
:super_admin :super_admin
]) ])

View File

@ -7,6 +7,7 @@ defmodule Plausible.Auth do
use Plausible.Repo use Plausible.Repo
alias Plausible.Auth alias Plausible.Auth
alias Plausible.RateLimit alias Plausible.RateLimit
alias Plausible.Teams
@rate_limits %{ @rate_limits %{
login_ip: %{ login_ip: %{
@ -71,9 +72,9 @@ defmodule Plausible.Auth do
def delete_user(user) do def delete_user(user) do
Repo.transaction(fn -> Repo.transaction(fn ->
case Plausible.Teams.get_by_owner(user) do case Teams.get_by_owner(user) do
{:ok, team} -> {:ok, %{setup_complete: false} = team} ->
for site <- Plausible.Teams.owned_sites(team) do for site <- Teams.owned_sites(team) do
Plausible.Site.Removal.run(site) Plausible.Site.Removal.run(site)
end end
@ -84,15 +85,39 @@ defmodule Plausible.Auth do
) )
Repo.delete!(team) Repo.delete!(team)
Repo.delete!(user)
_ -> {:ok, team} ->
:skip check_can_leave_team!(team)
Repo.delete!(user)
{:error, :multiple_teams} ->
check_can_leave_teams!(user)
Repo.delete!(user)
{:error, :no_team} ->
Repo.delete!(user)
end end
Repo.delete!(user) :deleted
end) end)
end end
defp check_can_leave_teams!(user) do
user
|> Teams.Users.owned_teams()
|> Enum.reject(&(&1.setup_complete == false))
|> Enum.map(fn team ->
check_can_leave_team!(team)
end)
end
defp check_can_leave_team!(team) do
if Teams.Memberships.owners_count(team) <= 1 do
Repo.rollback(:is_only_team_owner)
end
end
on_ee do on_ee do
def is_super_admin?(nil), do: false def is_super_admin?(nil), do: false
def is_super_admin?(%Plausible.Auth.User{id: id}), do: is_super_admin?(id) def is_super_admin?(%Plausible.Auth.User{id: id}), do: is_super_admin?(id)
@ -107,17 +132,12 @@ defmodule Plausible.Auth do
@spec create_api_key(Auth.User.t(), String.t(), String.t()) :: @spec create_api_key(Auth.User.t(), String.t(), String.t()) ::
{:ok, Auth.ApiKey.t()} | {:error, Ecto.Changeset.t() | :upgrade_required} {:ok, Auth.ApiKey.t()} | {:error, Ecto.Changeset.t() | :upgrade_required}
def create_api_key(user, name, key) do def create_api_key(user, name, key) do
team =
case Plausible.Teams.get_by_owner(user) do
{:ok, team} -> team
_ -> nil
end
params = %{name: name, user_id: user.id, key: key} params = %{name: name, user_id: user.id, key: key}
changeset = Auth.ApiKey.changeset(%Auth.ApiKey{}, params) changeset = Auth.ApiKey.changeset(%Auth.ApiKey{}, params)
with :ok <- Plausible.Billing.Feature.StatsAPI.check_availability(team), with :ok <- check_stats_api_available(user) do
do: Repo.insert(changeset) Repo.insert(changeset)
end
end end
@spec delete_api_key(Auth.User.t(), integer()) :: :ok | {:error, :not_found} @spec delete_api_key(Auth.User.t(), integer()) :: :ok | {:error, :not_found}
@ -148,6 +168,21 @@ defmodule Plausible.Auth do
end end
end end
defp check_stats_api_available(user) do
case Plausible.Teams.get_by_owner(user) do
{:ok, team} ->
Plausible.Billing.Feature.StatsAPI.check_availability(team)
{:error, :no_team} ->
Plausible.Billing.Feature.StatsAPI.check_availability(nil)
{:error, :multiple_teams} ->
# NOTE: Loophole to allow creating API keys when user is a member
# on multiple teams.
:ok
end
end
defp rate_limit_key(%Auth.User{id: id}), do: id defp rate_limit_key(%Auth.User{id: id}), do: id
defp rate_limit_key(%Plug.Conn{} = conn), do: PlausibleWeb.RemoteIP.get(conn) defp rate_limit_key(%Plug.Conn{} = conn), do: PlausibleWeb.RemoteIP.get(conn)
end end

View File

@ -34,11 +34,6 @@ defmodule Plausible.Auth.User do
# Field for purely informational purposes in CRM context # Field for purely informational purposes in CRM context
field :notes, :string field :notes, :string
# Fields used only by CRM for mapping to the ones in the owned team
field :trial_expiry_date, :date, virtual: true
field :allow_next_upgrade_override, :boolean, virtual: true
field :accept_traffic_until, :date, virtual: true
# Fields for TOTP authentication. See `Plausible.Auth.TOTP`. # Fields for TOTP authentication. See `Plausible.Auth.TOTP`.
field :totp_enabled, :boolean, default: false field :totp_enabled, :boolean, default: false
field :totp_secret, Plausible.Auth.TOTP.EncryptedBinary field :totp_secret, Plausible.Auth.TOTP.EncryptedBinary
@ -49,8 +44,8 @@ defmodule Plausible.Auth.User do
has_many :team_memberships, Plausible.Teams.Membership has_many :team_memberships, Plausible.Teams.Membership
has_many :api_keys, Plausible.Auth.ApiKey has_many :api_keys, Plausible.Auth.ApiKey
has_one :google_auth, Plausible.Site.GoogleAuth has_one :google_auth, Plausible.Site.GoogleAuth
has_one :owner_membership, Plausible.Teams.Membership, where: [role: :owner] has_many :owner_memberships, Plausible.Teams.Membership, where: [role: :owner]
has_one :my_team, through: [:owner_membership, :team] has_many :owned_teams, through: [:owner_memberships, :team]
timestamps() timestamps()
end end
@ -113,16 +108,7 @@ defmodule Plausible.Auth.User do
def changeset(user, attrs \\ %{}) do def changeset(user, attrs \\ %{}) do
user user
|> cast(attrs, [ |> cast(attrs, [:email, :name, :email_verified, :theme, :notes])
:email,
:name,
:email_verified,
:theme,
:notes,
:trial_expiry_date,
:allow_next_upgrade_override,
:accept_traffic_until
])
|> validate_required([:email, :name, :email_verified]) |> validate_required([:email, :name, :email_verified])
|> unique_constraint(:email) |> unique_constraint(:email)
end end

View File

@ -1,24 +1,13 @@
defmodule Plausible.Auth.UserAdmin do defmodule Plausible.Auth.UserAdmin do
use Plausible.Repo use Plausible.Repo
use Plausible use Plausible
require Plausible.Billing.Subscription.Status
alias Plausible.Billing.Subscription
def custom_index_query(_conn, _schema, query) do def custom_index_query(_conn, _schema, query) do
subscripton_q = from(s in Plausible.Billing.Subscription, order_by: [desc: s.inserted_at]) from(r in query, preload: [:owned_teams])
from(r in query, preload: [my_team: [subscription: ^subscripton_q]])
end end
def custom_show_query(_conn, _schema, query) do def custom_show_query(_conn, _schema, query) do
from(u in query, from(u in query, preload: [:owned_teams])
left_join: t in assoc(u, :my_team),
select: %{
u
| trial_expiry_date: t.trial_expiry_date,
allow_next_upgrade_override: t.allow_next_upgrade_override,
accept_traffic_until: t.accept_traffic_until
}
)
end end
def form_fields(_) do def form_fields(_) do
@ -26,78 +15,31 @@ defmodule Plausible.Auth.UserAdmin do
name: nil, name: nil,
email: nil, email: nil,
previous_email: nil, previous_email: nil,
trial_expiry_date: %{
help_text: "Change will also update Accept Traffic Until date"
},
allow_next_upgrade_override: nil,
accept_traffic_until: %{
help_text: "Change will take up to 15 minutes to propagate"
},
notes: %{type: :textarea, rows: 6} notes: %{type: :textarea, rows: 6}
] ]
end end
def update(_conn, changeset) do
my_team = Repo.preload(changeset.data, :my_team).my_team
team_changed_params =
[:trial_expiry_date, :allow_next_upgrade_override, :accept_traffic_until]
|> Enum.map(&{&1, Ecto.Changeset.get_change(changeset, &1, :no_change)})
|> Enum.reject(fn {_, val} -> val == :no_change end)
|> Map.new()
with {:ok, user} <- Repo.update(changeset) do
cond do
my_team && map_size(team_changed_params) > 0 ->
my_team
|> Plausible.Teams.Team.crm_sync_changeset(team_changed_params)
|> Repo.update!()
team_changed_params[:trial_expiry_date] ->
{:ok, team} = Plausible.Teams.get_or_create(user)
team
|> Plausible.Teams.Team.crm_sync_changeset(team_changed_params)
|> Repo.update!()
true ->
:ignore
end
{:ok, user}
end
end
def delete(_conn, %{data: user}) do def delete(_conn, %{data: user}) do
Plausible.Auth.delete_user(user) case Plausible.Auth.delete_user(user) do
{:ok, :deleted} ->
:ok
{:error, :is_only_team_owner} ->
"The user is the only public team owner on one or more teams."
end
end end
def index(_) do def index(_) do
[ [
name: nil, name: nil,
email: nil, email: nil,
inserted_at: %{name: "Created at", value: &format_date(&1.inserted_at)}, owned_teams: %{value: &Phoenix.HTML.raw(teams(&1.owned_teams))},
trial_expiry_date: %{name: "Trial expiry", value: &format_date(&1.trial_expiry_date)}, inserted_at: %{name: "Created at", value: &format_date(&1.inserted_at)}
subscription_plan: %{value: &subscription_plan/1},
subscription_status: %{value: &subscription_status/1},
grace_period: %{value: &grace_period_status/1},
accept_traffic_until: %{
name: "Accept traffic until",
value: &format_date(&1.accept_traffic_until)
}
] ]
end end
def resource_actions(_) do def resource_actions(_) do
[ [
unlock: %{
name: "Unlock",
action: fn _, user -> unlock(user) end
},
lock: %{
name: "Lock",
action: fn _, user -> lock(user) end
},
reset_2fa: %{ reset_2fa: %{
name: "Reset 2FA", name: "Reset 2FA",
action: fn _, user -> disable_2fa(user) end action: fn _, user -> disable_2fa(user) end
@ -105,94 +47,21 @@ defmodule Plausible.Auth.UserAdmin do
] ]
end end
defp lock(user) do
user = Repo.preload(user, :my_team)
if user.my_team && user.my_team.grace_period do
Plausible.Billing.SiteLocker.set_lock_status_for(user.my_team, true)
Plausible.Teams.end_grace_period(user.my_team)
{:ok, user}
else
{:error, user, "No active grace period on this user"}
end
end
defp unlock(user) do
user = Repo.preload(user, :my_team)
if user.my_team && user.my_team.grace_period do
Plausible.Teams.remove_grace_period(user.my_team)
Plausible.Billing.SiteLocker.set_lock_status_for(user.my_team, false)
{:ok, user}
else
{:error, user, "No active grace period on this user"}
end
end
def disable_2fa(user) do def disable_2fa(user) do
Plausible.Auth.TOTP.force_disable(user) Plausible.Auth.TOTP.force_disable(user)
end end
defp grace_period_status(user) do def teams([]) do
grace_period = user.my_team && user.my_team.grace_period "(none)"
case grace_period do
nil ->
"--"
%{manual_lock: true, is_over: true} ->
"Manually locked"
%{manual_lock: true, is_over: false} ->
"Waiting for manual lock"
%{is_over: true} ->
"ended"
%{end_date: %Date{} = end_date} ->
days_left = Date.diff(end_date, Date.utc_today())
"#{days_left} days left"
end
end end
defp subscription_plan(user) do def teams(teams) do
subscription = user.my_team && user.my_team.subscription teams
|> Enum.map_join("<br>\n", fn team ->
if Subscription.Status.active?(subscription) && subscription.paddle_subscription_id do """
quota = PlausibleWeb.AuthView.subscription_quota(subscription) <a href="/crm/teams/team/#{team.id}">#{team.name}</a>
interval = PlausibleWeb.AuthView.subscription_interval(subscription) """
end)
{:safe, ~s(<a href="#{manage_url(subscription)}">#{quota} \(#{interval}\)</a>)}
else
"--"
end
end
defp subscription_status(user) do
team = user.my_team
cond do
team && team.subscription ->
status_str =
PlausibleWeb.SettingsView.present_subscription_status(team.subscription.status)
if team.subscription.paddle_subscription_id do
{:safe, ~s(<a href="#{manage_url(team.subscription)}">#{status_str}</a>)}
else
status_str
end
Plausible.Teams.on_trial?(team) ->
"On trial"
true ->
"Trial expired"
end
end
defp manage_url(%{paddle_subscription_id: paddle_id} = _subscription) do
Plausible.Billing.PaddleApi.vendors_domain() <>
"/subscriptions/customers/manage/" <> paddle_id
end end
defp format_date(nil), do: "--" defp format_date(nil), do: "--"

View File

@ -89,7 +89,7 @@ defmodule Plausible.Billing do
subscription = subscription =
Subscription Subscription
|> Repo.get_by(paddle_subscription_id: params["subscription_id"]) |> Repo.get_by(paddle_subscription_id: params["subscription_id"])
|> Repo.preload(team: :owner) |> Repo.preload(team: :owners)
if subscription do if subscription do
changeset = changeset =
@ -99,9 +99,11 @@ defmodule Plausible.Billing do
updated = Repo.update!(changeset) updated = Repo.update!(changeset)
subscription.team.owner for owner <- subscription.team.owners do
|> PlausibleWeb.Email.cancellation_email() owner
|> Plausible.Mailer.send() |> PlausibleWeb.Email.cancellation_email()
|> Plausible.Mailer.send()
end
updated updated
end end
@ -138,9 +140,16 @@ defmodule Plausible.Billing do
Teams.get!(team_id) Teams.get!(team_id)
{:user_id, user_id} -> {:user_id, user_id} ->
user = Repo.get!(Auth.User, user_id) # Given a guest or non-owner member user initiates the new subscription payment
{:ok, team} = Teams.get_or_create(user) # and becomes an owner of an existing team already with a subscription in between,
team # this could result in assigning this new subscription to the newly owned team,
# effectively "shadowing" any old one.
#
# That's why we are always defaulting to creating a new "My Team" team regardless
# if they were owner of one before or not.
Auth.User
|> Repo.get!(user_id)
|> Teams.force_create_my_team()
end end
end end
@ -212,7 +221,7 @@ defmodule Plausible.Billing do
Teams.Team Teams.Team
|> Repo.get!(subscription.team_id) |> Repo.get!(subscription.team_id)
|> Teams.with_subscription() |> Teams.with_subscription()
|> Repo.preload(:owner) |> Repo.preload(:owners)
if subscription.id != team.subscription.id do if subscription.id != team.subscription.id do
Sentry.capture_message("Susbscription ID mismatch", Sentry.capture_message("Susbscription ID mismatch",
@ -236,7 +245,8 @@ defmodule Plausible.Billing do
) )
if plan do if plan do
api_keys = from(key in Plausible.Auth.ApiKey, where: key.user_id == ^team.owner.id) owner_ids = Enum.map(team.owners, & &1.id)
api_keys = from(key in Plausible.Auth.ApiKey, where: key.user_id in ^owner_ids)
Repo.update_all(api_keys, set: [hourly_request_limit: plan.hourly_api_request_limit]) Repo.update_all(api_keys, set: [hourly_request_limit: plan.hourly_api_request_limit])
end end

View File

@ -24,9 +24,6 @@ defmodule Plausible.Billing.EnterprisePlan do
field :features, Plausible.Billing.Ecto.FeatureList, default: [] field :features, Plausible.Billing.Ecto.FeatureList, default: []
field :hourly_api_request_limit, :integer field :hourly_api_request_limit, :integer
# Field used only by CRM for mapping to the ones in the owned team
field :user_id, :integer, virtual: true
belongs_to :team, Plausible.Teams.Team belongs_to :team, Plausible.Teams.Team
timestamps() timestamps()

View File

@ -2,7 +2,7 @@ defmodule Plausible.Billing.EnterprisePlanAdmin do
use Plausible.Repo use Plausible.Repo
@numeric_fields [ @numeric_fields [
"user_id", "team_id",
"paddle_plan_id", "paddle_plan_id",
"monthly_pageview_limit", "monthly_pageview_limit",
"site_limit", "site_limit",
@ -18,7 +18,7 @@ defmodule Plausible.Billing.EnterprisePlanAdmin do
def form_fields(_schema) do def form_fields(_schema) do
[ [
user_id: nil, team_id: nil,
paddle_plan_id: nil, paddle_plan_id: nil,
billing_interval: %{choices: [{"Yearly", "yearly"}, {"Monthly", "monthly"}]}, billing_interval: %{choices: [{"Yearly", "yearly"}, {"Monthly", "monthly"}]},
monthly_pageview_limit: nil, monthly_pageview_limit: nil,
@ -40,25 +40,19 @@ defmodule Plausible.Billing.EnterprisePlanAdmin do
from(r in query, from(r in query,
inner_join: t in assoc(r, :team), inner_join: t in assoc(r, :team),
inner_join: o in assoc(t, :owner), inner_join: o in assoc(t, :owners),
or_where: ilike(r.paddle_plan_id, ^search_term), or_where: ilike(r.paddle_plan_id, ^search_term),
or_where: ilike(o.email, ^search_term) or ilike(o.name, ^search_term), or_where: ilike(o.email, ^search_term),
preload: [team: {t, owner: o}] or_where: ilike(o.name, ^search_term),
) or_where: ilike(t.name, ^search_term),
end preload: [team: {t, owners: o}]
def custom_show_query(_conn, _schema, query) do
from(ep in query,
inner_join: t in assoc(ep, :team),
inner_join: o in assoc(t, :owner),
select: %{ep | user_id: o.id}
) )
end end
def index(_) do def index(_) do
[ [
id: nil, id: nil,
user_email: %{value: &get_user_email/1}, user_email: %{value: &owner_emails(&1.team)},
paddle_plan_id: nil, paddle_plan_id: nil,
billing_interval: nil, billing_interval: nil,
monthly_pageview_limit: nil, monthly_pageview_limit: nil,
@ -68,20 +62,15 @@ defmodule Plausible.Billing.EnterprisePlanAdmin do
] ]
end end
defp get_user_email(plan), do: plan.team.owner.email defp owner_emails(team) do
team.owners
|> Enum.map_join("<br>", & &1.email)
|> Phoenix.HTML.raw()
end
def create_changeset(schema, attrs) do def create_changeset(schema, attrs) do
attrs = sanitize_attrs(attrs) attrs = sanitize_attrs(attrs)
team_id =
if user_id = attrs["user_id"] do
user = Repo.get!(Plausible.Auth.User, user_id)
{:ok, team} = Plausible.Teams.get_or_create(user)
team.id
end
attrs = Map.put(attrs, "team_id", team_id)
Plausible.Billing.EnterprisePlan.changeset(struct(schema, %{}), attrs) Plausible.Billing.EnterprisePlan.changeset(struct(schema, %{}), attrs)
end end

View File

@ -26,7 +26,7 @@ defmodule Plausible.Billing.SiteLocker do
Plausible.Teams.end_grace_period(team) Plausible.Teams.end_grace_period(team)
if send_email? do if send_email? do
team = Repo.preload(team, :owner) team = Repo.preload(team, :owners)
send_grace_period_end_email(team) send_grace_period_end_email(team)
end end
@ -64,8 +64,10 @@ defmodule Plausible.Billing.SiteLocker do
usage = Teams.Billing.monthly_pageview_usage(team) usage = Teams.Billing.monthly_pageview_usage(team)
suggested_plan = Plausible.Billing.Plans.suggest(team, usage.last_cycle.total) suggested_plan = Plausible.Billing.Plans.suggest(team, usage.last_cycle.total)
team.owner for owner <- team.owners do
|> PlausibleWeb.Email.dashboard_locked(usage, suggested_plan) owner
|> Plausible.Mailer.send() |> PlausibleWeb.Email.dashboard_locked(usage, suggested_plan)
|> Plausible.Mailer.send()
end
end end
end end

View File

@ -9,12 +9,31 @@ defmodule Plausible.CrmExtensions do
# Kaffy uses String.to_existing_atom when listing params # Kaffy uses String.to_existing_atom when listing params
@custom_search :custom_search @custom_search :custom_search
def javascripts(%{assigns: %{context: "teams", resource: "team", entry: %{} = team}}) do
[
Phoenix.HTML.raw("""
<script type="text/javascript">
(async () => {
const response = await fetch("/crm/teams/team/#{team.id}/usage?embed=true")
const usageHTML = await response.text()
const cardBody = document.querySelector(".card-body")
if (cardBody) {
const usageDOM = document.createElement("div")
usageDOM.innerHTML = usageHTML
cardBody.prepend(usageDOM)
}
})()
</script>
""")
]
end
def javascripts(%{assigns: %{context: "auth", resource: "user", entry: %{} = user}}) do def javascripts(%{assigns: %{context: "auth", resource: "user", entry: %{} = user}}) do
[ [
Phoenix.HTML.raw(""" Phoenix.HTML.raw("""
<script type="text/javascript"> <script type="text/javascript">
(async () => { (async () => {
const response = await fetch("/crm/auth/user/#{user.id}/usage?embed=true") const response = await fetch("/crm/auth/user/#{user.id}/info")
const usageHTML = await response.text() const usageHTML = await response.text()
const cardBody = document.querySelector(".card-body") const cardBody = document.querySelector(".card-body")
if (cardBody) { if (cardBody) {
@ -54,15 +73,22 @@ defmodule Plausible.CrmExtensions do
<script type="text/javascript"> <script type="text/javascript">
(async () => { (async () => {
const CHECK_INTERVAL = 300 const CHECK_INTERVAL = 300
const userIdField = document.querySelector("#enterprise_plan_user_id")
const userIdLabel = document.querySelector("label[for=enterprise_plan_user_id]") const teamPicker = document.querySelector("#pick-raw-resource")
if (teamPicker) {
teamPicker.style.display = "none";
}
const teamIdField = document.querySelector("#enterprise_plan_team_id") ||
document.querySelector("#team_id")
const teamIdLabel = document.querySelector("label[for=enterprise_plan_team_id]")
const dataList = document.createElement("datalist") const dataList = document.createElement("datalist")
dataList.id = "user-choices" dataList.id = "team-choices"
userIdField.after(dataList) teamIdField.after(dataList)
userIdField.setAttribute("list", "user-choices") teamIdField.setAttribute("list", "team-choices")
userIdField.setAttribute("type", "text") teamIdField.setAttribute("type", "text")
teamIdField.setAttribute("autocomplete", "off")
const labelSpan = document.createElement("span") const labelSpan = document.createElement("span")
userIdLabel.appendChild(labelSpan) teamIdLabel.appendChild(labelSpan)
let updateAction; let updateAction;
@ -70,17 +96,17 @@ defmodule Plausible.CrmExtensions do
id = Number(id) id = Number(id)
if (!isNaN(id) && id > 0) { if (!isNaN(id) && id > 0) {
const response = await fetch(`/crm/billing/search/user-by-id/${id}`) const response = await fetch(`/crm/billing/search/team-by-id/${id}`)
labelSpan.innerHTML = ` <i>(${await response.text()})</i>` labelSpan.innerHTML = ` <i>(${await response.text()})</i>`
} }
} }
const updateSearch = async () => { const updateSearch = async () => {
const search = userIdField.value const search = teamIdField.value
updateLabel(search) updateLabel(search)
const response = await fetch("/crm/billing/search/user", { const response = await fetch("/crm/billing/search/team", {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "POST", method: "POST",
body: JSON.stringify({ search: search }) body: JSON.stringify({ search: search })
@ -100,9 +126,9 @@ defmodule Plausible.CrmExtensions do
dataList.replaceChildren(...options) dataList.replaceChildren(...options)
} }
updateLabel(userIdField.value) updateLabel(teamIdField.value)
userIdField.addEventListener("input", async (e) => { teamIdField.addEventListener("input", async (e) => {
if (updateAction) { if (updateAction) {
clearTimeout(updateAction) clearTimeout(updateAction)
updateAction = null updateAction = null
@ -136,20 +162,20 @@ defmodule Plausible.CrmExtensions do
<script type="text/javascript"> <script type="text/javascript">
(async () => { (async () => {
const CHECK_INTERVAL = 300 const CHECK_INTERVAL = 300
const userIdField = document.getElementById("enterprise_plan_user_id") || document.getElementById("user_id") const teamIdField = document.getElementById("enterprise_plan_team_id") || document.getElementById("team_id")
let planRequest let planRequest
let lastValue = Number(userIdField.value) let lastValue = Number(teamIdField.value)
let currentValue = lastValue let currentValue = lastValue
setTimeout(prefillCallback, CHECK_INTERVAL) setTimeout(prefillCallback, CHECK_INTERVAL)
async function prefillCallback() { async function prefillCallback() {
currentValue = Number(userIdField.value) currentValue = Number(teamIdField.value)
if (Number.isInteger(currentValue) if (Number.isInteger(currentValue)
&& currentValue > 0 && currentValue > 0
&& currentValue != lastValue && currentValue != lastValue
&& !planRequest) { && !planRequest) {
planRequest = await fetch("/crm/billing/user/" + currentValue + "/current_plan") planRequest = await fetch("/crm/billing/team/" + currentValue + "/current_plan")
const result = await planRequest.json() const result = await planRequest.json()
fillForm(result) fillForm(result)
@ -178,7 +204,10 @@ defmodule Plausible.CrmExtensions do
['stats_api', 'props', 'funnels', 'revenue_goals'].forEach(feature => { ['stats_api', 'props', 'funnels', 'revenue_goals'].forEach(feature => {
const checked = result.features.includes(feature) const checked = result.features.includes(feature)
document.getElementById('enterprise_plan_features_' + feature).checked = checked const field = document.querySelector(`input[type=checkbox][value=${feature}]`)
if (field) {
field.checked = checked
}
}); });
} }
})() })()
@ -188,7 +217,7 @@ defmodule Plausible.CrmExtensions do
end end
def javascripts(%{assigns: %{context: context}}) def javascripts(%{assigns: %{context: context}})
when context in ["sites", "billing"] do when context in ["teams", "sites", "billing"] do
[ [
Phoenix.HTML.raw(""" Phoenix.HTML.raw("""
<script type="text/javascript"> <script type="text/javascript">

View File

@ -47,8 +47,8 @@ defmodule Plausible.Site do
has_one :google_auth, GoogleAuth has_one :google_auth, GoogleAuth
has_one :weekly_report, Plausible.Site.WeeklyReport has_one :weekly_report, Plausible.Site.WeeklyReport
has_one :monthly_report, Plausible.Site.MonthlyReport has_one :monthly_report, Plausible.Site.MonthlyReport
has_one :ownership, through: [:team, :ownership] has_many :ownerships, through: [:team, :ownerships], preload_order: [asc: :id]
has_one :owner, through: [:team, :owner] has_many :owners, through: [:team, :owners]
# If `from_cache?` is set, the struct might be incomplete - see `Plausible.Site.Cache`. # If `from_cache?` is set, the struct might be incomplete - see `Plausible.Site.Cache`.
# Use `Plausible.Repo.reload!(cached_site)` to pre-fill missing fields if # Use `Plausible.Repo.reload!(cached_site)` to pre-fill missing fields if

View File

@ -33,9 +33,11 @@ defmodule Plausible.SiteAdmin do
from(r in query, from(r in query,
as: :site, as: :site,
inner_join: o in assoc(r, :owner), inner_join: o in assoc(r, :owners),
inner_join: t in assoc(r, :team), inner_join: t in assoc(r, :team),
preload: [owner: o, team: t, guest_memberships: [team_membership: :user]], preload: [owners: o, team: t, guest_memberships: [team_membership: :user]],
or_where: type(t.identifier, :string) == ^search,
or_where: ilike(t.name, ^search_term),
or_where: ilike(r.domain, ^search_term), or_where: ilike(r.domain, ^search_term),
or_where: ilike(o.email, ^search_term), or_where: ilike(o.email, ^search_term),
or_where: ilike(o.name, ^search_term), or_where: ilike(o.name, ^search_term),
@ -78,7 +80,8 @@ defmodule Plausible.SiteAdmin do
inserted_at: %{name: "Created at", value: &format_date(&1.inserted_at)}, inserted_at: %{name: "Created at", value: &format_date(&1.inserted_at)},
timezone: nil, timezone: nil,
public: nil, public: nil,
owner: %{value: &get_owner/1}, team: %{value: &get_team/1},
owners: %{value: &get_owners/1},
other_members: %{value: &get_other_members/1}, other_members: %{value: &get_other_members/1},
limits: %{ limits: %{
value: fn site -> value: fn site ->
@ -113,7 +116,8 @@ defmodule Plausible.SiteAdmin do
transfer_ownership_direct: %{ transfer_ownership_direct: %{
name: "Transfer ownership without invite", name: "Transfer ownership without invite",
inputs: [ inputs: [
%{name: "email", title: "New Owner Email", default: nil} %{name: "email", title: "New Owner Email", default: nil},
%{name: "team_id", title: "Team Identifier", default: nil}
], ],
action: fn conn, sites, params -> transfer_ownership_direct(conn, sites, params) end action: fn conn, sites, params -> transfer_ownership_direct(conn, sites, params) end
} }
@ -150,12 +154,14 @@ defmodule Plausible.SiteAdmin do
{:error, "Please select at least one site from the list"} {:error, "Please select at least one site from the list"}
end end
defp transfer_ownership_direct(_conn, sites, %{"email" => email}) do defp transfer_ownership_direct(_conn, sites, %{"email" => email} = params) do
with {:ok, new_owner} <- Plausible.Auth.get_user_by(email: email), with {:ok, new_owner} <- Plausible.Auth.get_user_by(email: email),
{:ok, team} <- get_team_by_id(params["team_id"]),
{:ok, _} <- {:ok, _} <-
Plausible.Site.Memberships.bulk_transfer_ownership_direct( Plausible.Site.Memberships.bulk_transfer_ownership_direct(
sites, sites,
new_owner new_owner,
team
) do ) do
:ok :ok
else else
@ -168,35 +174,87 @@ defmodule Plausible.SiteAdmin do
{:error, :no_plan} -> {:error, :no_plan} ->
{:error, "The new owner does not have a subscription"} {:error, "The new owner does not have a subscription"}
{:error, :multiple_teams} ->
{:error, "The new owner owns more than one team"}
{:error, :permission_denied} ->
{:error, "The new owner can't add sites in the selected team"}
{:error, :invalid_team_id} ->
{:error, "The provided team identifier is invalid"}
{:error, {:over_plan_limits, limits}} -> {:error, {:over_plan_limits, limits}} ->
{:error, "Plan limits exceeded for one of the sites: #{Enum.join(limits, ", ")}"} {:error, "Plan limits exceeded for one of the sites: #{Enum.join(limits, ", ")}"}
end end
end end
defp get_team_by_id(id) when is_binary(id) and byte_size(id) > 0 do
case Ecto.UUID.cast(id) do
{:ok, team_id} ->
{:ok, Plausible.Teams.get(team_id)}
:error ->
{:error, :invalid_team_id}
end
end
defp get_team_by_id(_) do
{:ok, nil}
end
defp format_date(date) do defp format_date(date) do
Calendar.strftime(date, "%b %-d, %Y") Calendar.strftime(date, "%b %-d, %Y")
end end
defp get_owner(site) do defp get_team(site) do
owner = site.owner team_name =
case site.owners do
[owner] ->
if site.team.name == "My Team" do
owner.name
else
site.team.name
end
if owner do [_ | _] ->
escaped_name = Phoenix.HTML.html_escape(owner.name) |> Phoenix.HTML.safe_to_string() site.team.name
escaped_email = Phoenix.HTML.html_escape(owner.email) |> Phoenix.HTML.safe_to_string() end
|> html_escape()
{:safe, """
""" <a href="/crm/teams/team/#{site.team.id}">#{team_name}</a>
<a href="/crm/auth/user/#{owner.id}">#{escaped_name}</a> """
<br/><br/> |> Phoenix.HTML.raw()
#{escaped_email} end
"""}
end defp get_owners(site) do
owners_html =
Enum.map(site.owners, fn owner ->
escaped_name = html_escape(owner.name)
escaped_email = html_escape(owner.email)
"""
<a href="/crm/auth/user/#{owner.id}">#{escaped_name}</a>
<br/>
#{escaped_email}
"""
end)
{:safe, Enum.join(owners_html, "<br/><br/>")}
end end
defp get_other_members(site) do defp get_other_members(site) do
site.guest_memberships site.guest_memberships
|> Enum.map(fn m -> m.team_membership.user.email <> "(#{member_role(m.role)})" end) |> Enum.map_join(", ", fn m ->
|> Enum.join(", ") id = m.team_membership.user.id
email = html_escape(m.team_membership.user.email)
role = html_escape(m.role)
"""
<a href="/auth/user/#{id}">#{email} (#{role})</a>
"""
end)
|> Phoenix.HTML.raw()
end end
def get_struct_fields(module) do def get_struct_fields(module) do
@ -210,6 +268,9 @@ defmodule Plausible.SiteAdmin do
def create_changeset(schema, attrs), do: Plausible.Site.crm_changeset(schema, attrs) def create_changeset(schema, attrs), do: Plausible.Site.crm_changeset(schema, attrs)
def update_changeset(schema, attrs), do: Plausible.Site.crm_changeset(schema, attrs) def update_changeset(schema, attrs), do: Plausible.Site.crm_changeset(schema, attrs)
defp member_role(:editor), do: :admin def html_escape(string) do
defp member_role(other), do: other string
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
end
end end

View File

@ -5,7 +5,9 @@ defmodule Plausible.Site.Memberships do
alias Plausible.Site.Memberships alias Plausible.Site.Memberships
defdelegate accept_invitation(invitation_id, user), to: Memberships.AcceptInvitation defdelegate accept_invitation(invitation_id, user, team \\ nil),
to: Memberships.AcceptInvitation
defdelegate reject_invitation(invitation_id, user), to: Memberships.RejectInvitation defdelegate reject_invitation(invitation_id, user), to: Memberships.RejectInvitation
defdelegate remove_invitation(invitation_id, site), to: Memberships.RemoveInvitation defdelegate remove_invitation(invitation_id, site), to: Memberships.RemoveInvitation
@ -15,6 +17,6 @@ defmodule Plausible.Site.Memberships do
defdelegate bulk_create_invitation(sites, inviter, invitee_email, role, opts), defdelegate bulk_create_invitation(sites, inviter, invitee_email, role, opts),
to: Memberships.CreateInvitation to: Memberships.CreateInvitation
defdelegate bulk_transfer_ownership_direct(sites, new_owner), defdelegate bulk_transfer_ownership_direct(sites, new_owner, team \\ nil),
to: Memberships.AcceptInvitation to: Memberships.AcceptInvitation
end end

View File

@ -25,22 +25,25 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do
| Ecto.Changeset.t() | Ecto.Changeset.t()
| :transfer_to_self | :transfer_to_self
| :no_plan | :no_plan
| :multiple_teams
| :permission_denied
@type accept_error() :: @type accept_error() ::
:invitation_not_found :invitation_not_found
| :already_other_team_member
| Billing.Quota.Limits.over_limits_error() | Billing.Quota.Limits.over_limits_error()
| Ecto.Changeset.t() | Ecto.Changeset.t()
| :no_plan | :no_plan
| :multiple_teams
| :permission_denied
@type membership :: %Plausible.Teams.Membership{} @type membership :: %Teams.Membership{}
@spec bulk_transfer_ownership_direct([Site.t()], Auth.User.t()) :: @spec bulk_transfer_ownership_direct([Site.t()], Auth.User.t(), Teams.Team.t() | nil) ::
{:ok, [membership]} | {:error, transfer_error()} {:ok, [membership]} | {:error, transfer_error()}
def bulk_transfer_ownership_direct(sites, new_owner) do def bulk_transfer_ownership_direct(sites, new_owner, team \\ nil) do
Repo.transaction(fn -> Repo.transaction(fn ->
for site <- sites do for site <- sites do
case transfer_ownership(site, new_owner) do case transfer_ownership(site, new_owner, team) do
{:ok, membership} -> {:ok, membership} ->
membership membership
@ -51,14 +54,14 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do
end) end)
end end
@spec accept_invitation(String.t(), Auth.User.t()) :: @spec accept_invitation(String.t(), Auth.User.t(), Teams.Team.t() | nil) ::
{:ok, map()} | {:error, accept_error()} {:ok, map()} | {:error, accept_error()}
def accept_invitation(invitation_or_transfer_id, user) do def accept_invitation(invitation_or_transfer_id, user, team \\ nil) do
with {:ok, invitation_or_transfer} <- with {:ok, invitation_or_transfer} <-
Teams.Invitations.find_for_user(invitation_or_transfer_id, user) do Teams.Invitations.find_for_user(invitation_or_transfer_id, user) do
case invitation_or_transfer do case invitation_or_transfer do
%Teams.SiteTransfer{} = site_transfer -> %Teams.SiteTransfer{} = site_transfer ->
do_accept_ownership_transfer(site_transfer, user) do_accept_ownership_transfer(site_transfer, user, team)
%Teams.Invitation{} = team_invitation -> %Teams.Invitation{} = team_invitation ->
do_accept_team_invitation(team_invitation, user) do_accept_team_invitation(team_invitation, user)
@ -69,41 +72,49 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do
end end
end end
defp transfer_ownership(site, new_owner) do defp transfer_ownership(site, new_owner, team) do
site = Repo.preload(site, :team) site = Repo.preload(site, :team)
with :ok <- with :ok <- Teams.Invitations.ensure_transfer_valid(site.team, new_owner, :owner),
Plausible.Teams.Invitations.ensure_transfer_valid( {:ok, new_team} <- maybe_get_team(new_owner, team),
site.team, :ok <- check_can_transfer_site(new_team, new_owner),
new_owner, :ok <- Teams.Invitations.ensure_can_take_ownership(site, new_team),
:owner :ok <- Teams.Invitations.transfer_site(site, new_team) do
), site = site |> Repo.reload!() |> Repo.preload(ownerships: :user)
{:ok, new_team} = Plausible.Teams.get_or_create(new_owner),
:ok <- Plausible.Teams.Invitations.ensure_can_take_ownership(site, new_team),
:ok <- Plausible.Teams.Invitations.transfer_site(site, new_owner) do
site = site |> Repo.reload!() |> Repo.preload(ownership: :user)
{:ok, site.ownership} {:ok, site.ownerships}
end end
end end
defp do_accept_ownership_transfer(site_transfer, user) do defp do_accept_ownership_transfer(site_transfer, new_owner, team) do
site = Repo.preload(site_transfer.site, :team) site = Repo.preload(site_transfer.site, :team)
with :ok <- with :ok <- Teams.Invitations.ensure_transfer_valid(site.team, new_owner, :owner),
Plausible.Teams.Invitations.ensure_transfer_valid( {:ok, new_team} <- maybe_get_team(new_owner, team),
site.team, :ok <- check_can_transfer_site(new_team, new_owner),
user, :ok <- Teams.Invitations.ensure_can_take_ownership(site, new_team),
:owner :ok <- Teams.Invitations.accept_site_transfer(site_transfer, new_team) do
),
{:ok, team} = Plausible.Teams.get_or_create(user),
:ok <- Plausible.Teams.Invitations.ensure_can_take_ownership(site, team),
:ok <- Teams.Invitations.accept_site_transfer(site_transfer, user) do
Teams.Invitations.send_transfer_accepted_email(site_transfer) Teams.Invitations.send_transfer_accepted_email(site_transfer)
site = site |> Repo.reload!() |> Repo.preload(ownership: :user) site = site |> Repo.reload!() |> Repo.preload(ownerships: :user)
{:ok, %{team: team, team_membership: site.ownership, site: site}} {:ok, %{team: new_team, team_memberships: site.ownerships, site: site}}
end
end
defp maybe_get_team(_user, %Teams.Team{} = team) do
{:ok, team}
end
defp maybe_get_team(user, nil) do
Teams.get_or_create(user)
end
defp check_can_transfer_site(team, user) do
if Teams.Memberships.can_transfer_site?(team, user) do
:ok
else
{:error, :permission_denied}
end end
end end
@ -112,16 +123,6 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do
end end
defp do_accept_team_invitation(team_invitation, user) do defp do_accept_team_invitation(team_invitation, user) do
with :ok <- ensure_no_other_team_membership(team_invitation.team, user) do Teams.Invitations.accept_team_invitation(team_invitation, user)
Teams.Invitations.accept_team_invitation(team_invitation, user)
end
end
defp ensure_no_other_team_membership(team, user) do
if Teams.Users.team_member?(user, except: [team.id]) do
{:error, :already_other_team_member}
else
:ok
end
end end
end end

View File

@ -50,7 +50,7 @@ defmodule Plausible.Site.Memberships.CreateInvitation do
end end
defp do_invite(site, inviter, invitee_email, role, opts \\ []) do defp do_invite(site, inviter, invitee_email, role, opts \\ []) do
with site <- Repo.preload(site, [:owner, :team]), with site <- Repo.preload(site, [:owners, :team]),
:ok <- :ok <-
Teams.Invitations.check_invitation_permissions( Teams.Invitations.check_invitation_permissions(
site, site,

View File

@ -9,7 +9,7 @@ defmodule Plausible.Site.Removal do
@spec run(Plausible.Site.t()) :: {:ok, map()} @spec run(Plausible.Site.t()) :: {:ok, map()}
def run(site) do def run(site) do
Repo.transaction(fn -> Repo.transaction(fn ->
site = Plausible.Teams.load_for_site(site) site = Repo.preload(site, :team)
result = Repo.delete_all(from(s in Plausible.Site, where: s.domain == ^site.domain)) result = Repo.delete_all(from(s in Plausible.Site, where: s.domain == ^site.domain))

View File

@ -75,7 +75,7 @@ defmodule Plausible.Sites do
to: Plausible.Teams.Sites to: Plausible.Teams.Sites
def list_people(site) do def list_people(site) do
owner_membership = owner_memberships =
from( from(
tm in Teams.Membership, tm in Teams.Membership,
inner_join: u in assoc(tm, :user), inner_join: u in assoc(tm, :user),
@ -86,7 +86,7 @@ defmodule Plausible.Sites do
role: tm.role role: tm.role
} }
) )
|> Repo.one!() |> Repo.all()
memberships = memberships =
from( from(
@ -111,7 +111,7 @@ defmodule Plausible.Sites do
) )
|> Repo.all() |> Repo.all()
memberships = [owner_membership | memberships] memberships = owner_memberships ++ memberships
invitations = invitations =
from( from(
@ -151,28 +151,55 @@ defmodule Plausible.Sites do
%{memberships: memberships, invitations: site_transfers ++ invitations} %{memberships: memberships, invitations: site_transfers ++ invitations}
end end
@spec for_user_query(Auth.User.t()) :: Ecto.Query.t() @spec for_user_query(Auth.User.t(), Teams.Team.t() | nil) :: Ecto.Query.t()
def for_user_query(user) do def for_user_query(user, team \\ nil) do
from(s in Site, query =
inner_join: t in assoc(s, :team), from(s in Site,
inner_join: tm in assoc(t, :team_memberships), as: :site,
left_join: gm in assoc(tm, :guest_memberships), inner_join: t in assoc(s, :team),
where: tm.user_id == ^user.id, as: :team,
where: tm.role != :guest or gm.site_id == s.id, inner_join: tm in assoc(t, :team_memberships),
order_by: [desc: s.id] as: :team_memberships,
) left_join: gm in assoc(tm, :guest_memberships),
as: :guest_memberships,
where: tm.user_id == ^user.id,
order_by: [desc: s.id]
)
if team do
where(
query,
[team_memberships: tm, guest_memberships: gm, site: s],
(tm.role != :guest and tm.team_id == ^team.id) or gm.site_id == s.id
)
else
where(
query,
[team_memberships: tm, guest_memberships: gm, site: s],
tm.role != :guest or gm.site_id == s.id
)
end
end end
def create(user, params) do def create(user, params, team \\ nil) do
Ecto.Multi.new() Ecto.Multi.new()
|> Ecto.Multi.put(:site_changeset, Site.new(params)) |> Ecto.Multi.put(:site_changeset, Site.new(params))
|> Ecto.Multi.run(:create_team, fn _repo, _context -> |> Ecto.Multi.run(:create_team, fn _repo, _context ->
{:ok, team} = Plausible.Teams.get_or_create(user) cond do
team && Teams.Memberships.can_add_site?(team, user) ->
{:ok, Teams.with_subscription(team)}
{:ok, Plausible.Teams.with_subscription(team)} is_nil(team) ->
with {:ok, team} <- Teams.get_or_create(user) do
{:ok, Teams.with_subscription(team)}
end
true ->
{:error, :permission_denied}
end
end) end)
|> Ecto.Multi.run(:ensure_can_add_new_site, fn _repo, %{create_team: team} -> |> Ecto.Multi.run(:ensure_can_add_new_site, fn _repo, %{create_team: team} ->
case Plausible.Teams.Billing.ensure_can_add_new_site(team) do case Teams.Billing.ensure_can_add_new_site(team) do
:ok -> {:ok, :proceed} :ok -> {:ok, :proceed}
error -> error error -> error
end end
@ -304,9 +331,7 @@ defmodule Plausible.Sites do
locked locked
end end
def get_for_user!(user, domain, roles \\ [:owner, :admin, :viewer]) do def get_for_user!(user, domain, roles \\ [:owner, :admin, :editor, :viewer]) do
roles = translate_roles(roles)
site = site =
if :super_admin in roles and Plausible.Auth.is_super_admin?(user.id) do if :super_admin in roles and Plausible.Auth.is_super_admin?(user.id) do
get_by_domain!(domain) get_by_domain!(domain)
@ -319,9 +344,7 @@ defmodule Plausible.Sites do
Repo.preload(site, :team) Repo.preload(site, :team)
end end
def get_for_user(user, domain, roles \\ [:owner, :admin, :viewer]) do def get_for_user(user, domain, roles \\ [:owner, :admin, :editor, :viewer]) do
roles = translate_roles(roles)
if :super_admin in roles and Plausible.Auth.is_super_admin?(user.id) do if :super_admin in roles and Plausible.Auth.is_super_admin?(user.id) do
get_by_domain(domain) get_by_domain(domain)
else else
@ -331,13 +354,6 @@ defmodule Plausible.Sites do
end end
end end
defp translate_roles(roles) do
Enum.map(roles, fn
:admin -> :editor
role -> role
end)
end
defp get_for_user_query(user_id, domain, roles) do defp get_for_user_query(user_id, domain, roles) do
roles = Enum.map(roles, &to_string/1) roles = Enum.map(roles, &to_string/1)

View File

@ -16,6 +16,17 @@ defmodule Plausible.Teams do
not is_nil(team) and FunWithFlags.enabled?(:teams, for: team) not is_nil(team) and FunWithFlags.enabled?(:teams, for: team)
end end
@spec get(pos_integer() | binary() | nil) :: Teams.Team.t() | nil
def get(nil), do: nil
def get(team_id) when is_integer(team_id) do
Repo.get(Teams.Team, team_id)
end
def get(team_identifier) when is_binary(team_identifier) do
Repo.get_by(Teams.Team, identifier: team_identifier)
end
@spec get!(pos_integer() | binary()) :: Teams.Team.t() @spec get!(pos_integer() | binary()) :: Teams.Team.t()
def get!(team_id) when is_integer(team_id) do def get!(team_id) when is_integer(team_id) do
Repo.get!(Teams.Team, team_id) Repo.get!(Teams.Team, team_id)
@ -25,15 +36,6 @@ defmodule Plausible.Teams do
Repo.get_by!(Teams.Team, identifier: team_identifier) Repo.get_by!(Teams.Team, identifier: team_identifier)
end end
@spec get_owner(Teams.Team.t()) ::
{:ok, Auth.User.t()} | {:error, :no_owner | :multiple_owners}
def get_owner(team) do
case Repo.preload(team, :owner).owner do
nil -> {:error, :no_owner}
owner_user -> {:ok, owner_user}
end
end
@spec on_trial?(Teams.Team.t() | nil) :: boolean() @spec on_trial?(Teams.Team.t() | nil) :: boolean()
on_ee do on_ee do
def on_trial?(nil), do: false def on_trial?(nil), do: false
@ -110,27 +112,6 @@ defmodule Plausible.Teams do
|> Enum.any?(&Plausible.Sites.has_stats?/1) |> Enum.any?(&Plausible.Sites.has_stats?/1)
end end
@doc """
Create (when necessary) and load team relation for provided site.
Used for sync logic to work smoothly during transitional period.
"""
def load_for_site(site) do
site = Repo.preload(site, [:team, :owner])
if site.team do
site
else
{:ok, team} = get_or_create(site.owner)
site
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_assoc(:team, team)
|> Ecto.Changeset.force_change(:updated_at, site.updated_at)
|> Repo.update!()
end
end
@doc """ @doc """
Get or create user's team. Get or create user's team.
@ -156,6 +137,25 @@ defmodule Plausible.Teams do
end end
end end
@spec force_create_my_team(Auth.User.t()) :: Teams.Team.t()
def force_create_my_team(user) do
{:ok, team} =
Repo.transaction(fn ->
Repo.update_all(
from(tm in Teams.Membership,
where: tm.user_id == ^user.id,
where: tm.is_autocreated == true
),
set: [is_autocreated: false]
)
{:ok, team} = create_my_team(user)
team
end)
team
end
@spec get_by_owner(Auth.User.t() | pos_integer()) :: @spec get_by_owner(Auth.User.t() | pos_integer()) ::
{:ok, Teams.Team.t()} | {:error, :no_team | :multiple_teams} {:ok, Teams.Team.t()} | {:error, :no_team | :multiple_teams}
def get_by_owner(user_id) when is_integer(user_id) do def get_by_owner(user_id) when is_integer(user_id) do

View File

@ -382,7 +382,7 @@ defmodule Plausible.Teams.Billing do
def team_member_usage(nil, _), do: 0 def team_member_usage(nil, _), do: 0
def team_member_usage(team, opts) do def team_member_usage(team, opts) do
{:ok, owner} = Teams.get_owner(team) [owner | _] = Repo.preload(team, :owners).owners
exclude_emails = Keyword.get(opts, :exclude_emails, []) ++ [owner.email] exclude_emails = Keyword.get(opts, :exclude_emails, []) ++ [owner.email]
pending_site_ids = Keyword.get(opts, :pending_ownership_site_ids, []) pending_site_ids = Keyword.get(opts, :pending_ownership_site_ids, [])

View File

@ -143,7 +143,7 @@ defmodule Plausible.Teams.Invitations do
end end
def invite(%Plausible.Site{} = site, invitee_email, role, inviter) do def invite(%Plausible.Site{} = site, invitee_email, role, inviter) do
site = Teams.load_for_site(site) site = Repo.preload(site, :team)
if role == :owner do if role == :owner do
create_site_transfer( create_site_transfer(
@ -188,10 +188,9 @@ defmodule Plausible.Teams.Invitations do
) )
end end
def accept_site_transfer(site_transfer, user) do def accept_site_transfer(site_transfer, team) do
{:ok, _} = {:ok, _} =
Repo.transaction(fn -> Repo.transaction(fn ->
{:ok, team} = Teams.get_or_create(user)
:ok = transfer_site_ownership(site_transfer.site, team, NaiveDateTime.utc_now(:second)) :ok = transfer_site_ownership(site_transfer.site, team, NaiveDateTime.utc_now(:second))
Repo.delete_all(from st in Teams.SiteTransfer, where: st.id == ^site_transfer.id) Repo.delete_all(from st in Teams.SiteTransfer, where: st.id == ^site_transfer.id)
end) end)
@ -199,10 +198,9 @@ defmodule Plausible.Teams.Invitations do
:ok :ok
end end
def transfer_site(site, user) do def transfer_site(site, team) do
{:ok, _} = {:ok, _} =
Repo.transaction(fn -> Repo.transaction(fn ->
{:ok, team} = Teams.get_or_create(user)
:ok = transfer_site_ownership(site, team, NaiveDateTime.utc_now(:second)) :ok = transfer_site_ownership(site, team, NaiveDateTime.utc_now(:second))
end) end)
@ -335,7 +333,7 @@ defmodule Plausible.Teams.Invitations do
site = site =
Repo.preload(site, [ Repo.preload(site, [
:team, :team,
:owner, :owners,
guest_memberships: [team_membership: :user], guest_memberships: [team_membership: :user],
guest_invitations: [team_invitation: :inviter] guest_invitations: [team_invitation: :inviter]
]) ])
@ -392,19 +390,21 @@ defmodule Plausible.Teams.Invitations do
Repo.delete_all(from gm in Teams.GuestMembership, where: gm.id in ^old_guest_ids) Repo.delete_all(from gm in Teams.GuestMembership, where: gm.id in ^old_guest_ids)
:ok = Teams.Memberships.prune_guests(prior_team) :ok = Teams.Memberships.prune_guests(prior_team)
{:ok, prior_owner} = Teams.get_owner(prior_team) prior_owners = Repo.preload(prior_team, :owners).owners
{:ok, prior_owner_team_membership} = create_team_membership(team, :guest, prior_owner, now) for prior_owner <- prior_owners do
{:ok, prior_owner_team_membership} = create_team_membership(team, :guest, prior_owner, now)
if prior_owner_team_membership.role == :guest do if prior_owner_team_membership.role == :guest do
{:ok, _} = {:ok, _} =
prior_owner_team_membership prior_owner_team_membership
|> Teams.GuestMembership.changeset(site, :editor) |> Teams.GuestMembership.changeset(site, :editor)
|> Repo.insert( |> Repo.insert(
on_conflict: [set: [updated_at: now, role: :editor]], on_conflict: [set: [updated_at: now, role: :editor]],
conflict_target: [:team_membership_id, :site_id], conflict_target: [:team_membership_id, :site_id],
returning: true returning: true
) )
end
end end
on_ee do on_ee do

View File

@ -12,7 +12,7 @@ defmodule Plausible.Teams.Invitations.InviteToTeam do
def invite(team, inviter, invitee_email, role, opts \\ []) def invite(team, inviter, invitee_email, role, opts \\ [])
def invite(team, inviter, invitee_email, role, opts) when role in @valid_roles do def invite(team, inviter, invitee_email, role, opts) when role in @valid_roles do
with team <- Repo.preload(team, [:owner]), with team <- Repo.preload(team, [:owners]),
:ok <- :ok <-
Teams.Invitations.check_invitation_permissions( Teams.Invitations.check_invitation_permissions(
team, team,

View File

@ -46,6 +46,26 @@ defmodule Plausible.Teams.Memberships do
end end
end end
def can_add_site?(team, user) do
case team_role(team, user) do
{:ok, role} when role in [:owner, :admin, :editor] ->
true
_ ->
false
end
end
def can_transfer_site?(team, user) do
case team_role(team, user) do
{:ok, role} when role in [:owner, :admin] ->
true
_ ->
false
end
end
def site_role(_site, nil), do: {:error, :not_a_member} def site_role(_site, nil), do: {:error, :not_a_member}
def site_role(site, user) do def site_role(site, user) do

View File

@ -8,18 +8,21 @@ defmodule Plausible.Teams.Sites do
alias Plausible.Site alias Plausible.Site
alias Plausible.Teams alias Plausible.Teams
@type list_opt() :: {:filter_by_domain, String.t()} @type list_opt() :: {:filter_by_domain, String.t()} | {:team, Teams.Team.t() | nil}
@spec list(Auth.User.t(), map(), [list_opt()]) :: Scrivener.Page.t() @spec list(Auth.User.t(), map(), [list_opt()]) :: Scrivener.Page.t()
def list(user, pagination_params, opts \\ []) do def list(user, pagination_params, opts \\ []) do
domain_filter = Keyword.get(opts, :filter_by_domain) domain_filter = Keyword.get(opts, :filter_by_domain)
team = Keyword.get(opts, :team)
team_membership_query = team_membership_query =
from tm in Teams.Membership, from(tm in Teams.Membership,
inner_join: t in assoc(tm, :team), inner_join: t in assoc(tm, :team),
inner_join: s in assoc(t, :sites), inner_join: s in assoc(t, :sites),
where: tm.user_id == ^user.id and tm.role != :guest, where: tm.user_id == ^user.id and tm.role != :guest,
select: %{site_id: s.id, entry_type: "site"} select: %{site_id: s.id, entry_type: "site"}
)
|> maybe_filter_by_team(team)
guest_membership_query = guest_membership_query =
from tm in Teams.Membership, from tm in Teams.Membership,
@ -71,9 +74,10 @@ defmodule Plausible.Teams.Sites do
@spec list_with_invitations(Auth.User.t(), map(), [list_opt()]) :: Scrivener.Page.t() @spec list_with_invitations(Auth.User.t(), map(), [list_opt()]) :: Scrivener.Page.t()
def list_with_invitations(user, pagination_params, opts \\ []) do def list_with_invitations(user, pagination_params, opts \\ []) do
domain_filter = Keyword.get(opts, :filter_by_domain) domain_filter = Keyword.get(opts, :filter_by_domain)
team = Keyword.get(opts, :team)
team_membership_query = team_membership_query =
from tm in Teams.Membership, from(tm in Teams.Membership,
inner_join: t in assoc(tm, :team), inner_join: t in assoc(tm, :team),
inner_join: u in assoc(tm, :user), inner_join: u in assoc(tm, :user),
as: :user, as: :user,
@ -94,6 +98,8 @@ defmodule Plausible.Teams.Sites do
role: tm.role, role: tm.role,
transfer_id: 0 transfer_id: 0
} }
)
|> maybe_filter_by_team(team)
guest_membership_query = guest_membership_query =
from(tm in Teams.Membership, from(tm in Teams.Membership,
@ -265,6 +271,12 @@ defmodule Plausible.Teams.Sites do
end) end)
end end
defp maybe_filter_by_team(team_membership_query, %Teams.Team{} = team) do
where(team_membership_query, [tm], tm.team_id == ^team.id)
end
defp maybe_filter_by_team(team_membership_query, _), do: team_membership_query
defp maybe_filter_by_domain(query, domain) defp maybe_filter_by_domain(query, domain)
when byte_size(domain) >= 1 and byte_size(domain) <= 64 do when byte_size(domain) >= 1 and byte_size(domain) <= 64 do
where(query, [site: s], ilike(s.domain, ^"%#{domain}%")) where(query, [site: s], ilike(s.domain, ^"%#{domain}%"))

View File

@ -37,8 +37,11 @@ defmodule Plausible.Teams.Team do
has_one :subscription, Plausible.Billing.Subscription has_one :subscription, Plausible.Billing.Subscription
has_one :enterprise_plan, Plausible.Billing.EnterprisePlan has_one :enterprise_plan, Plausible.Billing.EnterprisePlan
has_one :ownership, Plausible.Teams.Membership, where: [role: :owner] has_many :ownerships, Plausible.Teams.Membership,
has_one :owner, through: [:ownership, :user] where: [role: :owner],
preload_order: [asc: :id]
has_many :owners, through: [:ownerships, :user]
timestamps() timestamps()
end end

View File

@ -0,0 +1,222 @@
defmodule Plausible.Teams.TeamAdmin do
@moduledoc """
Kaffy CRM definition for Team.
"""
use Plausible
use Plausible.Repo
alias Plausible.Billing.Subscription
alias Plausible.Teams
require Plausible.Billing.Subscription.Status
def custom_index_query(conn, _schema, query) do
search =
(conn.params["custom_search"] || "")
|> String.trim()
|> String.replace("%", "\%")
|> String.replace("_", "\_")
search_term = "%#{search}%"
member_query =
from t in Plausible.Teams.Team,
left_join: tm in assoc(t, :team_memberships),
left_join: u in assoc(tm, :user),
where: t.id == parent_as(:team).id,
where: ilike(u.email, ^search_term) or ilike(u.name, ^search_term),
select: 1
from(t in query,
as: :team,
left_lateral_join: s in subquery(Teams.last_subscription_join_query()),
on: true,
preload: [:owners, team_memberships: :user, subscription: s],
or_where: ilike(t.name, ^search_term),
or_where: exists(member_query)
)
end
def index(_) do
[
name: %{value: &team_name/1},
inserted_at: %{name: "Created at", value: &format_date(&1.inserted_at)},
owners: %{value: &get_owners/1},
other_members: %{value: &get_other_members/1},
trial_expiry_date: %{name: "Trial expiry", value: &format_date(&1.trial_expiry_date)},
subscription_plan: %{value: &Phoenix.HTML.raw(subscription_plan(&1))},
subscription_status: %{value: &Phoenix.HTML.raw(subscription_status(&1))},
grace_period: %{value: &Phoenix.HTML.raw(grace_period_status(&1))},
accept_traffic_until: %{
name: "Accept traffic until",
value: &format_date(&1.accept_traffic_until)
}
]
end
def form_fields(_) do
[
identifier: %{create: :hidden, update: :readonly},
name: nil,
trial_expiry_date: %{
help_text: "Change will also update Accept Traffic Until date"
},
allow_next_upgrade_override: nil,
accept_traffic_until: %{
help_text: "Change will take up to 15 minutes to propagate"
}
]
end
def resource_actions(_) do
[
unlock: %{
name: "Unlock",
action: fn _, team -> unlock(team) end
},
lock: %{
name: "Lock",
action: fn _, team -> lock(team) end
}
]
end
def delete(_conn, %{data: _team}) do
# TODO: Implement custom team removal
"Cannot remove the team for now"
end
def grace_period_status(team) do
grace_period = team.grace_period
case grace_period do
nil ->
"--"
%{manual_lock: true, is_over: true} ->
"Manually locked"
%{manual_lock: true, is_over: false} ->
"Waiting for manual lock"
%{is_over: true} ->
"ended"
%{end_date: %Date{} = end_date} ->
days_left = Date.diff(end_date, Date.utc_today())
"#{days_left} days left"
end
end
def subscription_plan(team) do
subscription = team.subscription
if Subscription.Status.active?(subscription) && subscription.paddle_subscription_id do
quota = PlausibleWeb.AuthView.subscription_quota(subscription)
interval = PlausibleWeb.AuthView.subscription_interval(subscription)
~s(<a href="#{manage_url(subscription)}">#{quota} \(#{interval}\)</a>)
else
"--"
end
end
def subscription_status(team) do
cond do
team && team.subscription ->
status_str =
PlausibleWeb.SettingsView.present_subscription_status(team.subscription.status)
if team.subscription.paddle_subscription_id do
~s(<a href="#{manage_url(team.subscription)}">#{status_str}</a>)
else
status_str
end
Plausible.Teams.on_trial?(team) ->
"On trial"
true ->
"Trial expired"
end
end
defp lock(team) do
if team.grace_period do
Plausible.Billing.SiteLocker.set_lock_status_for(team, true)
Plausible.Teams.end_grace_period(team)
{:ok, team}
else
{:error, team, "No active grace period on this team"}
end
end
defp unlock(team) do
if team.grace_period do
Plausible.Teams.remove_grace_period(team)
Plausible.Billing.SiteLocker.set_lock_status_for(team, false)
{:ok, team}
else
{:error, team, "No active grace period on this team"}
end
end
defp team_name(team) do
case team.owners do
[owner] ->
if team.name == "My Team" do
owner.name
else
team.name
end
[_ | _] ->
team.name
end
end
defp manage_url(%{paddle_subscription_id: paddle_id} = _subscription) do
Plausible.Billing.PaddleApi.vendors_domain() <>
"/subscriptions/customers/manage/" <> paddle_id
end
defp get_owners(team) do
team.owners
|> Enum.map_join("<br><br>\n", fn owner ->
name = html_escape(owner.name)
email = html_escape(owner.email)
"""
<a href="/crm/auth/user/#{owner.id}">#{name}</a><br>#{email}
"""
end)
|> Phoenix.HTML.raw()
end
defp get_other_members(team) do
team.team_memberships
|> Enum.reject(&(&1.role == :owner))
|> Enum.map_join("<br>\n", fn tm ->
email = html_escape(tm.user.email)
role = html_escape(tm.role)
"""
<a href="/crm/auth/user/#{tm.user.id}">#{email <> " (#{role})"}</a>
"""
end)
|> Phoenix.HTML.raw()
end
defp format_date(nil), do: "--"
defp format_date(date) do
Calendar.strftime(date, "%b %-d, %Y")
end
def html_escape(string) do
string
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
end
end

View File

@ -8,6 +8,31 @@ defmodule Plausible.Teams.Users do
alias Plausible.Repo alias Plausible.Repo
alias Plausible.Teams alias Plausible.Teams
def owned_teams(user) do
Repo.all(
from(
tm in Teams.Membership,
inner_join: t in assoc(tm, :team),
where: tm.user_id == ^user.id,
where: tm.role == :owner,
select: t
)
)
end
def teams(user) do
from(
tm in Teams.Membership,
inner_join: t in assoc(tm, :team),
where: tm.user_id == ^user.id,
where: tm.role != :guest,
select: t,
order_by: [t.name, t.id]
)
|> Repo.all()
|> Repo.preload(:owners)
end
def team_member?(user, opts \\ []) do def team_member?(user, opts \\ []) do
excluded_team_ids = Keyword.get(opts, :except, []) excluded_team_ids = Keyword.get(opts, :except, [])

View File

@ -8,18 +8,13 @@ defmodule PlausibleWeb.AdminController do
alias Plausible.Teams alias Plausible.Teams
def usage(conn, params) do def usage(conn, params) do
user_id = String.to_integer(params["user_id"]) team_id = String.to_integer(params["team_id"])
team = team =
case Teams.get_by_owner(user_id) do team_id
{:ok, team} -> |> Teams.get()
team |> Repo.preload([:owners, team_memberships: :user])
|> Teams.with_subscription() |> Teams.with_subscription()
|> Plausible.Repo.preload(:owner)
{:error, :no_team} ->
nil
end
usage = Teams.Billing.quota_usage(team, with_features: true) usage = Teams.Billing.quota_usage(team, with_features: true)
@ -36,17 +31,35 @@ defmodule PlausibleWeb.AdminController do
|> send_resp(200, html_response) |> send_resp(200, html_response)
end end
def current_plan(conn, params) do def user_info(conn, params) do
user_id = String.to_integer(params["user_id"]) user_id = String.to_integer(params["user_id"])
team = user =
case Teams.get_by_owner(user_id) do Plausible.Auth.User
{:ok, team} -> |> Repo.get!(user_id)
Teams.with_subscription(team) |> Repo.preload(:owned_teams)
{:error, :no_team} -> teams_list = Plausible.Auth.UserAdmin.teams(user.owned_teams)
nil
end html_response = """
<div style="margin-bottom: 1.1em;">
<p><b>Owned teams:</b></p>
#{teams_list}
</div>
"""
conn
|> put_resp_content_type("text/html")
|> send_resp(200, html_response)
end
def current_plan(conn, params) do
team_id = String.to_integer(params["team_id"])
team =
team_id
|> Teams.get()
|> Teams.with_subscription()
plan = plan =
case team && team.subscription && case team && team.subscription &&
@ -74,21 +87,39 @@ defmodule PlausibleWeb.AdminController do
|> send_resp(200, json_response) |> send_resp(200, json_response)
end end
def user_by_id(conn, params) do def team_by_id(conn, params) do
id = params["user_id"] id = params["team_id"]
entry = entry =
Repo.one( Repo.one(
from u in Plausible.Auth.User, from t in Plausible.Teams.Team,
where: u.id == ^id, inner_join: o in assoc(t, :owners),
select: fragment("concat(?, ?, ?, ?)", u.name, " (", u.email, ")") where: t.id == ^id,
group_by: t.id,
select:
fragment(
"""
case when ? = ? then
string_agg(concat(?, ' (', ?, ')'), ',')
else
concat(?, ' [', string_agg(concat(?, ' (', ?, ')'), ','), ']')
end
""",
t.name,
"My Team",
o.name,
o.email,
t.name,
o.name,
o.email
)
) || "" ) || ""
conn conn
|> send_resp(200, entry) |> send_resp(200, entry)
end end
def user_search(conn, params) do def team_search(conn, params) do
search = search =
(params["search"] || "") (params["search"] || "")
|> String.trim() |> String.trim()
@ -102,20 +133,47 @@ defmodule PlausibleWeb.AdminController do
term = "%#{term}%" term = "%#{term}%"
user_id = team_id =
case Integer.parse(search) do case Integer.parse(search) do
{id, ""} -> id {id, ""} -> id
_ -> 0 _ -> 0
end end
if user_id != 0 do if team_id != 0 do
[] []
else else
Repo.all( Repo.all(
from u in Plausible.Auth.User, from t in Teams.Team,
where: u.id == ^user_id or ilike(u.name, ^term) or ilike(u.email, ^term), inner_join: o in assoc(t, :owners),
order_by: [u.name, u.id], where:
select: [fragment("concat(?, ?, ?, ?)", u.name, " (", u.email, ")"), u.id], t.id == ^team_id or
type(t.identifier, :string) == ^search or
ilike(t.name, ^term) or
ilike(o.email, ^term) or
ilike(o.name, ^term),
order_by: [t.name, t.id],
group_by: t.id,
select: [
fragment(
"""
case when ? = ? then
concat(string_agg(concat(?, ' (', ?, ')'), ','), ' - ', ?)
else
concat(concat(?, ' [', string_agg(concat(?, ' (', ?, ')'), ','), ']'), ' - ', ?)
end
""",
t.name,
"My Team",
o.name,
o.email,
t.identifier,
t.name,
o.name,
o.email,
t.identifier
),
t.id
],
limit: 20 limit: 20
) )
end end
@ -131,12 +189,17 @@ defmodule PlausibleWeb.AdminController do
defp usage_and_limits_html(team, usage, limits, embed?) do defp usage_and_limits_html(team, usage, limits, embed?) do
content = """ content = """
<ul> <ul>
<li>Team: <b>#{team && team.name}</b></li> <li>Team: <b>#{team.name}</b></li>
<li>Subscription plan: #{Teams.TeamAdmin.subscription_plan(team)}</li>
<li>Subscription status: #{Teams.TeamAdmin.subscription_status(team)}</li>
<li>Grace period: #{Teams.TeamAdmin.grace_period_status(team)}</li>
<li>Sites: <b>#{usage.sites}</b> / #{limits.sites}</li> <li>Sites: <b>#{usage.sites}</b> / #{limits.sites}</li>
<li>Team members: <b>#{usage.team_members}</b> / #{limits.team_members}</li> <li>Team members: <b>#{usage.team_members}</b> / #{limits.team_members}</li>
<li>Features: #{features_usage(usage.features)}</li> <li>Features: #{features_usage(usage.features)}</li>
<li>Monthly pageviews: #{monthly_pageviews_usage(usage.monthly_pageviews, limits.monthly_pageviews)}</li> <li>Monthly pageviews: #{monthly_pageviews_usage(usage.monthly_pageviews, limits.monthly_pageviews)}</li>
#{sites_count_row(team)} #{sites_count_row(team)}
<li>Owners: #{get_owners(team)}</li>
<li>Team members: #{get_other_members(team)}</li>
</ul> </ul>
""" """
@ -177,7 +240,7 @@ defmodule PlausibleWeb.AdminController do
sites_link = sites_link =
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site, Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site,
custom_search: team.owner.email custom_search: team.identifier
) )
""" """
@ -210,4 +273,34 @@ defmodule PlausibleWeb.AdminController do
"<ul>#{Enum.join(list_items)}</ul>" "<ul>#{Enum.join(list_items)}</ul>"
end end
defp get_owners(team) do
team.owners
|> Enum.map_join(", ", fn owner ->
email = html_escape(owner.email)
"""
<a href="/crm/auth/user/#{owner.id}">#{email}</a>
"""
end)
end
defp get_other_members(team) do
team.team_memberships
|> Enum.reject(&(&1.role == :owner))
|> Enum.map_join(", ", fn tm ->
email = html_escape(tm.user.email)
role = html_escape(tm.role)
"""
<a href="/crm/auth/user/#{tm.user.id}">#{email <> " (#{role})"}</a>
"""
end)
end
def html_escape(string) do
string
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
end
end end

View File

@ -7,7 +7,7 @@ defmodule PlausibleWeb.Api.ExternalQueryApiController do
alias Plausible.Stats.Query alias Plausible.Stats.Query
def query(conn, params) do def query(conn, params) do
site = Repo.preload(conn.assigns.site, :owner) site = Repo.preload(conn.assigns.site, :owners)
case Query.build(site, conn.assigns.schema_type, params, debug_metadata(conn)) do case Query.build(site, conn.assigns.schema_type, params, debug_metadata(conn)) do
{:ok, query} -> {:ok, query} ->

View File

@ -10,7 +10,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController do
end end
def aggregate(conn, params) do def aggregate(conn, params) do
site = Repo.preload(conn.assigns.site, :owner) site = Repo.preload(conn.assigns.site, :owners)
params = Map.put(params, "property", nil) params = Map.put(params, "property", nil)
@ -31,7 +31,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController do
end end
def breakdown(conn, params) do def breakdown(conn, params) do
site = Repo.preload(conn.assigns.site, :owner) site = Repo.preload(conn.assigns.site, :owners)
with :ok <- validate_period(params), with :ok <- validate_period(params),
:ok <- validate_date(params), :ok <- validate_date(params),
@ -239,7 +239,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController do
defp event_only_property?(_), do: false defp event_only_property?(_), do: false
def timeseries(conn, params) do def timeseries(conn, params) do
site = Repo.preload(conn.assigns.site, :owner) site = Repo.preload(conn.assigns.site, :owners)
params = Map.put(params, "property", nil) params = Map.put(params, "property", nil)

View File

@ -6,9 +6,10 @@ defmodule PlausibleWeb.Api.InternalController do
def sites(conn, _params) do def sites(conn, _params) do
current_user = conn.assigns[:current_user] current_user = conn.assigns[:current_user]
current_team = conn.assigns[:current_team]
if current_user do if current_user do
sites = sites_for(current_user) sites = sites_for(current_user, current_team)
json(conn, %{data: sites}) json(conn, %{data: sites})
else else
@ -54,8 +55,8 @@ defmodule PlausibleWeb.Api.InternalController do
end end
end end
defp sites_for(user) do defp sites_for(user, team) do
pagination = Sites.list(user, %{page_size: 9}) pagination = Sites.list(user, %{page_size: 9}, team: team)
Enum.map(pagination.entries, &%{domain: &1.domain}) Enum.map(pagination.entries, &%{domain: &1.domain})
end end
end end

View File

@ -3,6 +3,7 @@ defmodule PlausibleWeb.AuthController do
use Plausible.Repo use Plausible.Repo
alias Plausible.Auth alias Plausible.Auth
alias Plausible.Teams
alias PlausibleWeb.TwoFactor alias PlausibleWeb.TwoFactor
alias PlausibleWeb.UserAuth alias PlausibleWeb.UserAuth
@ -33,7 +34,9 @@ defmodule PlausibleWeb.AuthController do
:verify_2fa_setup_form, :verify_2fa_setup_form,
:verify_2fa_setup, :verify_2fa_setup,
:disable_2fa, :disable_2fa,
:generate_2fa_recovery_codes :generate_2fa_recovery_codes,
:select_team,
:switch_team
] ]
) )
@ -52,6 +55,67 @@ defmodule PlausibleWeb.AuthController do
TwoFactor.Session.clear_2fa_user(conn) TwoFactor.Session.clear_2fa_user(conn)
end end
def select_team(conn, _params) do
current_user = conn.assigns.current_user
owner_name_fn = fn owner ->
if owner.id == current_user.id do
"You"
else
owner.name
end
end
teams =
current_user
|> Teams.Users.teams()
|> Enum.map(fn team ->
current_team? = team.id == conn.assigns.current_team.id
owners =
Enum.map_join(team.owners, ", ", &owner_name_fn.(&1))
many_owners? = length(team.owners) > 1
%{
identifier: team.identifier,
name: team.name,
current?: current_team?,
many_owners?: many_owners?,
owners: owners
}
end)
render(conn, "select_team.html", teams: teams)
end
def switch_team(conn, params) do
current_user = conn.assigns.current_user
team = Teams.get(params["team_id"])
if team do
case Teams.Memberships.team_role(team, current_user) do
{:ok, role} when role != :guest ->
conn
|> put_session("current_team_id", team.identifier)
|> put_flash(
:success,
"You have switched to \"#{conn.assigns.current_team.name}\" team"
)
|> redirect(to: Routes.site_path(conn, :index))
_ ->
conn
|> put_flash(:error, "You have select an invalid team")
|> redirect(to: Routes.site_path(conn, :index))
end
else
conn
|> put_flash(:error, "You have select an invalid team")
|> redirect(to: Routes.site_path(conn, :index))
end
end
def activate_form(conn, params) do def activate_form(conn, params) do
user = conn.assigns.current_user user = conn.assigns.current_user
flow = params["flow"] || PlausibleWeb.Flows.register() flow = params["flow"] || PlausibleWeb.Flows.register()
@ -433,9 +497,18 @@ defmodule PlausibleWeb.AuthController do
end end
def delete_me(conn, params) do def delete_me(conn, params) do
Plausible.Auth.delete_user(conn.assigns[:current_user]) case Plausible.Auth.delete_user(conn.assigns[:current_user]) do
{:ok, :deleted} ->
logout(conn, params)
logout(conn, params) {:error, :is_only_team_owner} ->
conn
|> put_flash(
:error,
"You can't delete your account when you are the only owner on a team."
)
|> redirect(to: Routes.settings_path(conn, :danger_zone))
end
end end
def logout(conn, params) do def logout(conn, params) do

View File

@ -7,7 +7,10 @@ defmodule PlausibleWeb.InvitationController do
[:owner, :editor, :admin] when action in [:remove_invitation] [:owner, :editor, :admin] when action in [:remove_invitation]
def accept_invitation(conn, %{"invitation_id" => invitation_id}) do def accept_invitation(conn, %{"invitation_id" => invitation_id}) do
case Plausible.Site.Memberships.accept_invitation(invitation_id, conn.assigns.current_user) do current_user = conn.assigns.current_user
team = conn.assigns.current_team
case Plausible.Site.Memberships.accept_invitation(invitation_id, current_user, team) do
{:ok, result} -> {:ok, result} ->
team = result.team team = result.team
@ -38,9 +41,9 @@ defmodule PlausibleWeb.InvitationController do
|> put_flash(:error, "Invitation missing or already accepted") |> put_flash(:error, "Invitation missing or already accepted")
|> redirect(to: "/sites") |> redirect(to: "/sites")
{:error, :already_other_team_member} -> {:error, :permission_denied} ->
conn conn
|> put_flash(:error, "You already are a team member in another team") |> put_flash(:error, "You can't add sites in the current team")
|> redirect(to: "/sites") |> redirect(to: "/sites")
{:error, :no_plan} -> {:error, :no_plan} ->

View File

@ -3,7 +3,7 @@ defmodule PlausibleWeb.Site.MembershipController do
This controller deals with user management via the UI in Site Settings -> People. It's important to enforce permissions in this controller. This controller deals with user management via the UI in Site Settings -> People. It's important to enforce permissions in this controller.
Owner - Can manage users, can trigger a 'transfer ownership' request Owner - Can manage users, can trigger a 'transfer ownership' request
Admin - Can manage users Admin and Editor - Can manage users
Viewer - Can not access user management settings Viewer - Can not access user management settings
Anyone - Can accept invitations Anyone - Can accept invitations
@ -27,7 +27,7 @@ defmodule PlausibleWeb.Site.MembershipController do
site = site =
conn.assigns.current_user conn.assigns.current_user
|> Plausible.Sites.get_for_user!(conn.assigns.site.domain) |> Plausible.Sites.get_for_user!(conn.assigns.site.domain)
|> Plausible.Repo.preload(:owner) |> Plausible.Repo.preload(:owners)
limit = Plausible.Teams.Billing.team_member_limit(site.team) limit = Plausible.Teams.Billing.team_member_limit(site.team)
usage = Plausible.Teams.Billing.team_member_usage(site.team) usage = Plausible.Teams.Billing.team_member_usage(site.team)
@ -48,7 +48,7 @@ defmodule PlausibleWeb.Site.MembershipController do
site = site =
Plausible.Sites.get_for_user!(conn.assigns.current_user, site_domain) Plausible.Sites.get_for_user!(conn.assigns.current_user, site_domain)
|> Plausible.Repo.preload(:owner) |> Plausible.Repo.preload(:owners)
case Memberships.create_invitation(site, conn.assigns.current_user, email, role) do case Memberships.create_invitation(site, conn.assigns.current_user, email, role) do
{:ok, invitation} -> {:ok, invitation} ->

View File

@ -26,12 +26,13 @@ defmodule PlausibleWeb.SiteController do
end end
def create_site(conn, %{"site" => site_params}) do def create_site(conn, %{"site" => site_params}) do
team = conn.assigns.my_team current_team = conn.assigns.current_team
team = Plausible.Teams.get(site_params["team_id"]) || current_team
user = conn.assigns.current_user user = conn.assigns.current_user
first_site? = Plausible.Teams.Billing.site_usage(team) == 0 first_site? = Plausible.Teams.Billing.site_usage(team) == 0
flow = conn.params["flow"] flow = conn.params["flow"]
case Sites.create(user, site_params) do case Sites.create(user, site_params, team) do
{:ok, %{site: site}} -> {:ok, %{site: site}} ->
if first_site? do if first_site? do
PlausibleWeb.Email.welcome_email(user) PlausibleWeb.Email.welcome_email(user)
@ -46,6 +47,18 @@ defmodule PlausibleWeb.SiteController do
) )
) )
{:error, _, :permission_denied, _} ->
conn
|> put_flash(:error, "You are not permitted to add sites in the current team")
|> render("new.html",
changeset: Plausible.Site.changeset(%Plausible.Site{}),
first_site?: first_site?,
site_limit: Plausible.Teams.Billing.site_limit(team),
site_limit_exceeded?: false,
flow: flow,
form_submit_url: "/sites?flow=#{flow}"
)
{:error, _, {:over_limit, limit}, _} -> {:error, _, {:over_limit, limit}, _} ->
render(conn, "new.html", render(conn, "new.html",
changeset: Plausible.Site.changeset(%Plausible.Site{}), changeset: Plausible.Site.changeset(%Plausible.Site{}),

View File

@ -58,7 +58,7 @@ defmodule PlausibleWeb.StatsController do
) )
def stats(%{assigns: %{site: site}} = conn, _params) do def stats(%{assigns: %{site: site}} = conn, _params) do
site = Plausible.Repo.preload(site, :owner) site = Plausible.Repo.preload(site, :owners)
current_user = conn.assigns[:current_user] current_user = conn.assigns[:current_user]
stats_start_date = Plausible.Sites.stats_start_date(site) stats_start_date = Plausible.Sites.stats_start_date(site)
can_see_stats? = not Sites.locked?(site) or conn.assigns[:site_role] == :super_admin can_see_stats? = not Sites.locked?(site) or conn.assigns[:site_role] == :super_admin
@ -94,7 +94,7 @@ defmodule PlausibleWeb.StatsController do
redirect(conn, external: Routes.site_path(conn, :verification, site.domain)) redirect(conn, external: Routes.site_path(conn, :verification, site.domain))
Sites.locked?(site) -> Sites.locked?(site) ->
site = Plausible.Repo.preload(site, :owner) site = Plausible.Repo.preload(site, :owners)
render(conn, "site_locked.html", site: site, dogfood_page_path: dogfood_page_path) render(conn, "site_locked.html", site: site, dogfood_page_path: dogfood_page_path)
end end
end end
@ -119,7 +119,7 @@ defmodule PlausibleWeb.StatsController do
""" """
def csv_export(conn, params) do def csv_export(conn, params) do
if is_nil(params["interval"]) or Plausible.Stats.Interval.valid?(params["interval"]) do if is_nil(params["interval"]) or Plausible.Stats.Interval.valid?(params["interval"]) do
site = Plausible.Repo.preload(conn.assigns.site, :owner) site = Plausible.Repo.preload(conn.assigns.site, :owners)
query = Query.from(site, params, debug_metadata(conn)) query = Query.from(site, params, debug_metadata(conn))
date_range = Query.date_range(query) date_range = Query.date_range(query)
@ -346,7 +346,7 @@ defmodule PlausibleWeb.StatsController do
cond do cond do
!shared_link.site.locked -> !shared_link.site.locked ->
current_user = conn.assigns[:current_user] current_user = conn.assigns[:current_user]
shared_link = Plausible.Repo.preload(shared_link, site: :owner) shared_link = Plausible.Repo.preload(shared_link, site: :owners)
stats_start_date = Plausible.Sites.stats_start_date(shared_link.site) stats_start_date = Plausible.Sites.stats_start_date(shared_link.site)
scroll_depth_visible? = scroll_depth_visible? =
@ -377,10 +377,10 @@ defmodule PlausibleWeb.StatsController do
) )
Sites.locked?(shared_link.site) -> Sites.locked?(shared_link.site) ->
owner = Plausible.Repo.preload(shared_link.site, :owner) owners = Plausible.Repo.preload(shared_link.site, :owners)
render(conn, "site_locked.html", render(conn, "site_locked.html",
owner: owner, owners: owners,
site: shared_link.site, site: shared_link.site,
dogfood_page_path: "/share/:dashboard" dogfood_page_path: "/share/:dashboard"
) )

View File

@ -94,15 +94,7 @@ defmodule PlausibleWeb.Email do
|> render("trial_one_week_reminder.html", user: user) |> render("trial_one_week_reminder.html", user: user)
end end
def trial_upgrade_email(user, day, usage) do def trial_upgrade_email(user, day, usage, suggested_plan) do
team =
case Plausible.Teams.get_by_owner(user) do
{:ok, team} -> team
_ -> nil
end
suggested_plan = Plausible.Billing.Plans.suggest(team, usage.total)
base_email() base_email()
|> to(user) |> to(user)
|> tag("trial-upgrade-email") |> tag("trial-upgrade-email")
@ -198,22 +190,22 @@ defmodule PlausibleWeb.Email do
}) })
end end
def yearly_renewal_notification(team) do def yearly_renewal_notification(team, owner) do
date = Calendar.strftime(team.subscription.next_bill_date, "%B %-d, %Y") date = Calendar.strftime(team.subscription.next_bill_date, "%B %-d, %Y")
priority_email() priority_email()
|> to(team.owner) |> to(owner)
|> tag("yearly-renewal") |> tag("yearly-renewal")
|> subject("Your Plausible subscription is up for renewal") |> subject("Your Plausible subscription is up for renewal")
|> render("yearly_renewal_notification.html", %{ |> render("yearly_renewal_notification.html", %{
user: team.owner, user: owner,
date: date, date: date,
next_bill_amount: team.subscription.next_bill_amount, next_bill_amount: team.subscription.next_bill_amount,
currency: team.subscription.currency_code currency: team.subscription.currency_code
}) })
end end
def yearly_expiration_notification(team) do def yearly_expiration_notification(team, owner) do
next_bill_date = Calendar.strftime(team.subscription.next_bill_date, "%B %-d, %Y") next_bill_date = Calendar.strftime(team.subscription.next_bill_date, "%B %-d, %Y")
accept_traffic_until = accept_traffic_until =
@ -222,11 +214,11 @@ defmodule PlausibleWeb.Email do
|> Calendar.strftime("%B %-d, %Y") |> Calendar.strftime("%B %-d, %Y")
priority_email() priority_email()
|> to(team.owner) |> to(owner)
|> tag("yearly-expiration") |> tag("yearly-expiration")
|> subject("Your Plausible subscription is about to expire") |> subject("Your Plausible subscription is about to expire")
|> render("yearly_expiration_notification.html", %{ |> render("yearly_expiration_notification.html", %{
user: team.owner, user: owner,
next_bill_date: next_bill_date, next_bill_date: next_bill_date,
accept_traffic_until: accept_traffic_until accept_traffic_until: accept_traffic_until
}) })

View File

@ -31,12 +31,49 @@ defmodule PlausibleWeb.Live.AuthContext do
_ -> nil _ -> nil
end end
end) end)
|> assign_new(:my_team, fn context -> |> assign_new(:team_from_session, fn
case context.current_user do %{current_user: nil} ->
nil -> nil nil
%{team_memberships: [%{team: team}]} -> team
%{team_memberships: []} -> nil %{current_user: user} ->
end if current_team_id = session["current_team_id"] do
user.team_memberships
|> Enum.find(%{}, &(&1.team_id == current_team_id))
|> Map.get(:team)
end
end)
|> assign_new(:my_team, fn
%{current_user: nil} ->
nil
%{current_user: user} = context ->
current_team = context.team_from_session
current_team_owner? =
(current_team || %{})
|> Map.get(:owners, [])
|> Enum.any?(&(&1.id == user.id))
if current_team_owner? do
current_team
else
user.team_memberships
# NOTE: my_team should eventually only hold user's personal team. This requires
# additional adjustments, which will be done in follow-up work.
# |> Enum.find(%{}, &(&1.role == :owner and &1.team.setup_complete == false))
|> List.first(%{})
|> Map.get(:team)
end
end)
|> assign_new(:current_team, fn context ->
context.team_from_session || context.my_team
end)
|> assign_new(:teams_count, fn
%{current_user: nil} -> 0
%{current_user: user} -> length(user.team_memberships)
end)
|> assign_new(:multiple_teams?, fn context ->
context.teams_count > 1
end) end)
{:cont, socket} {:cont, socket}

View File

@ -16,7 +16,7 @@ defmodule PlausibleWeb.Live.GoalSettings do
socket socket
|> assign_new(:site, fn %{current_user: current_user} -> |> assign_new(:site, fn %{current_user: current_user} ->
current_user current_user
|> Plausible.Sites.get_for_user!(domain, [:owner, :admin, :super_admin]) |> Plausible.Sites.get_for_user!(domain, [:owner, :admin, :editor, :super_admin])
end) end)
|> assign_new(:all_goals, fn %{site: site} -> |> assign_new(:all_goals, fn %{site: site} ->
Goals.for_site(site, preload_funnels?: true) Goals.for_site(site, preload_funnels?: true)

View File

@ -9,7 +9,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
alias Plausible.Repo alias Plausible.Repo
def update(assigns, socket) do def update(assigns, socket) do
site = Repo.preload(assigns.site, [:team, :owner]) site = Repo.preload(assigns.site, [:team, :owners])
has_access_to_revenue_goals? = has_access_to_revenue_goals? =
Plausible.Billing.Feature.RevenueGoals.check_availability(site.team) == :ok Plausible.Billing.Feature.RevenueGoals.check_availability(site.team) == :ok
@ -297,7 +297,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
~H""" ~H"""
<div class="mt-6 space-y-3" x-data={@js_data}> <div class="mt-6 space-y-3" x-data={@js_data}>
<PlausibleWeb.Components.Billing.Notice.premium_feature <PlausibleWeb.Components.Billing.Notice.premium_feature
billable_user={@site.owner} billable_user={List.first(@site.owners)}
current_user={@current_user} current_user={@current_user}
current_team={@site_team} current_team={@site_team}
feature_mod={Plausible.Billing.Feature.RevenueGoals} feature_mod={Plausible.Billing.Feature.RevenueGoals}

View File

@ -18,6 +18,7 @@ defmodule PlausibleWeb.Live.ImportsExportsSettings do
Plausible.Sites.get_for_user!(current_user, domain, [ Plausible.Sites.get_for_user!(current_user, domain, [
:owner, :owner,
:admin, :admin,
:editor,
:super_admin :super_admin
]) ])
end) end)

View File

@ -35,6 +35,7 @@ defmodule PlausibleWeb.Live.Installation do
Plausible.Sites.get_for_user!(socket.assigns.current_user, domain, [ Plausible.Sites.get_for_user!(socket.assigns.current_user, domain, [
:owner, :owner,
:admin, :admin,
:editor,
:super_admin, :super_admin,
:viewer :viewer
]) ])

View File

@ -14,6 +14,7 @@ defmodule PlausibleWeb.Live.Plugins.API.Settings do
Plausible.Sites.get_for_user!(current_user, domain, [ Plausible.Sites.get_for_user!(current_user, domain, [
:owner, :owner,
:admin, :admin,
:editor,
:super_admin :super_admin
]) ])
end) end)

View File

@ -22,6 +22,7 @@ defmodule PlausibleWeb.Live.Plugins.API.TokenForm do
Plausible.Sites.get_for_user!(current_user, domain, [ Plausible.Sites.get_for_user!(current_user, domain, [
:owner, :owner,
:admin, :admin,
:editor,
:super_admin :super_admin
]) ])
end) end)

View File

@ -14,6 +14,7 @@ defmodule PlausibleWeb.Live.PropsSettings do
Plausible.Sites.get_for_user!(current_user, domain, [ Plausible.Sites.get_for_user!(current_user, domain, [
:owner, :owner,
:admin, :admin,
:editor,
:super_admin :super_admin
]) ])
end) end)

View File

@ -21,6 +21,7 @@ defmodule PlausibleWeb.Live.PropsSettings.Form do
Plausible.Sites.get_for_user!(current_user, domain, [ Plausible.Sites.get_for_user!(current_user, domain, [
:owner, :owner,
:admin, :admin,
:editor,
:super_admin :super_admin
]) ])
end) end)

View File

@ -17,6 +17,7 @@ defmodule PlausibleWeb.Live.Shields.Countries do
Plausible.Sites.get_for_user!(current_user, domain, [ Plausible.Sites.get_for_user!(current_user, domain, [
:owner, :owner,
:admin, :admin,
:editor,
:super_admin :super_admin
]) ])
end) end)

View File

@ -13,6 +13,7 @@ defmodule PlausibleWeb.Live.Shields.Hostnames do
Plausible.Sites.get_for_user!(current_user, domain, [ Plausible.Sites.get_for_user!(current_user, domain, [
:owner, :owner,
:admin, :admin,
:editor,
:super_admin :super_admin
]) ])
end) end)

View File

@ -20,6 +20,7 @@ defmodule PlausibleWeb.Live.Shields.IPAddresses do
Plausible.Sites.get_for_user!(current_user, domain, [ Plausible.Sites.get_for_user!(current_user, domain, [
:owner, :owner,
:admin, :admin,
:editor,
:super_admin :super_admin
]) ])
end) end)

View File

@ -13,6 +13,7 @@ defmodule PlausibleWeb.Live.Shields.Pages do
Plausible.Sites.get_for_user!(current_user, domain, [ Plausible.Sites.get_for_user!(current_user, domain, [
:owner, :owner,
:admin, :admin,
:editor,
:super_admin :super_admin
]) ])
end) end)

View File

@ -651,7 +651,8 @@ defmodule PlausibleWeb.Live.Sites do
defp load_sites(%{assigns: assigns} = socket) do defp load_sites(%{assigns: assigns} = socket) do
sites = sites =
Sites.list_with_invitations(assigns.current_user, assigns.params, Sites.list_with_invitations(assigns.current_user, assigns.params,
filter_by_domain: assigns.filter_text filter_by_domain: assigns.filter_text,
team: assigns.current_team
) )
hourly_stats = hourly_stats =
@ -664,7 +665,7 @@ defmodule PlausibleWeb.Live.Sites do
end) end)
end end
invitations = extract_invitations(sites.entries, assigns.current_user) invitations = extract_invitations(sites.entries, assigns.current_team)
assign( assign(
socket, socket,
@ -674,20 +675,14 @@ defmodule PlausibleWeb.Live.Sites do
) )
end end
defp extract_invitations(sites, user) do defp extract_invitations(sites, team) do
sites sites
|> Enum.filter(&(&1.entry_type == "invitation")) |> Enum.filter(&(&1.entry_type == "invitation"))
|> Enum.flat_map(& &1.invitations) |> Enum.flat_map(& &1.invitations)
|> Enum.map(&check_limits(&1, user)) |> Enum.map(&check_limits(&1, team))
end end
defp check_limits(%{role: :owner, site: site} = invitation, user) do defp check_limits(%{role: :owner, site: site} = invitation, team) do
team =
case Plausible.Teams.get_by_owner(user) do
{:ok, team} -> team
_ -> nil
end
case ensure_can_take_ownership(site, team) do case ensure_can_take_ownership(site, team) do
:ok -> :ok ->
check_features(invitation, team) check_features(invitation, team)

View File

@ -21,6 +21,7 @@ defmodule PlausibleWeb.Live.Verification do
Plausible.Sites.get_for_user!(socket.assigns.current_user, domain, [ Plausible.Sites.get_for_user!(socket.assigns.current_user, domain, [
:owner, :owner,
:admin, :admin,
:editor,
:super_admin, :super_admin,
:viewer :viewer
]) ])

View File

@ -7,7 +7,6 @@ defmodule PlausibleWeb.AuthPlug do
""" """
import Plug.Conn import Plug.Conn
use Plausible.Repo
alias PlausibleWeb.UserAuth alias PlausibleWeb.UserAuth
@ -20,22 +19,44 @@ defmodule PlausibleWeb.AuthPlug do
{:ok, user_session} -> {:ok, user_session} ->
user = user_session.user user = user_session.user
team = current_team_id = Plug.Conn.get_session(conn, "current_team_id")
case user.team_memberships do
[%{team: team}] ->
team
[] -> current_team =
nil if current_team_id do
user.team_memberships
|> Enum.find(%{}, &(&1.team_id == current_team_id))
|> Map.get(:team)
end end
current_team_owner? =
(current_team || %{})
|> Map.get(:owners, [])
|> Enum.any?(&(&1.id == user.id))
my_team =
if current_team_owner? do
current_team
else
user.team_memberships
# NOTE: my_team should eventually only hold user's personal team. This requires
# additional adjustments, which will be done in follow-up work.
# |> Enum.find(%{}, &(&1.role == :owner and &1.team.setup_complete == false))
|> List.first(%{})
|> Map.get(:team)
end
teams_count = length(user.team_memberships)
Plausible.OpenTelemetry.add_user_attributes(user) Plausible.OpenTelemetry.add_user_attributes(user)
Sentry.Context.set_user_context(%{id: user.id, name: user.name, email: user.email}) Sentry.Context.set_user_context(%{id: user.id, name: user.name, email: user.email})
conn conn
|> assign(:current_user, user) |> assign(:current_user, user)
|> assign(:current_user_session, user_session) |> assign(:current_user_session, user_session)
|> assign(:my_team, team) |> assign(:my_team, my_team)
|> assign(:current_team, current_team || my_team)
|> assign(:teams_count, teams_count)
|> assign(:multiple_teams?, teams_count > 1)
_ -> _ ->
conn conn

View File

@ -134,11 +134,7 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do
end end
defp verify_site_access(api_key, site) do defp verify_site_access(api_key, site) do
team = team = Repo.preload(site, :team).team
case Plausible.Teams.get_by_owner(api_key.user) do
{:ok, team} -> team
_ -> nil
end
is_member? = Plausible.Teams.Memberships.site_member?(site, api_key.user) is_member? = Plausible.Teams.Memberships.site_member?(site, api_key.user)
is_super_admin? = Auth.is_super_admin?(api_key.user_id) is_super_admin? = Auth.is_super_admin?(api_key.user_id)

View File

@ -112,7 +112,7 @@ defmodule PlausibleWeb.Plugs.AuthorizeSiteAccess do
site = site =
site site
|> Repo.preload([ |> Repo.preload([
:owner, :owners,
:completed_imports, :completed_imports,
team: [subscription: Plausible.Teams.last_subscription_query()] team: [subscription: Plausible.Teams.last_subscription_query()]
]) ])

View File

@ -100,10 +100,11 @@ defmodule PlausibleWeb.Router do
on_ee do on_ee do
scope "/crm", PlausibleWeb do scope "/crm", PlausibleWeb do
pipe_through :flags pipe_through :flags
get "/auth/user/:user_id/usage", AdminController, :usage get "/teams/team/:team_id/usage", AdminController, :usage
get "/billing/user/:user_id/current_plan", AdminController, :current_plan get "/auth/user/:user_id/info", AdminController, :user_info
get "/billing/search/user-by-id/:user_id", AdminController, :user_by_id get "/billing/team/:team_id/current_plan", AdminController, :current_plan
post "/billing/search/user", AdminController, :user_search get "/billing/search/team-by-id/:team_id", AdminController, :team_by_id
post "/billing/search/team", AdminController, :team_search
end end
end end
@ -406,6 +407,9 @@ defmodule PlausibleWeb.Router do
get "/logout", AuthController, :logout get "/logout", AuthController, :logout
delete "/me", AuthController, :delete_me delete "/me", AuthController, :delete_me
get "/team/select", AuthController, :select_team
post "/team/select/:team_id", AuthController, :switch_team
get "/auth/google/callback", AuthController, :google_auth_callback get "/auth/google/callback", AuthController, :google_auth_callback
on_ee do on_ee do

View File

@ -0,0 +1,32 @@
<.focus_box>
<:title>Switch Team</:title>
<:subtitle>Switch your current team.</:subtitle>
<div>
<ul>
<li
:for={team <- @teams}
class={if team.current?, do: ["border-indigo-400 border-l-4 m-2"], else: ["m-2"]}
>
<.unstyled_link
method={if team.current?, do: "get", else: "post"}
href={
if team.current?,
do: "#",
else: Routes.auth_path(@conn, :switch_team, team.identifier)
}
>
<div class="hover:bg-indigo-100 dark:hover:bg-gray-700 p-4">
<p class="truncate font-medium text-gray-900 dark:text-gray-100" role="none">
{team.name}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Owner{if team.many_owners?, do: "s"}: {team.owners}
</p>
</div>
</.unstyled_link>
</li>
</ul>
</div>
</.focus_box>

View File

@ -72,10 +72,30 @@
{@conn.assigns[:current_user].email} {@conn.assigns[:current_user].email}
</p> </p>
</.dropdown_item> </.dropdown_item>
<.dropdown_item :if={@conn.assigns[:multiple_teams?]}>
<div class="text-xs text-gray-500 dark:text-gray-400">Team</div>
<p class="truncate font-medium text-gray-900 dark:text-gray-100" role="none">
{@current_team.name}
</p>
</.dropdown_item>
<.dropdown_divider /> <.dropdown_divider />
<.dropdown_item href={Routes.settings_path(@conn, :index)}> <.dropdown_item href={Routes.settings_path(@conn, :index)}>
Account Settings Account Settings
</.dropdown_item> </.dropdown_item>
<div :if={Plausible.Teams.enabled?(@my_team) and not @my_team.setup_complete}>
<.dropdown_item class="flex" href={Routes.team_setup_path(@conn, :setup)}>
<span class="flex-1">
Create a Team
</span>
<span class="ml-1 bg-indigo-700 text-gray-100 text-xs p-1 rounded">
NEW
</span>
</.dropdown_item>
<.dropdown_divider />
</div>
<div :if={Plausible.Teams.enabled?(@my_team) and @my_team.setup_complete}> <div :if={Plausible.Teams.enabled?(@my_team) and @my_team.setup_complete}>
<.dropdown_item <.dropdown_item
class="flex" class="flex"
@ -85,6 +105,12 @@
Team Settings Team Settings
</span> </span>
</.dropdown_item> </.dropdown_item>
<.dropdown_item
:if={@conn.assigns[:multiple_teams?]}
href={Routes.auth_path(@conn, :select_team)}
>
Switch Team
</.dropdown_item>
<.dropdown_divider /> <.dropdown_divider />
</div> </div>
<.dropdown_item <.dropdown_item
@ -118,18 +144,6 @@
> >
Github Repo Github Repo
</.dropdown_item> </.dropdown_item>
<.dropdown_divider />
<div :if={Plausible.Teams.enabled?(@my_team) and not @my_team.setup_complete}>
<.dropdown_item class="flex" href={Routes.team_setup_path(@conn, :setup)}>
<span class="flex-1">
Create a Team
</span>
<span class="ml-1 bg-indigo-700 text-gray-100 text-xs p-1 rounded">
NEW
</span>
</.dropdown_item>
<.dropdown_divider />
</div>
<.dropdown_item href="/logout">Log Out</.dropdown_item> <.dropdown_item href="/logout">Log Out</.dropdown_item>
</:menu> </:menu>
</.dropdown> </.dropdown>

View File

@ -12,7 +12,7 @@
<PlausibleWeb.Components.Billing.Notice.limit_exceeded <PlausibleWeb.Components.Billing.Notice.limit_exceeded
:if={Map.get(assigns, :is_at_limit, false)} :if={Map.get(assigns, :is_at_limit, false)}
current_user={@current_user} current_user={@current_user}
billable_user={@site.owner} billable_user={List.first(@site.owners)}
current_team={@site_team} current_team={@site_team}
limit={Map.get(assigns, :team_member_limit, 0)} limit={Map.get(assigns, :team_member_limit, 0)}
resource="team members" resource="team members"

View File

@ -13,7 +13,7 @@
</:subtitle> </:subtitle>
<PlausibleWeb.Components.Billing.Notice.premium_feature <PlausibleWeb.Components.Billing.Notice.premium_feature
billable_user={@site.owner} billable_user={List.first(@site.owners)}
current_user={@current_user} current_user={@current_user}
current_team={@site_team} current_team={@site_team}
feature_mod={Plausible.Billing.Feature.Funnels} feature_mod={Plausible.Billing.Feature.Funnels}

View File

@ -14,7 +14,7 @@
</:subtitle> </:subtitle>
<PlausibleWeb.Components.Billing.Notice.premium_feature <PlausibleWeb.Components.Billing.Notice.premium_feature
billable_user={@site.owner} billable_user={List.first(@site.owners)}
current_user={@current_user} current_user={@current_user}
current_team={@site_team} current_team={@site_team}
feature_mod={Plausible.Billing.Feature.Props} feature_mod={Plausible.Billing.Feature.Props}

View File

@ -44,14 +44,17 @@
<div class="mt-3 text-gray-600 dark:text-gray-300 text-center"> <div class="mt-3 text-gray-600 dark:text-gray-300 text-center">
<p> <p>
This dashboard is currently locked and cannot be accessed. The site owner This dashboard is currently locked and cannot be accessed. The site owner
<b>{@site.owner.email}</b> <b>{List.first(@site.owners).email}</b>
must upgrade their subscription plan in order to must upgrade their subscription plan in order to
unlock the stats. unlock the stats.
</p> </p>
<div class="mt-6 text-sm text-gray-500"> <div
:if={not Plausible.Teams.enabled?(@conn.assigns[:my_team])}
class="mt-6 text-sm text-gray-500"
>
<p>Want to pay for this site with the account you're logged in with?</p> <p>Want to pay for this site with the account you're logged in with?</p>
<p class="mt-1"> <p class="mt-1">
Contact {@site.owner.email} and ask them to Contact {List.first(@site.owners).email} and ask them to
<.styled_link href="https://plausible.io/docs/transfer-ownership" new_tab={true}> <.styled_link href="https://plausible.io/docs/transfer-ownership" new_tab={true}>
transfer the ownership transfer the ownership
</.styled_link> </.styled_link>

View File

@ -134,15 +134,19 @@ defmodule PlausibleWeb.UserAuth do
inner_join: u in assoc(us, :user), inner_join: u in assoc(us, :user),
as: :user, as: :user,
left_join: tm in assoc(u, :team_memberships), left_join: tm in assoc(u, :team_memberships),
# NOTE: whenever my_team.subscription is used to prevent user action, we must check whether the team association is ownership. # NOTE: whenever my_team.subscription is used to prevent user action,
# Otherwise regular members will be limited by team owner in cases like deleting their own account. # we must check whether the team association is ownership.
# Otherwise regular members will be limited by team owner in cases
# like deleting their own account.
on: tm.role != :guest, on: tm.role != :guest,
left_join: t in assoc(tm, :team), left_join: t in assoc(tm, :team),
as: :team, as: :team,
left_join: o in assoc(t, :owners),
left_lateral_join: ts in subquery(last_team_subscription_query), left_lateral_join: ts in subquery(last_team_subscription_query),
on: true, on: true,
where: us.token == ^token and us.timeout_at > ^now, where: us.token == ^token and us.timeout_at > ^now,
preload: [user: {u, team_memberships: {tm, team: {t, subscription: ts}}}] order_by: t.id,
preload: [user: {u, team_memberships: {tm, team: {t, subscription: ts, owners: o}}}]
) )
case Repo.one(token_query) do case Repo.one(token_query) do

View File

@ -26,14 +26,14 @@ defmodule Plausible.Workers.AcceptTrafficUntil do
# send at most one notification per user, per day # send at most one notification per user, per day
sent_today_query = sent_today_query =
from s in "sent_accept_traffic_until_notifications", from s in "sent_accept_traffic_until_notifications",
where: s.user_id == parent_as(:user).id and s.sent_on == ^today, where: s.user_id == parent_as(:users).id and s.sent_on == ^today,
select: true select: true
notifications = notifications =
Repo.all( Repo.all(
from t in Plausible.Teams.Team, from t in Plausible.Teams.Team,
inner_join: u in assoc(t, :owner), inner_join: u in assoc(t, :owners),
as: :user, as: :users,
inner_join: s in assoc(t, :sites), inner_join: s in assoc(t, :sites),
where: t.accept_traffic_until == ^tomorrow or t.accept_traffic_until == ^next_week, where: t.accept_traffic_until == ^tomorrow or t.accept_traffic_until == ^next_week,
where: not exists(sent_today_query), where: not exists(sent_today_query),

View File

@ -40,7 +40,7 @@ defmodule Plausible.Workers.CheckUsage do
Repo.all( Repo.all(
from(t in Teams.Team, from(t in Teams.Team,
as: :team, as: :team,
inner_join: o in assoc(t, :owner), inner_join: o in assoc(t, :owners),
inner_lateral_join: s in subquery(Teams.last_subscription_join_query()), inner_lateral_join: s in subquery(Teams.last_subscription_join_query()),
on: true, on: true,
left_join: ep in Plausible.Billing.EnterprisePlan, left_join: ep in Plausible.Billing.EnterprisePlan,
@ -58,7 +58,7 @@ defmodule Plausible.Workers.CheckUsage do
least(day_of_month(s.last_bill_date), day_of_month(last_day_of_month(^yesterday))) == least(day_of_month(s.last_bill_date), day_of_month(last_day_of_month(^yesterday))) ==
day_of_month(^yesterday), day_of_month(^yesterday),
order_by: t.id, order_by: t.id,
preload: [subscription: s, enterprise_plan: ep, owner: o] preload: [subscription: s, enterprise_plan: ep, owners: o]
) )
) )
@ -110,8 +110,10 @@ defmodule Plausible.Workers.CheckUsage do
suggested_plan = suggested_plan =
Plausible.Billing.Plans.suggest(subscriber, pageview_usage.last_cycle.total) Plausible.Billing.Plans.suggest(subscriber, pageview_usage.last_cycle.total)
PlausibleWeb.Email.over_limit_email(subscriber.owner, pageview_usage, suggested_plan) for owner <- subscriber.owners do
|> Plausible.Mailer.send() PlausibleWeb.Email.over_limit_email(owner, pageview_usage, suggested_plan)
|> Plausible.Mailer.send()
end
Plausible.Teams.start_grace_period(subscriber) Plausible.Teams.start_grace_period(subscriber)
@ -129,13 +131,15 @@ defmodule Plausible.Workers.CheckUsage do
nil nil
{{_, pageview_usage}, {_, {site_usage, site_allowance}}} -> {{_, pageview_usage}, {_, {site_usage, site_allowance}}} ->
PlausibleWeb.Email.enterprise_over_limit_internal_email( for owner <- subscriber.owners do
subscriber.owner, PlausibleWeb.Email.enterprise_over_limit_internal_email(
pageview_usage, owner,
site_usage, pageview_usage,
site_allowance site_usage,
) site_allowance
|> Plausible.Mailer.send() )
|> Plausible.Mailer.send()
end
Plausible.Teams.start_manual_lock_grace_period(subscriber) Plausible.Teams.start_manual_lock_grace_period(subscriber)
end end

View File

@ -25,7 +25,7 @@ defmodule Plausible.Workers.NotifyAnnualRenewal do
Repo.all( Repo.all(
from t in Teams.Team, from t in Teams.Team,
as: :team, as: :team,
inner_join: o in assoc(t, :owner), inner_join: o in assoc(t, :owners),
inner_lateral_join: s in subquery(Teams.last_subscription_join_query()), inner_lateral_join: s in subquery(Teams.last_subscription_join_query()),
on: true, on: true,
left_join: sent in ^sent_notification, left_join: sent in ^sent_notification,
@ -35,29 +35,38 @@ defmodule Plausible.Workers.NotifyAnnualRenewal do
where: where:
s.next_bill_date > fragment("now()::date") and s.next_bill_date > fragment("now()::date") and
s.next_bill_date <= fragment("now()::date + INTERVAL '7 days'"), s.next_bill_date <= fragment("now()::date + INTERVAL '7 days'"),
preload: [owner: o, subscription: s] preload: [owners: o, subscription: s]
) )
for team <- teams do for team <- teams do
case team.subscription.status do case team.subscription.status do
Subscription.Status.active() -> Subscription.Status.active() ->
template = PlausibleWeb.Email.yearly_renewal_notification(team) for owner <- team.owners do
Plausible.Mailer.send(template) template = PlausibleWeb.Email.yearly_renewal_notification(team, owner)
Plausible.Mailer.send(template)
end
Subscription.Status.deleted() -> Subscription.Status.deleted() ->
template = PlausibleWeb.Email.yearly_expiration_notification(team) for owner <- team.owners do
Plausible.Mailer.send(template) template = PlausibleWeb.Email.yearly_expiration_notification(team, owner)
Plausible.Mailer.send(template)
end
_ -> _ ->
Sentry.capture_message("Invalid subscription for renewal", team: team, user: team.owner) Sentry.capture_message("Invalid subscription for renewal",
team: team,
user: List.first(team.owner)
)
end end
Repo.insert_all("sent_renewal_notifications", [ for owner <- team.owners do
%{ Repo.insert_all("sent_renewal_notifications", [
user_id: team.owner.id, %{
timestamp: NaiveDateTime.utc_now() user_id: owner.id,
} timestamp: NaiveDateTime.utc_now()
]) }
])
end
end end
:ok :ok

View File

@ -44,16 +44,16 @@ defmodule Plausible.Workers.SendSiteSetupEmails do
on: se.site_id == s.id, on: se.site_id == s.id,
where: is_nil(se.id), where: is_nil(se.id),
where: s.inserted_at > fragment("(now() at time zone 'utc') - '72 hours'::interval"), where: s.inserted_at > fragment("(now() at time zone 'utc') - '72 hours'::interval"),
preload: [:owner, :team] preload: [:owners, :team]
) )
for site <- Repo.all(q) do for site <- Repo.all(q) do
owner = site.owner owners = site.owners
setup_completed = Plausible.Sites.has_stats?(site) setup_completed = Plausible.Sites.has_stats?(site)
hours_passed = NaiveDateTime.diff(DateTime.utc_now(), site.inserted_at, :hour) hours_passed = NaiveDateTime.diff(DateTime.utc_now(), site.inserted_at, :hour)
if !setup_completed && hours_passed > 47 do if !setup_completed && hours_passed > 47 do
send_setup_help_email(owner, site) send_setup_help_email(owners, site)
end end
end end
end end
@ -66,7 +66,7 @@ defmodule Plausible.Workers.SendSiteSetupEmails do
where: is_nil(se.id), where: is_nil(se.id),
inner_join: t in assoc(s, :team), inner_join: t in assoc(s, :team),
where: s.inserted_at > fragment("(now() at time zone 'utc') - '72 hours'::interval"), where: s.inserted_at > fragment("(now() at time zone 'utc') - '72 hours'::interval"),
preload: [:owner, team: t] preload: [:owners, team: t]
) )
for site <- Repo.all(q) do for site <- Repo.all(q) do
@ -89,8 +89,10 @@ defmodule Plausible.Workers.SendSiteSetupEmails do
end end
defp send_setup_success_email(site) do defp send_setup_success_email(site) do
PlausibleWeb.Email.site_setup_success(site.owner, site.team, site) for owner <- site.owners do
|> Plausible.Mailer.send() PlausibleWeb.Email.site_setup_success(owner, site.team, site)
|> Plausible.Mailer.send()
end
Repo.insert_all("setup_success_emails", [ Repo.insert_all("setup_success_emails", [
%{ %{
@ -100,9 +102,11 @@ defmodule Plausible.Workers.SendSiteSetupEmails do
]) ])
end end
defp send_setup_help_email(user, site) do defp send_setup_help_email(users, site) do
PlausibleWeb.Email.site_setup_help(user, site) for user <- users do
|> Plausible.Mailer.send() PlausibleWeb.Email.site_setup_help(user, site)
|> Plausible.Mailer.send()
end
Repo.insert_all("setup_help_emails", [ Repo.insert_all("setup_help_emails", [
%{ %{

View File

@ -14,34 +14,34 @@ defmodule Plausible.Workers.SendTrialNotifications do
teams = teams =
Repo.all( Repo.all(
from t in Teams.Team, from t in Teams.Team,
inner_join: o in assoc(t, :owner), inner_join: o in assoc(t, :owners),
left_join: s in assoc(t, :subscription), left_join: s in assoc(t, :subscription),
where: not is_nil(t.trial_expiry_date), where: not is_nil(t.trial_expiry_date),
where: is_nil(s.id), where: is_nil(s.id),
order_by: t.inserted_at, order_by: t.inserted_at,
preload: [owner: o] preload: [owners: o]
) )
for team <- teams do for team <- teams do
case Date.diff(team.trial_expiry_date, Date.utc_today()) do case Date.diff(team.trial_expiry_date, Date.utc_today()) do
7 -> 7 ->
if Teams.has_active_sites?(team) do if Teams.has_active_sites?(team) do
send_one_week_reminder(team.owner) send_one_week_reminder(team.owners)
end end
1 -> 1 ->
if Teams.has_active_sites?(team) do if Teams.has_active_sites?(team) do
send_tomorrow_reminder(team.owner, team) send_tomorrow_reminder(team.owners, team)
end end
0 -> 0 ->
if Teams.has_active_sites?(team) do if Teams.has_active_sites?(team) do
send_today_reminder(team.owner, team) send_today_reminder(team.owners, team)
end end
-1 -> -1 ->
if Teams.has_active_sites?(team) do if Teams.has_active_sites?(team) do
send_over_reminder(team.owner) send_over_reminder(team.owners)
end end
_ -> _ ->
@ -52,27 +52,37 @@ defmodule Plausible.Workers.SendTrialNotifications do
:ok :ok
end end
defp send_one_week_reminder(user) do defp send_one_week_reminder(users) do
PlausibleWeb.Email.trial_one_week_reminder(user) for user <- users do
|> Plausible.Mailer.send() PlausibleWeb.Email.trial_one_week_reminder(user)
|> Plausible.Mailer.send()
end
end end
defp send_tomorrow_reminder(user, team) do defp send_tomorrow_reminder(users, team) do
usage = Plausible.Teams.Billing.usage_cycle(team, :last_30_days) usage = Plausible.Teams.Billing.usage_cycle(team, :last_30_days)
suggested_plan = Plausible.Billing.Plans.suggest(team, usage.total)
PlausibleWeb.Email.trial_upgrade_email(user, "tomorrow", usage) for user <- users do
|> Plausible.Mailer.send() PlausibleWeb.Email.trial_upgrade_email(user, "tomorrow", usage, suggested_plan)
|> Plausible.Mailer.send()
end
end end
defp send_today_reminder(user, team) do defp send_today_reminder(users, team) do
usage = Plausible.Teams.Billing.usage_cycle(team, :last_30_days) usage = Plausible.Teams.Billing.usage_cycle(team, :last_30_days)
suggested_plan = Plausible.Billing.Plans.suggest(team, usage.total)
PlausibleWeb.Email.trial_upgrade_email(user, "today", usage) for user <- users do
|> Plausible.Mailer.send() PlausibleWeb.Email.trial_upgrade_email(user, "today", usage, suggested_plan)
|> Plausible.Mailer.send()
end
end end
defp send_over_reminder(user) do defp send_over_reminder(users) do
PlausibleWeb.Email.trial_over_email(user) for user <- users do
|> Plausible.Mailer.send() PlausibleWeb.Email.trial_over_email(user)
|> Plausible.Mailer.send()
end
end end
end end

View File

@ -64,6 +64,15 @@ defmodule Plausible.AuthTest do
assert {:error, :upgrade_required} = assert {:error, :upgrade_required} =
Auth.create_api_key(user, "my new key", Ecto.UUID.generate()) Auth.create_api_key(user, "my new key", Ecto.UUID.generate())
end end
test "creates a key for user on a growth plan when they are an owner of more than one team" do
user = new_user() |> subscribe_to_growth_plan()
another_site = new_site()
add_member(another_site.team, user: user, role: :owner)
assert {:ok, %Auth.ApiKey{}} =
Auth.create_api_key(user, "my new key", Ecto.UUID.generate())
end
end end
describe "delete_api_key/2" do describe "delete_api_key/2" do

View File

@ -9,10 +9,12 @@ defmodule Plausible.Billing.EnterprisePlanAdminTest do
test "sanitizes number inputs and whitespace" do test "sanitizes number inputs and whitespace" do
user = new_user() user = new_user()
_site = new_site(owner: user)
team = team_of(user)
changeset = changeset =
EnterprisePlanAdmin.create_changeset(%EnterprisePlan{}, %{ EnterprisePlanAdmin.create_changeset(%EnterprisePlan{}, %{
"user_id" => to_string(user.id), "team_id" => to_string(team.id),
"paddle_plan_id" => " . 123456 ", "paddle_plan_id" => " . 123456 ",
"billing_interval" => "monthly", "billing_interval" => "monthly",
"monthly_pageview_limit" => "100,000,000", "monthly_pageview_limit" => "100,000,000",
@ -34,10 +36,12 @@ defmodule Plausible.Billing.EnterprisePlanAdminTest do
test "scrubs empty attrs" do test "scrubs empty attrs" do
user = new_user() user = new_user()
_site = new_site(owner: user)
team = team_of(user)
changeset = changeset =
EnterprisePlanAdmin.create_changeset(%EnterprisePlan{}, %{ EnterprisePlanAdmin.create_changeset(%EnterprisePlan{}, %{
"user_id" => to_string(user.id), "team_id" => to_string(team.id),
"paddle_plan_id" => " ,. ", "paddle_plan_id" => " ,. ",
"billing_interval" => "monthly", "billing_interval" => "monthly",
"monthly_pageview_limit" => "100,000,000", "monthly_pageview_limit" => "100,000,000",

View File

@ -55,13 +55,14 @@ defmodule Plausible.HelpScoutTest do
describe "get_details_for_customer/2" do describe "get_details_for_customer/2" do
test "returns details for user on trial" do test "returns details for user on trial" do
%{id: user_id, email: email} = new_user(trial_expiry_date: Date.utc_today()) %{email: email} = user = new_user(trial_expiry_date: Date.utc_today())
stub_help_scout_requests(email) stub_help_scout_requests(email)
team = team_of(user)
crm_url = "#{PlausibleWeb.Endpoint.url()}/crm/auth/user/#{user_id}" crm_url = "#{PlausibleWeb.Endpoint.url()}/crm/teams/team/#{team.id}"
owned_sites_url = owned_sites_url =
"#{PlausibleWeb.Endpoint.url()}/crm/sites/site?custom_search=#{URI.encode_www_form(email)}" "#{PlausibleWeb.Endpoint.url()}/crm/sites/site?custom_search=#{URI.encode_www_form(team.identifier)}"
assert {:ok, assert {:ok,
%{ %{
@ -405,12 +406,13 @@ defmodule Plausible.HelpScoutTest do
describe "get_details_for_emails/2" do describe "get_details_for_emails/2" do
test "returns details for user and persists mapping" do test "returns details for user and persists mapping" do
%{id: user_id, email: email} = new_user(trial_expiry_date: Date.utc_today()) %{email: email} = user = new_user(trial_expiry_date: Date.utc_today())
team = team_of(user)
crm_url = "#{PlausibleWeb.Endpoint.url()}/crm/auth/user/#{user_id}" crm_url = "#{PlausibleWeb.Endpoint.url()}/crm/teams/team/#{team.id}"
owned_sites_url = owned_sites_url =
"#{PlausibleWeb.Endpoint.url()}/crm/sites/site?custom_search=#{URI.encode_www_form(email)}" "#{PlausibleWeb.Endpoint.url()}/crm/sites/site?custom_search=#{URI.encode_www_form(team.identifier)}"
assert {:ok, assert {:ok,
%{ %{
@ -444,11 +446,12 @@ defmodule Plausible.HelpScoutTest do
user2 = new_user() user2 = new_user()
new_site(owner: user2) new_site(owner: user2)
new_site(owner: user2) new_site(owner: user2)
team2 = team_of(user2)
crm_url = "#{PlausibleWeb.Endpoint.url()}/crm/auth/user/#{user2.id}" crm_url = "#{PlausibleWeb.Endpoint.url()}/crm/teams/team/#{team2.id}"
owned_sites_url = owned_sites_url =
"#{PlausibleWeb.Endpoint.url()}/crm/sites/site?custom_search=#{URI.encode_www_form(user2.email)}" "#{PlausibleWeb.Endpoint.url()}/crm/sites/site?custom_search=#{URI.encode_www_form(team2.identifier)}"
assert {:ok, assert {:ok,
%{ %{

View File

@ -89,6 +89,64 @@ defmodule Plausible.Site.AdminTest do
action.(conn, [site], %{"email" => current_owner.email}) action.(conn, [site], %{"email" => current_owner.email})
end end
test "the provided team identifier must be valid UUID format", %{
conn: conn,
transfer_direct_action: action
} do
today = Date.utc_today()
current_owner = new_user()
site = new_site(owner: current_owner)
new_owner =
new_user()
|> subscribe_to_growth_plan(last_bill_date: Date.shift(today, day: -5))
assert {:error, "The provided team identifier is invalid"} =
action.(conn, [site], %{"email" => new_owner.email, "team_id" => "invalid"})
end
test "new owner must be owner on a single team if no team identifier provided", %{
conn: conn,
transfer_direct_action: action
} do
today = Date.utc_today()
current_owner = new_user()
site = new_site(owner: current_owner)
new_owner =
new_user()
|> subscribe_to_growth_plan(last_bill_date: Date.shift(today, day: -5))
another_site = new_site()
add_member(another_site.team, user: new_owner, role: :owner)
assert {:error, "The new owner owns more than one team"} =
action.(conn, [site], %{"email" => new_owner.email})
end
test "new owner must be permitted to add sites in the selected team", %{
conn: conn,
transfer_direct_action: action
} do
today = Date.utc_today()
current_owner = new_user()
site = new_site(owner: current_owner)
new_owner =
new_user()
|> subscribe_to_growth_plan(last_bill_date: Date.shift(today, day: -5))
another_site = new_site()
new_team = another_site.team
add_member(new_team, user: new_owner, role: :viewer)
assert {:error, "The new owner can't add sites in the selected team"} =
action.(conn, [site], %{
"email" => new_owner.email,
"team_id" => new_team.identifier
})
end
@tag :ee_only @tag :ee_only
test "new owner's plan must accommodate the transferred site", %{ test "new owner's plan must accommodate the transferred site", %{
conn: conn, conn: conn,
@ -126,5 +184,32 @@ defmodule Plausible.Site.AdminTest do
assert :ok = action.(conn, [site1, site2], %{"email" => new_owner.email}) assert :ok = action.(conn, [site1, site2], %{"email" => new_owner.email})
end end
test "executes ownership transfer for multiple sites in one action for provided team", %{
conn: conn,
transfer_direct_action: action
} do
today = Date.utc_today()
current_owner = new_user()
new_owner = new_user()
another_owner =
new_user()
|> subscribe_to_growth_plan(last_bill_date: Date.shift(today, day: -5))
another_site = new_site(owner: another_owner)
another_team = another_site.team
add_member(another_team, user: new_owner, role: :admin)
site1 = new_site(owner: current_owner)
site2 = new_site(owner: current_owner)
assert :ok =
action.(conn, [site1, site2], %{
"email" => new_owner.email,
"team_id" => another_team.identifier
})
end
end end
end end

View File

@ -46,6 +46,66 @@ defmodule Plausible.Site.Memberships.AcceptInvitationTest do
assert_team_membership(new_owner, site2.team, :owner) assert_team_membership(new_owner, site2.team, :owner)
end end
test "does not allow transferring ownership without selecting team for owner of more than one team" do
new_owner = new_user() |> subscribe_to_growth_plan()
other_site1 = new_site()
add_member(other_site1.team, user: new_owner, role: :owner)
other_site2 = new_site()
add_member(other_site2.team, user: new_owner, role: :owner)
current_owner = new_user()
site1 = new_site(owner: current_owner)
site2 = new_site(owner: current_owner)
assert {:error, :multiple_teams} =
AcceptInvitation.bulk_transfer_ownership_direct(
[site1, site2],
new_owner
)
end
test "does not allow transferring ownership to a team where user has no permission" do
other_owner = new_user() |> subscribe_to_growth_plan()
other_team = team_of(other_owner)
new_owner = new_user()
add_member(other_team, user: new_owner, role: :viewer)
current_owner = new_user()
site1 = new_site(owner: current_owner)
site2 = new_site(owner: current_owner)
assert {:error, :permission_denied} =
AcceptInvitation.bulk_transfer_ownership_direct(
[site1, site2],
new_owner,
other_team
)
end
test "allows transferring ownership to a team where user has permission" do
other_owner = new_user() |> subscribe_to_growth_plan()
other_team = team_of(other_owner)
new_owner = new_user()
add_member(other_team, user: new_owner, role: :admin)
current_owner = new_user()
site1 = new_site(owner: current_owner)
site2 = new_site(owner: current_owner)
assert {:ok, _} =
AcceptInvitation.bulk_transfer_ownership_direct(
[site1, site2],
new_owner,
other_team
)
assert Repo.reload(site1).team_id == other_team.id
assert_guest_membership(other_team, site1, current_owner, :editor)
assert Repo.reload(site2).team_id == other_team.id
assert_guest_membership(other_team, site2, current_owner, :editor)
end
@tag :ee_only @tag :ee_only
test "does not allow transferring ownership to a non-member user when at team members limit" do test "does not allow transferring ownership to a non-member user when at team members limit" do
old_owner = new_user() |> subscribe_to_business_plan() old_owner = new_user() |> subscribe_to_business_plan()
@ -160,7 +220,7 @@ defmodule Plausible.Site.Memberships.AcceptInvitationTest do
end end
for role <- @roles do for role <- @roles do
test "does not allow accepting invite by a member of another team (role: #{role})" do test "does allow accepting invite by a member of another team (role: #{role})" do
user = new_user() user = new_user()
_site = new_site(owner: user) _site = new_site(owner: user)
team = team_of(user) team = team_of(user)
@ -169,7 +229,7 @@ defmodule Plausible.Site.Memberships.AcceptInvitationTest do
invitation = invite_member(team, member, inviter: user, role: unquote(role)) invitation = invite_member(team, member, inviter: user, role: unquote(role))
assert {:error, :already_other_team_member} = assert {:ok, _} =
AcceptInvitation.accept_invitation(invitation.invitation_id, member) AcceptInvitation.accept_invitation(invitation.invitation_id, member)
end end
end end
@ -358,6 +418,56 @@ defmodule Plausible.Site.Memberships.AcceptInvitationTest do
end end
end end
test "does not allow transferring ownership without selecting team for owner of more than one team" do
old_owner = new_user() |> subscribe_to_business_plan()
new_owner = new_user() |> subscribe_to_growth_plan()
site = new_site(owner: old_owner)
site1 = new_site()
add_member(site1.team, user: new_owner, role: :owner)
site2 = new_site()
add_member(site2.team, user: new_owner, role: :owner)
transfer = invite_transfer(site, new_owner, inviter: old_owner)
assert {:error, :multiple_teams} =
AcceptInvitation.accept_invitation(transfer.transfer_id, new_owner)
end
test "does not allow transferring ownership to a team where user has no permission" do
old_owner = new_user() |> subscribe_to_business_plan()
new_owner = new_user() |> subscribe_to_growth_plan()
site = new_site(owner: old_owner)
another_site = new_site()
another_team = another_site.team
add_member(another_team, user: new_owner, role: :viewer)
transfer = invite_transfer(site, new_owner, inviter: old_owner)
assert {:error, :permission_denied} =
AcceptInvitation.accept_invitation(transfer.transfer_id, new_owner, another_team)
end
test "allows transferring ownership to a team where user has permission" do
old_owner = new_user() |> subscribe_to_business_plan()
new_owner = new_user()
site = new_site(owner: old_owner)
another_owner = new_user() |> subscribe_to_growth_plan()
another_site = new_site(owner: another_owner)
another_team = another_site.team
add_member(another_team, user: new_owner, role: :admin)
transfer = invite_transfer(site, new_owner, inviter: old_owner)
assert {:ok, _} =
AcceptInvitation.accept_invitation(transfer.transfer_id, new_owner, another_team)
assert_guest_membership(another_team, site, old_owner, :editor)
assert Repo.reload(site).team_id == another_team.id
end
@tag :ee_only @tag :ee_only
test "does not allow transferring ownership to a non-member user when at team members limit" do test "does not allow transferring ownership to a non-member user when at team members limit" do
old_owner = new_user() |> subscribe_to_business_plan() old_owner = new_user() |> subscribe_to_business_plan()

View File

@ -48,6 +48,48 @@ defmodule Plausible.SitesTest do
assert {:error, :site, %{errors: [timezone: {"is invalid", []}]}, %{}} = assert {:error, :site, %{errors: [timezone: {"is invalid", []}]}, %{}} =
Sites.create(user, params) Sites.create(user, params)
end end
test "fails for user owning more than one team without explicit pick" do
user = new_user()
_site1 = new_site(owner: user)
site2 = new_site()
add_member(site2.team, user: user, role: :owner)
params = %{"domain" => "example.com", "timezone" => "Europe/London"}
assert {:error, _, :multiple_teams, _} = Sites.create(user, params)
end
test "fails for user not being permitted to add sites in selected team" do
user = new_user()
site = new_site()
viewer_team = site.team
add_member(viewer_team, user: user, role: :viewer)
other_site = new_site()
other_team = other_site.team
params = %{"domain" => "example.com", "timezone" => "Europe/London"}
assert {:error, _, :permission_denied, _} = Sites.create(user, params, viewer_team)
assert {:error, _, :permission_denied, _} = Sites.create(user, params, other_team)
end
test "succeeds for user being permitted to add sites in selected team" do
user = new_user()
viewer_site = new_site()
viewer_team = viewer_site.team
editor_site = new_site()
editor_team = editor_site.team
add_member(viewer_team, user: user, role: :viewer)
add_member(editor_team, user: user, role: :editor)
params = %{"domain" => "example.com", "timezone" => "Europe/London"}
assert {:ok, %{site: site}} = Sites.create(user, params, editor_team)
assert site.team_id == editor_team.id
end
end end
describe "stats_start_date" do describe "stats_start_date" do
@ -416,6 +458,51 @@ defmodule Plausible.SitesTest do
} = Sites.list_with_invitations(user1, %{}, filter_by_domain: "first") } = Sites.list_with_invitations(user1, %{}, filter_by_domain: "first")
end end
test "scopes by team when provided" do
user1 = new_user()
user2 = new_user()
user3 = new_user()
site1 = new_site(owner: user1, domain: "first.example.com")
site2 = new_site(owner: user2, domain: "first-transfer.example.com")
site3 = new_site(owner: user3, domain: "first-invitation.example.com")
site4 = new_site(domain: "zzzsitefromanotherteam.com")
invite_guest(site3, user1, role: :viewer, inviter: user3)
invite_transfer(site2, user1, inviter: user2)
add_member(site4.team, user: user1, role: :editor)
assert_matches %{
entries: [
%{id: ^site1.id},
%{id: ^site4.id}
]
} = Sites.list(user1, %{})
assert_matches %{
entries: [
%{id: ^site3.id},
%{id: ^site2.id},
%{id: ^site1.id},
%{id: ^site4.id}
]
} = Sites.list_with_invitations(user1, %{})
assert_matches %{
entries: [
%{id: ^site4.id}
]
} = Sites.list(user1, %{}, team: site4.team)
assert_matches %{
entries: [
%{id: ^site3.id},
%{id: ^site2.id},
%{id: ^site4.id}
]
} = Sites.list_with_invitations(user1, %{}, team: site4.team)
end
test "handles pagination correctly" do test "handles pagination correctly" do
user1 = new_user() user1 = new_user()
user2 = new_user() user2 = new_user()

View File

@ -6,6 +6,217 @@ defmodule Plausible.TeamsTest do
alias Plausible.Teams alias Plausible.Teams
alias Plausible.Repo alias Plausible.Repo
describe "get_or_create/1" do
test "creates 'My Team' if user is a member of none" do
today = Date.utc_today()
user = new_user()
user_id = user.id
assert {:ok, team} = Teams.get_or_create(user)
assert team.name == "My Team"
assert Date.compare(team.trial_expiry_date, today) == :gt
assert [
%{user_id: ^user_id, role: :owner, is_autocreated: true}
] = Repo.preload(team, :team_memberships).team_memberships
end
test "returns existing 'My Team' if user already owns one" do
user = new_user(trial_expiry_date: ~D[2020-04-01])
user_id = user.id
existing_team = team_of(user)
assert {:ok, team} = Teams.get_or_create(user)
assert team.id == existing_team.id
assert Date.compare(team.trial_expiry_date, ~D[2020-04-01])
assert [
%{user_id: ^user_id, role: :owner, is_autocreated: true}
] = Repo.preload(team, :team_memberships).team_memberships
end
test "returns existing owned team even if explicitly assigned as owner" do
user = new_user()
user_id = user.id
site = new_site()
existing_team = site.team
add_member(existing_team, user: user, role: :owner)
assert {:ok, team} = Teams.get_or_create(user)
assert team.id == existing_team.id
assert [
%{role: :owner},
%{user_id: ^user_id, role: :owner, is_autocreated: false}
] =
team
|> Repo.preload(:team_memberships)
|> Map.fetch!(:team_memberships)
|> Enum.sort_by(& &1.id)
end
test "creates 'My Team' if user is a guest on another team" do
user = new_user()
user_id = user.id
site = new_site()
existing_team = site.team
add_guest(site, user: user, role: :editor)
assert {:ok, team} = Teams.get_or_create(user)
assert team.id != existing_team.id
assert [%{user_id: ^user_id, role: :owner, is_autocreated: true}] =
team
|> Repo.preload(:team_memberships)
|> Map.fetch!(:team_memberships)
end
test "creates 'My Team' if user is a non-owner member on existing teams" do
user = new_user()
user_id = user.id
site1 = new_site()
team1 = site1.team
site2 = new_site()
team2 = site2.team
add_member(team1, user: user, role: :viewer)
add_member(team2, user: user, role: :editor)
assert {:ok, team} = Teams.get_or_create(user)
assert team.id != team1.id
assert team.id != team2.id
assert [%{user_id: ^user_id, role: :owner, is_autocreated: true}] =
team
|> Repo.preload(:team_memberships)
|> Map.fetch!(:team_memberships)
end
test "returns existing owned team if user is also a non-owner member on existing teams" do
user = new_user()
_site = new_site(owner: user)
user_id = user.id
owned_team = team_of(user)
site1 = new_site()
team1 = site1.team
site2 = new_site()
team2 = site2.team
add_member(team1, user: user, role: :viewer)
add_member(team2, user: user, role: :editor)
assert {:ok, team} = Teams.get_or_create(user)
assert team.id == owned_team.id
assert [%{user_id: ^user_id, role: :owner, is_autocreated: true}] =
team
|> Repo.preload(:team_memberships)
|> Map.fetch!(:team_memberships)
end
test "returns error if user is an owner of more than one team already" do
user = new_user()
site1 = new_site()
team1 = site1.team
site2 = new_site()
team2 = site2.team
add_member(team1, user: user, role: :owner)
add_member(team2, user: user, role: :owner)
assert {:error, :multiple_teams} = Teams.get_or_create(user)
end
end
describe "get_by_owner/1" do
test "returns error if user does not own any team" do
user = new_user()
assert {:error, :no_team} = Teams.get_by_owner(user)
end
test "returns error if user does not exist anymore" do
user = new_user()
_site = new_site(owner: user)
Repo.delete!(user)
assert {:error, :no_team} = Teams.get_by_owner(user)
end
test "returns existing 'My Team' if user already owns one" do
user = new_user(trial_expiry_date: ~D[2020-04-01])
user_id = user.id
existing_team = team_of(user)
assert {:ok, team} = Teams.get_by_owner(user)
assert team.id == existing_team.id
assert Date.compare(team.trial_expiry_date, ~D[2020-04-01])
assert [
%{user_id: ^user_id, role: :owner, is_autocreated: true}
] = Repo.preload(team, :team_memberships).team_memberships
end
test "returns existing owned team if explicitly assigned as owner" do
user = new_user()
user_id = user.id
site = new_site()
existing_team = site.team
add_member(existing_team, user: user, role: :owner)
assert {:ok, team} = Teams.get_by_owner(user)
assert team.id == existing_team.id
assert [
%{role: :owner},
%{user_id: ^user_id, role: :owner, is_autocreated: false}
] =
team
|> Repo.preload(:team_memberships)
|> Map.fetch!(:team_memberships)
|> Enum.sort_by(& &1.id)
end
test "returns existing owned team if user is also a non-owner member on existing teams" do
user = new_user()
_site = new_site(owner: user)
user_id = user.id
owned_team = team_of(user)
site1 = new_site()
team1 = site1.team
site2 = new_site()
team2 = site2.team
add_member(team1, user: user, role: :viewer)
add_member(team2, user: user, role: :editor)
assert {:ok, team} = Teams.get_by_owner(user)
assert team.id == owned_team.id
assert [%{user_id: ^user_id, role: :owner, is_autocreated: true}] =
team
|> Repo.preload(:team_memberships)
|> Map.fetch!(:team_memberships)
end
test "returns error if user is an owner of more than one team" do
user = new_user()
site1 = new_site()
team1 = site1.team
site2 = new_site()
team2 = site2.team
add_member(team1, user: user, role: :owner)
add_member(team2, user: user, role: :owner)
assert {:error, :multiple_teams} = Teams.get_by_owner(user)
end
end
describe "trial_days_left" do describe "trial_days_left" do
test "is 30 days for new signup" do test "is 30 days for new signup" do
user = new_user(trial_expiry_date: Teams.Team.trial_expiry()) user = new_user(trial_expiry_date: Teams.Team.trial_expiry())

View File

@ -4,26 +4,30 @@ defmodule PlausibleWeb.AdminControllerTest do
alias Plausible.Repo alias Plausible.Repo
describe "GET /crm/auth/user/:user_id/usage" do describe "GET /crm/teams/team/:team_id/usage" do
setup [:create_user, :log_in] setup [:create_user, :log_in, :create_team]
@tag :ee_only @tag :ee_only
test "returns 403 if the logged in user is not a super admin", %{conn: conn} do test "returns 403 if the logged in user is not a super admin", %{conn: conn} do
conn = get(conn, "/crm/auth/user/1/usage") conn = get(conn, "/crm/teams/team/1/usage")
assert response(conn, 403) == "Not allowed" assert response(conn, 403) == "Not allowed"
end end
@tag :ee_only @tag :ee_only
test "returns usage data as a standalone page", %{conn: conn, user: user} do test "returns usage data as a standalone page", %{conn: conn, user: user, team: team} do
patch_env(:super_admin_user_ids, [user.id]) patch_env(:super_admin_user_ids, [user.id])
conn = get(conn, "/crm/auth/user/#{user.id}/usage") conn = get(conn, "/crm/teams/team/#{team.id}/usage")
assert response(conn, 200) =~ "<html" assert response(conn, 200) =~ "<html"
end end
@tag :ee_only @tag :ee_only
test "returns usage data in embeddable form when requested", %{conn: conn, user: user} do test "returns usage data in embeddable form when requested", %{
conn: conn,
user: user,
team: team
} do
patch_env(:super_admin_user_ids, [user.id]) patch_env(:super_admin_user_ids, [user.id])
conn = get(conn, "/crm/auth/user/#{user.id}/usage?embed=true") conn = get(conn, "/crm/teams/team/#{team.id}/usage?embed=true")
refute response(conn, 200) =~ "<html" refute response(conn, 200) =~ "<html"
end end
end end
@ -102,23 +106,25 @@ defmodule PlausibleWeb.AdminControllerTest do
@tag :ee_only @tag :ee_only
test "returns 403 if the logged in user is not a super admin", %{conn: conn} do test "returns 403 if the logged in user is not a super admin", %{conn: conn} do
conn = get(conn, "/crm/billing/user/0/current_plan") conn = get(conn, "/crm/billing/team/0/current_plan")
assert response(conn, 403) == "Not allowed" assert response(conn, 403) == "Not allowed"
end end
@tag :ee_only @tag :ee_only
test "returns empty state for non-existent user", %{conn: conn, user: user} do test "returns empty state for non-existent team", %{conn: conn, user: user} do
patch_env(:super_admin_user_ids, [user.id]) patch_env(:super_admin_user_ids, [user.id])
conn = get(conn, "/crm/billing/user/0/current_plan") conn = get(conn, "/crm/billing/team/0/current_plan")
assert json_response(conn, 200) == %{"features" => []} assert json_response(conn, 200) == %{"features" => []}
end end
@tag :ee_only @tag :ee_only
test "returns empty state for user without subscription", %{conn: conn, user: user} do test "returns empty state for user without subscription", %{conn: conn, user: user} do
patch_env(:super_admin_user_ids, [user.id]) patch_env(:super_admin_user_ids, [user.id])
_site = new_site(owner: user)
team = team_of(user)
conn = get(conn, "/crm/billing/user/#{user.id}/current_plan") conn = get(conn, "/crm/billing/team/#{team.id}/current_plan")
assert json_response(conn, 200) == %{"features" => []} assert json_response(conn, 200) == %{"features" => []}
end end
@ -131,7 +137,9 @@ defmodule PlausibleWeb.AdminControllerTest do
subscribe_to_plan(user, "does-not-exist") subscribe_to_plan(user, "does-not-exist")
conn = get(conn, "/crm/billing/user/#{user.id}/current_plan") team = team_of(user)
conn = get(conn, "/crm/billing/team/#{team.id}/current_plan")
assert json_response(conn, 200) == %{"features" => []} assert json_response(conn, 200) == %{"features" => []}
end end
@ -140,8 +148,9 @@ defmodule PlausibleWeb.AdminControllerTest do
patch_env(:super_admin_user_ids, [user.id]) patch_env(:super_admin_user_ids, [user.id])
subscribe_to_plan(user, "857104") subscribe_to_plan(user, "857104")
team = team_of(user)
conn = get(conn, "/crm/billing/user/#{user.id}/current_plan") conn = get(conn, "/crm/billing/team/#{team.id}/current_plan")
assert json_response(conn, 200) == %{ assert json_response(conn, 200) == %{
"features" => ["goals"], "features" => ["goals"],

View File

@ -567,6 +567,23 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
} = json_response(conn, 200) } = json_response(conn, 200)
end end
test "returns sites scoped to a given team for full memberships", %{conn: conn, user: user} do
_owned_site = new_site(owner: user)
other_site = new_site()
add_guest(other_site, user: user, role: :viewer)
other_team_site = new_site()
add_member(other_team_site.team, user: user, role: :viewer)
conn = get(conn, "/api/v1/sites?team_id=" <> other_team_site.team.identifier)
assert_matches %{
"sites" => [
%{"domain" => ^other_team_site.domain},
%{"domain" => ^other_site.domain}
]
} = json_response(conn, 200)
end
test "handles pagination correctly", %{conn: conn, user: user} do test "handles pagination correctly", %{conn: conn, user: user} do
[ [
%{domain: site1_domain}, %{domain: site1_domain},

View File

@ -610,6 +610,62 @@ defmodule PlausibleWeb.AuthControllerTest do
assert Repo.get(Plausible.Site, viewer_site.id) assert Repo.get(Plausible.Site, viewer_site.id)
refute Repo.get(Plausible.Site, owner_site.id) refute Repo.get(Plausible.Site, owner_site.id)
end end
test "refuses to delete user when an only owner of a setup team", %{
conn: conn,
user: user,
site: site
} do
site.team
|> Plausible.Teams.Team.setup_changeset()
|> Repo.update!()
conn = delete(conn, "/me")
assert redirected_to(conn, 302) == Routes.settings_path(conn, :danger_zone)
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"You can't delete your account when you are the only owner on a team"
assert Repo.reload(user)
end
test "refuses to delete user when an only owner of multiple setup teams", %{
conn: conn,
user: user,
site: site
} do
site.team
|> Plausible.Teams.Team.setup_changeset()
|> Repo.update!()
another_owner = new_user()
another_site = new_site(owner: another_owner)
add_member(another_site.team, user: user, role: :owner)
Repo.delete!(another_owner)
conn = delete(conn, "/me")
assert redirected_to(conn, 302) == Routes.settings_path(conn, :danger_zone)
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"You can't delete your account when you are the only owner on a team"
assert Repo.reload(user)
end
test "allows to delete user when not the only owner of a setup team", %{
conn: conn,
user: user
} do
another_owner = new_user()
another_site = new_site(owner: another_owner)
add_member(another_site.team, user: user, role: :owner)
delete(conn, "/me")
refute Repo.reload(user)
end
end end
describe "GET /auth/google/callback" do describe "GET /auth/google/callback" do

View File

@ -91,6 +91,24 @@ defmodule PlausibleWeb.Site.InvitationControllerTest do
assert_team_attached(site, new_team.id) assert_team_attached(site, new_team.id)
end end
test "fails when new owner has no permissions for current team", %{conn: conn, user: user} do
old_owner = new_user()
site = new_site(owner: old_owner)
other_owner = new_user() |> subscribe_to_growth_plan()
new_team = team_of(other_owner)
add_member(new_team, user: user, role: :viewer)
transfer = invite_transfer(site, user, inviter: old_owner)
conn = post(conn, "/sites/invitations/#{transfer.transfer_id}/accept")
assert redirected_to(conn, 302) == "/sites"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"You can't add sites in the current team"
end
@tag :ee_only @tag :ee_only
test "fails when new owner has no plan", %{conn: conn, user: user} do test "fails when new owner has no plan", %{conn: conn, user: user} do
old_owner = new_user() old_owner = new_user()

View File

@ -256,6 +256,21 @@ defmodule PlausibleWeb.SiteControllerTest do
assert html_response(conn, 200) =~ "can&#39;t be blank" assert html_response(conn, 200) =~ "can&#39;t be blank"
end end
test "fails to create site when not allowed to in selected team", %{conn: conn, user: user} do
site = new_site()
add_member(site.team, user: user, role: :viewer)
conn =
post(conn, "/sites", %{
"site" => %{
"domain" => "example.com",
"timezone" => "Europe/London"
}
})
assert html_response(conn, 200) =~ "You are not permitted to add sites in the current team"
end
test "starts trial if user does not have trial yet", %{conn: conn, user: user} do test "starts trial if user does not have trial yet", %{conn: conn, user: user} do
refute team_of(user) refute team_of(user)

View File

@ -287,8 +287,8 @@ defmodule PlausibleWeb.StatsControllerTest do
} do } do
{:ok, site} = Plausible.Props.allow(site, ["author"]) {:ok, site} = Plausible.Props.allow(site, ["author"])
site = Repo.preload(site, :owner) [owner | _] = Repo.preload(site, :owners).owners
subscribe_to_growth_plan(site.owner) subscribe_to_growth_plan(owner)
populate_stats(site, [ populate_stats(site, [
build(:pageview, "meta.key": ["author"], "meta.value": ["a"]), build(:pageview, "meta.key": ["author"], "meta.value": ["a"]),
@ -315,8 +315,8 @@ defmodule PlausibleWeb.StatsControllerTest do
} do } do
{:ok, site} = Plausible.Props.allow(site, ["author"]) {:ok, site} = Plausible.Props.allow(site, ["author"])
site = Repo.preload(site, :owner) [owner | _] = Repo.preload(site, :owners).owners
subscribe_to_growth_plan(site.owner) subscribe_to_growth_plan(owner)
populate_stats(site, [ populate_stats(site, [
build(:pageview, "meta.key": ["author"], "meta.value": ["a"]) build(:pageview, "meta.key": ["author"], "meta.value": ["a"])

View File

@ -999,7 +999,8 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do
user: user user: user
} do } do
user user
|> Plausible.Auth.User.changeset(%{trial_expiry_date: nil}) |> team_of()
|> Ecto.Changeset.change(trial_expiry_date: nil)
|> Repo.update!() |> Repo.update!()
{:ok, lv, _doc} = get_liveview(conn) {:ok, lv, _doc} = get_liveview(conn)

View File

@ -88,8 +88,8 @@ defmodule PlausibleWeb.Plugins.API.Controllers.CapabilitiesTest do
@tag :ee_only @tag :ee_only
test "growth", %{conn: conn, site: site, token: token} do test "growth", %{conn: conn, site: site, token: token} do
site = Plausible.Repo.preload(site, :owner) [owner | _] = Plausible.Repo.preload(site, :owners).owners
subscribe_to_growth_plan(site.owner) subscribe_to_growth_plan(owner)
resp = resp =
conn conn

View File

@ -42,8 +42,8 @@ defmodule PlausibleWeb.Plugins.API.Controllers.CustomPropsTest do
token: token, token: token,
conn: conn conn: conn
} do } do
site = Plausible.Repo.preload(site, :owner) [owner | _] = Plausible.Repo.preload(site, :owners).owners
subscribe_to_growth_plan(site.owner) subscribe_to_growth_plan(owner)
url = Routes.plugins_api_custom_props_url(PlausibleWeb.Endpoint, :enable) url = Routes.plugins_api_custom_props_url(PlausibleWeb.Endpoint, :enable)
@ -66,8 +66,8 @@ defmodule PlausibleWeb.Plugins.API.Controllers.CustomPropsTest do
token: token, token: token,
conn: conn conn: conn
} do } do
site = Plausible.Repo.preload(site, :owner) [owner | _] = Plausible.Repo.preload(site, :owners).owners
subscribe_to_growth_plan(site.owner) subscribe_to_growth_plan(owner)
url = Routes.plugins_api_custom_props_url(PlausibleWeb.Endpoint, :enable) url = Routes.plugins_api_custom_props_url(PlausibleWeb.Endpoint, :enable)

View File

@ -249,8 +249,8 @@ defmodule PlausibleWeb.Plugins.API.Controllers.FunnelsTest do
end end
test "fails for insufficient plan", %{conn: conn, token: token, site: site} do test "fails for insufficient plan", %{conn: conn, token: token, site: site} do
site = Plausible.Repo.preload(site, :owner) [owner | _] = Plausible.Repo.preload(site, :owners).owners
subscribe_to_growth_plan(site.owner) subscribe_to_growth_plan(owner)
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create) url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create)

View File

@ -53,8 +53,8 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
token: token, token: token,
conn: conn conn: conn
} do } do
site = Plausible.Repo.preload(site, :owner) [owner | _] = Plausible.Repo.preload(site, :owners).owners
subscribe_to_growth_plan(site.owner) subscribe_to_growth_plan(owner)
url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create)
@ -79,8 +79,8 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do
token: token, token: token,
conn: conn conn: conn
} do } do
site = Plausible.Repo.preload(site, :owner) [owner | _] = Plausible.Repo.preload(site, :owners).owners
subscribe_to_growth_plan(site.owner) subscribe_to_growth_plan(owner)
url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create)

View File

@ -62,6 +62,30 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPITest do
assert json_response(conn, 400)["error"] =~ "Missing site ID." assert json_response(conn, 400)["error"] =~ "Missing site ID."
end end
@tag :ee_only
test "halts with error when site's team lacks feature access", %{conn: conn} do
user = new_user()
_site = new_site(owner: user)
api_key = insert(:api_key, user: user)
another_owner =
new_user() |> subscribe_to_enterprise_plan(paddle_plan_id: "123321", features: [])
another_site = new_site(owner: another_owner)
conn =
conn
|> put_req_header("authorization", "Bearer #{api_key.key}")
|> get("/", %{"site_id" => another_site.domain})
|> assign(:api_scope, "stats:read:*")
|> AuthorizePublicAPI.call(nil)
assert conn.halted
assert json_response(conn, 402)["error"] =~
"The account that owns this API key does not have access"
end
@tag :ee_only @tag :ee_only
test "halts with error when upgrade is required", %{conn: conn} do test "halts with error when upgrade is required", %{conn: conn} do
user = new_user() |> subscribe_to_enterprise_plan(paddle_plan_id: "123321", features: []) user = new_user() |> subscribe_to_enterprise_plan(paddle_plan_id: "123321", features: [])

View File

@ -20,9 +20,11 @@ defmodule Plausible.Teams.Test do
def new_site(args \\ []) do def new_site(args \\ []) do
args = args =
if user = args[:owner] do if user = args[:owner] do
{owner, args} = Keyword.pop(args, :owner)
{:ok, team} = Teams.get_or_create(user) {:ok, team} = Teams.get_or_create(user)
args args
|> Keyword.put(:owners, [owner])
|> Keyword.put(:team, team) |> Keyword.put(:team, team)
else else
user = new_user() user = new_user()
@ -299,11 +301,13 @@ defmodule Plausible.Teams.Test do
end end
def assert_team_attached(site, team_id \\ nil) do def assert_team_attached(site, team_id \\ nil) do
assert site = %{team: team} = site |> Repo.reload!() |> Repo.preload([:team, :owner]) assert site = %{team: team} = site |> Repo.reload!() |> Repo.preload([:team, :owners])
assert membership = assert_team_membership(site.owner, team) for owner <- site.owners do
assert membership = assert_team_membership(owner, team)
assert membership.team_id == team.id assert membership.team_id == team.id
end
if team_id do if team_id do
assert team.id == team_id assert team.id == team_id

View File

@ -62,6 +62,7 @@ defmodule Plausible.Workers.SendTrialNotificationsTest do
user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 1)) user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 1))
site = new_site(owner: user) site = new_site(owner: user)
usage = %{total: 3, custom_events: 0} usage = %{total: 3, custom_events: 0}
suggested_plan = Plausible.Billing.Plans.suggest(site.team, usage.total)
populate_stats(site, [ populate_stats(site, [
build(:pageview), build(:pageview),
@ -71,13 +72,16 @@ defmodule Plausible.Workers.SendTrialNotificationsTest do
perform_job(SendTrialNotifications, %{}) perform_job(SendTrialNotifications, %{})
assert_delivered_email(PlausibleWeb.Email.trial_upgrade_email(user, "tomorrow", usage)) assert_delivered_email(
PlausibleWeb.Email.trial_upgrade_email(user, "tomorrow", usage, suggested_plan)
)
end end
test "sends an upgrade email the day the trial ends" do test "sends an upgrade email the day the trial ends" do
user = new_user(trial_expiry_date: Date.utc_today()) user = new_user(trial_expiry_date: Date.utc_today())
site = new_site(owner: user) site = new_site(owner: user)
usage = %{total: 3, custom_events: 0} usage = %{total: 3, custom_events: 0}
suggested_plan = Plausible.Billing.Plans.suggest(site.team, usage.total)
populate_stats(site, [ populate_stats(site, [
build(:pageview), build(:pageview),
@ -87,14 +91,18 @@ defmodule Plausible.Workers.SendTrialNotificationsTest do
perform_job(SendTrialNotifications, %{}) perform_job(SendTrialNotifications, %{})
assert_delivered_email(PlausibleWeb.Email.trial_upgrade_email(user, "today", usage)) assert_delivered_email(
PlausibleWeb.Email.trial_upgrade_email(user, "today", usage, suggested_plan)
)
end end
test "does not include custom event note if user has not used custom events" do test "does not include custom event note if user has not used custom events" do
user = new_user(trial_expiry_date: Date.utc_today()) user = new_user(trial_expiry_date: Date.utc_today())
site = new_site(owner: user)
usage = %{total: 9_000, custom_events: 0} usage = %{total: 9_000, custom_events: 0}
suggested_plan = Plausible.Billing.Plans.suggest(site.team, usage.total)
email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage) email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage, suggested_plan)
assert email.html_body =~ assert email.html_body =~
"In the last month, your account has used 9,000 billable pageviews." "In the last month, your account has used 9,000 billable pageviews."
@ -102,9 +110,11 @@ defmodule Plausible.Workers.SendTrialNotificationsTest do
test "includes custom event note if user has used custom events" do test "includes custom event note if user has used custom events" do
user = new_user(trial_expiry_date: Date.utc_today()) user = new_user(trial_expiry_date: Date.utc_today())
site = new_site(owner: user)
usage = %{total: 9_100, custom_events: 100} usage = %{total: 9_100, custom_events: 100}
suggested_plan = Plausible.Billing.Plans.suggest(site.team, usage.total)
email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage) email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage, suggested_plan)
assert email.html_body =~ assert email.html_body =~
"In the last month, your account has used 9,100 billable pageviews and custom events in total." "In the last month, your account has used 9,100 billable pageviews and custom events in total."
@ -146,82 +156,102 @@ defmodule Plausible.Workers.SendTrialNotificationsTest do
describe "Suggested plans" do describe "Suggested plans" do
test "suggests 10k/mo plan" do test "suggests 10k/mo plan" do
user = new_user() user = new_user()
site = new_site(owner: user)
usage = %{total: 9_000, custom_events: 0} usage = %{total: 9_000, custom_events: 0}
suggested_plan = Plausible.Billing.Plans.suggest(site.team, usage.total)
email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage) email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage, suggested_plan)
assert email.html_body =~ "we recommend you select a 10k/mo plan." assert email.html_body =~ "we recommend you select a 10k/mo plan."
end end
test "suggests 100k/mo plan" do test "suggests 100k/mo plan" do
user = new_user() user = new_user()
site = new_site(owner: user)
usage = %{total: 90_000, custom_events: 0} usage = %{total: 90_000, custom_events: 0}
suggested_plan = Plausible.Billing.Plans.suggest(site.team, usage.total)
email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage) email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage, suggested_plan)
assert email.html_body =~ "we recommend you select a 100k/mo plan." assert email.html_body =~ "we recommend you select a 100k/mo plan."
end end
test "suggests 200k/mo plan" do test "suggests 200k/mo plan" do
user = new_user() user = new_user()
site = new_site(owner: user)
usage = %{total: 180_000, custom_events: 0} usage = %{total: 180_000, custom_events: 0}
suggested_plan = Plausible.Billing.Plans.suggest(site.team, usage.total)
email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage) email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage, suggested_plan)
assert email.html_body =~ "we recommend you select a 200k/mo plan." assert email.html_body =~ "we recommend you select a 200k/mo plan."
end end
test "suggests 500k/mo plan" do test "suggests 500k/mo plan" do
user = new_user() user = new_user()
site = new_site(owner: user)
usage = %{total: 450_000, custom_events: 0} usage = %{total: 450_000, custom_events: 0}
suggested_plan = Plausible.Billing.Plans.suggest(site.team, usage.total)
email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage) email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage, suggested_plan)
assert email.html_body =~ "we recommend you select a 500k/mo plan." assert email.html_body =~ "we recommend you select a 500k/mo plan."
end end
test "suggests 1m/mo plan" do test "suggests 1m/mo plan" do
user = new_user() user = new_user()
site = new_site(owner: user)
usage = %{total: 900_000, custom_events: 0} usage = %{total: 900_000, custom_events: 0}
suggested_plan = Plausible.Billing.Plans.suggest(site.team, usage.total)
email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage) email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage, suggested_plan)
assert email.html_body =~ "we recommend you select a 1M/mo plan." assert email.html_body =~ "we recommend you select a 1M/mo plan."
end end
test "suggests 2m/mo plan" do test "suggests 2m/mo plan" do
user = new_user() user = new_user()
site = new_site(owner: user)
usage = %{total: 1_800_000, custom_events: 0} usage = %{total: 1_800_000, custom_events: 0}
suggested_plan = Plausible.Billing.Plans.suggest(site.team, usage.total)
email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage) email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage, suggested_plan)
assert email.html_body =~ "we recommend you select a 2M/mo plan." assert email.html_body =~ "we recommend you select a 2M/mo plan."
end end
test "suggests 5m/mo plan" do test "suggests 5m/mo plan" do
user = new_user() user = new_user()
site = new_site(owner: user)
usage = %{total: 4_500_000, custom_events: 0} usage = %{total: 4_500_000, custom_events: 0}
suggested_plan = Plausible.Billing.Plans.suggest(site.team, usage.total)
email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage) email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage, suggested_plan)
assert email.html_body =~ "we recommend you select a 5M/mo plan." assert email.html_body =~ "we recommend you select a 5M/mo plan."
end end
test "suggests 10m/mo plan" do test "suggests 10m/mo plan" do
user = new_user() user = new_user()
site = new_site(owner: user)
usage = %{total: 9_000_000, custom_events: 0} usage = %{total: 9_000_000, custom_events: 0}
suggested_plan = Plausible.Billing.Plans.suggest(site.team, usage.total)
email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage) email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage, suggested_plan)
assert email.html_body =~ "we recommend you select a 10M/mo plan." assert email.html_body =~ "we recommend you select a 10M/mo plan."
end end
test "does not suggest a plan above that" do test "does not suggest a plan above that" do
user = new_user() user = new_user()
site = new_site(owner: user)
usage = %{total: 20_000_000, custom_events: 0} usage = %{total: 20_000_000, custom_events: 0}
suggested_plan = Plausible.Billing.Plans.suggest(site.team, usage.total)
email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage) email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage, suggested_plan)
assert email.html_body =~ "please reply back to this email to get a quote for your volume" assert email.html_body =~ "please reply back to this email to get a quote for your volume"
end end
test "does not suggest a plan when user is switching to an enterprise plan" do test "does not suggest a plan when user is switching to an enterprise plan" do
user = new_user() user = new_user()
site = new_site(owner: user)
usage = %{total: 10_000, custom_events: 0} usage = %{total: 10_000, custom_events: 0}
subscribe_to_enterprise_plan(user, paddle_plan_id: "enterprise-plan-id") subscribe_to_enterprise_plan(user, paddle_plan_id: "enterprise-plan-id")
suggested_plan = Plausible.Billing.Plans.suggest(site.team, usage.total)
email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage) email = PlausibleWeb.Email.trial_upgrade_email(user, "today", usage, suggested_plan)
assert email.html_body =~ "please reply back to this email to get a quote for your volume" assert email.html_body =~ "please reply back to this email to get a quote for your volume"
end end
end end