Skip to content

Commit

Permalink
Implement support for multiple team owners and multiple teams per user (
Browse files Browse the repository at this point in the history
#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 <[email protected]>
  • Loading branch information
zoldar and aerosol authored Feb 19, 2025
1 parent 7ae88c2 commit bf010a1
Show file tree
Hide file tree
Showing 91 changed files with 1,979 additions and 653 deletions.
5 changes: 5 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,11 @@ if config_env() in [:dev, :staging, :prod, :test] do
api_key: [schema: Plausible.Auth.ApiKey, admin: Plausible.Auth.ApiKeyAdmin]
]
],
teams: [
resources: [
team: [schema: Plausible.Teams.Team, admin: Plausible.Teams.TeamAdmin]
]
],
sites: [
resources: [
site: [schema: Plausible.Site, admin: Plausible.SiteAdmin]
Expand Down
33 changes: 27 additions & 6 deletions extra/lib/plausible/help_scout.ex
Original file line number Diff line number Diff line change
Expand Up @@ -90,24 +90,45 @@ defmodule Plausible.HelpScout do
plan = Billing.Plans.get_subscription_plan(team.subscription)
{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} ->
{nil, nil, nil}
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,
%{
email: user.email,
notes: user.notes,
status_label: status_label(team, subscription),
status_link:
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :auth, :user, user.id),
status_link: status_link,
plan_label: plan_label(subscription, plan),
plan_link: plan_link(subscription),
sites_count: Plausible.Teams.owned_sites_count(team),
sites_link:
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site,
custom_search: user.email
)
sites_link: sites_link
}}
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
alias Plausible.Sites
alias Plausible.Goal
alias Plausible.Goals
alias Plausible.Teams
alias PlausibleWeb.Api.Helpers, as: H

@pagination_opts [cursor_fields: [{:id, :desc}], limit: 100, maximum_limit: 1000]

def index(conn, params) do
team = Teams.get(params["team_id"])
user = conn.assigns.current_user

page =
user
|> Sites.for_user_query()
|> Sites.for_user_query(team)
|> paginate(params, @pagination_opts)

json(conn, %{
Expand All @@ -30,7 +32,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
user = conn.assigns.current_user

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 =
site
|> Plausible.Goals.for_site_query()
Expand Down Expand Up @@ -60,8 +62,9 @@ defmodule PlausibleWeb.Api.ExternalSitesController do

def create_site(conn, params) do
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}} ->
json(conn, site)

Expand All @@ -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."
})

{: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, _} ->
conn
|> put_status(400)
Expand All @@ -81,7 +98,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end

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} ->
json(conn, %{
domain: site.domain,
Expand All @@ -107,7 +124,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do

def update_site(conn, %{"site_id" => site_id} = params) do
# 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
json(conn, site)
else
Expand All @@ -124,7 +141,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
def find_or_create_shared_link(conn, params) do
with {:ok, site_id} <- expect_param_key(params, "site_id"),
{: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 =
Expand Down Expand Up @@ -158,7 +175,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
def find_or_create_goal(conn, params) do
with {:ok, site_id} <- expect_param_key(params, "site_id"),
{: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
json(conn, goal)
else
Expand All @@ -176,7 +193,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
def delete_goal(conn, params) do
with {:ok, site_id} <- expect_param_key(params, "site_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
json(conn, %{"deleted" => true})
else
Expand Down
3 changes: 2 additions & 1 deletion extra/lib/plausible_web/live/funnel_settings.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ defmodule PlausibleWeb.Live.FunnelSettings do
Plausible.Sites.get_for_user!(current_user, domain, [
:owner,
:admin,
:editor,
:super_admin
])
end)
Expand Down Expand Up @@ -110,7 +111,7 @@ defmodule PlausibleWeb.Live.FunnelSettings do
Plausible.Sites.get_for_user!(
socket.assigns.current_user,
socket.assigns.domain,
[:owner, :admin]
[:owner, :admin, :editor]
)

id = String.to_integer(id)
Expand Down
1 change: 1 addition & 0 deletions extra/lib/plausible_web/live/funnel_settings/form.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
Plausible.Sites.get_for_user!(socket.assigns.current_user, domain, [
:owner,
:admin,
:editor,
:super_admin
])

Expand Down
63 changes: 49 additions & 14 deletions lib/plausible/auth/auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ defmodule Plausible.Auth do
use Plausible.Repo
alias Plausible.Auth
alias Plausible.RateLimit
alias Plausible.Teams

@rate_limits %{
login_ip: %{
Expand Down Expand Up @@ -71,9 +72,9 @@ defmodule Plausible.Auth do

def delete_user(user) do
Repo.transaction(fn ->
case Plausible.Teams.get_by_owner(user) do
{:ok, team} ->
for site <- Plausible.Teams.owned_sites(team) do
case Teams.get_by_owner(user) do
{:ok, %{setup_complete: false} = team} ->
for site <- Teams.owned_sites(team) do
Plausible.Site.Removal.run(site)
end

Expand All @@ -84,15 +85,39 @@ defmodule Plausible.Auth do
)

Repo.delete!(team)
Repo.delete!(user)

{:ok, team} ->
check_can_leave_team!(team)
Repo.delete!(user)

_ ->
:skip
{:error, :multiple_teams} ->
check_can_leave_teams!(user)
Repo.delete!(user)

{:error, :no_team} ->
Repo.delete!(user)
end

Repo.delete!(user)
:deleted
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
def is_super_admin?(nil), do: false
def is_super_admin?(%Plausible.Auth.User{id: id}), do: is_super_admin?(id)
Expand All @@ -107,17 +132,12 @@ defmodule Plausible.Auth do
@spec create_api_key(Auth.User.t(), String.t(), String.t()) ::
{:ok, Auth.ApiKey.t()} | {:error, Ecto.Changeset.t() | :upgrade_required}
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}
changeset = Auth.ApiKey.changeset(%Auth.ApiKey{}, params)

with :ok <- Plausible.Billing.Feature.StatsAPI.check_availability(team),
do: Repo.insert(changeset)
with :ok <- check_stats_api_available(user) do
Repo.insert(changeset)
end
end

@spec delete_api_key(Auth.User.t(), integer()) :: :ok | {:error, :not_found}
Expand Down Expand Up @@ -148,6 +168,21 @@ defmodule Plausible.Auth do
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(%Plug.Conn{} = conn), do: PlausibleWeb.RemoteIP.get(conn)
end
20 changes: 3 additions & 17 deletions lib/plausible/auth/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,6 @@ defmodule Plausible.Auth.User do
# Field for purely informational purposes in CRM context
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`.
field :totp_enabled, :boolean, default: false
field :totp_secret, Plausible.Auth.TOTP.EncryptedBinary
Expand All @@ -49,8 +44,8 @@ defmodule Plausible.Auth.User do
has_many :team_memberships, Plausible.Teams.Membership
has_many :api_keys, Plausible.Auth.ApiKey
has_one :google_auth, Plausible.Site.GoogleAuth
has_one :owner_membership, Plausible.Teams.Membership, where: [role: :owner]
has_one :my_team, through: [:owner_membership, :team]
has_many :owner_memberships, Plausible.Teams.Membership, where: [role: :owner]
has_many :owned_teams, through: [:owner_memberships, :team]

timestamps()
end
Expand Down Expand Up @@ -113,16 +108,7 @@ defmodule Plausible.Auth.User do

def changeset(user, attrs \\ %{}) do
user
|> cast(attrs, [
:email,
:name,
:email_verified,
:theme,
:notes,
:trial_expiry_date,
:allow_next_upgrade_override,
:accept_traffic_until
])
|> cast(attrs, [:email, :name, :email_verified, :theme, :notes])
|> validate_required([:email, :name, :email_verified])
|> unique_constraint(:email)
end
Expand Down
Loading

0 comments on commit bf010a1

Please sign in to comment.