Skip to content

Commit bf010a1

Browse files
zoldaraerosol
andauthored
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 <[email protected]>
1 parent 7ae88c2 commit bf010a1

File tree

91 files changed

+1979
-653
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

91 files changed

+1979
-653
lines changed

config/runtime.exs

+5
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,11 @@ if config_env() in [:dev, :staging, :prod, :test] do
802802
api_key: [schema: Plausible.Auth.ApiKey, admin: Plausible.Auth.ApiKeyAdmin]
803803
]
804804
],
805+
teams: [
806+
resources: [
807+
team: [schema: Plausible.Teams.Team, admin: Plausible.Teams.TeamAdmin]
808+
]
809+
],
805810
sites: [
806811
resources: [
807812
site: [schema: Plausible.Site, admin: Plausible.SiteAdmin]

extra/lib/plausible/help_scout.ex

+27-6
Original file line numberDiff line numberDiff line change
@@ -90,24 +90,45 @@ defmodule Plausible.HelpScout do
9090
plan = Billing.Plans.get_subscription_plan(team.subscription)
9191
{team, team.subscription, plan}
9292

93+
{:error, :multiple_teams} ->
94+
# NOTE: We might consider exposing the other teams later on
95+
[team | _] = Plausible.Teams.Users.owned_teams(user)
96+
team = Plausible.Teams.with_subscription(team)
97+
plan = Billing.Plans.get_subscription_plan(team.subscription)
98+
{team, team.subscription, plan}
99+
93100
{:error, :no_team} ->
94101
{nil, nil, nil}
95102
end
96103

104+
status_link =
105+
if team do
106+
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :teams, :team, team.id)
107+
else
108+
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :auth, :user, user.id)
109+
end
110+
111+
sites_link =
112+
if team do
113+
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site,
114+
custom_search: team.identifier
115+
)
116+
else
117+
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site,
118+
custom_search: user.email
119+
)
120+
end
121+
97122
{:ok,
98123
%{
99124
email: user.email,
100125
notes: user.notes,
101126
status_label: status_label(team, subscription),
102-
status_link:
103-
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :auth, :user, user.id),
127+
status_link: status_link,
104128
plan_label: plan_label(subscription, plan),
105129
plan_link: plan_link(subscription),
106130
sites_count: Plausible.Teams.owned_sites_count(team),
107-
sites_link:
108-
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site,
109-
custom_search: user.email
110-
)
131+
sites_link: sites_link
111132
}}
112133
end
113134
end

extra/lib/plausible_web/controllers/api/external_sites_controller.ex

+25-8
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,18 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
88
alias Plausible.Sites
99
alias Plausible.Goal
1010
alias Plausible.Goals
11+
alias Plausible.Teams
1112
alias PlausibleWeb.Api.Helpers, as: H
1213

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

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

1820
page =
1921
user
20-
|> Sites.for_user_query()
22+
|> Sites.for_user_query(team)
2123
|> paginate(params, @pagination_opts)
2224

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

3234
with {:ok, site_id} <- expect_param_key(params, "site_id"),
33-
{:ok, site} <- get_site(user, site_id, [:owner, :admin, :viewer]) do
35+
{:ok, site} <- get_site(user, site_id, [:owner, :admin, :editor, :viewer]) do
3436
page =
3537
site
3638
|> Plausible.Goals.for_site_query()
@@ -60,8 +62,9 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
6062

6163
def create_site(conn, params) do
6264
user = conn.assigns.current_user
65+
team = Plausible.Teams.get(params["team_id"])
6366

64-
case Sites.create(user, params) do
67+
case Sites.create(user, params, team) do
6568
{:ok, %{site: site}} ->
6669
json(conn, site)
6770

@@ -73,6 +76,20 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
7376
"Your account has reached the limit of #{limit} sites. To unlock more sites, please upgrade your subscription."
7477
})
7578

79+
{:error, _, :permission_denied, _} ->
80+
conn
81+
|> put_status(403)
82+
|> json(%{
83+
error: "You can't add sites to the selected team."
84+
})
85+
86+
{:error, _, :multiple_teams, _} ->
87+
conn
88+
|> put_status(400)
89+
|> json(%{
90+
error: "You must select a team with 'team_id' parameter."
91+
})
92+
7693
{:error, _, changeset, _} ->
7794
conn
7895
|> put_status(400)
@@ -81,7 +98,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
8198
end
8299

83100
def get_site(conn, %{"site_id" => site_id}) do
84-
case get_site(conn.assigns.current_user, site_id, [:owner, :admin, :viewer]) do
101+
case get_site(conn.assigns.current_user, site_id, [:owner, :admin, :editor, :viewer]) do
85102
{:ok, site} ->
86103
json(conn, %{
87104
domain: site.domain,
@@ -107,7 +124,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
107124

108125
def update_site(conn, %{"site_id" => site_id} = params) do
109126
# for now this only allows to change the domain
110-
with {:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin]),
127+
with {:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin, :editor]),
111128
{:ok, site} <- Plausible.Site.Domain.change(site, params["domain"]) do
112129
json(conn, site)
113130
else
@@ -124,7 +141,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
124141
def find_or_create_shared_link(conn, params) do
125142
with {:ok, site_id} <- expect_param_key(params, "site_id"),
126143
{:ok, link_name} <- expect_param_key(params, "name"),
127-
{:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin]) do
144+
{:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin, :editor]) do
128145
shared_link = Repo.get_by(Plausible.Site.SharedLink, site_id: site.id, name: link_name)
129146

130147
shared_link =
@@ -158,7 +175,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
158175
def find_or_create_goal(conn, params) do
159176
with {:ok, site_id} <- expect_param_key(params, "site_id"),
160177
{:ok, _} <- expect_param_key(params, "goal_type"),
161-
{:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin]),
178+
{:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin, :editor]),
162179
{:ok, goal} <- Goals.find_or_create(site, params) do
163180
json(conn, goal)
164181
else
@@ -176,7 +193,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
176193
def delete_goal(conn, params) do
177194
with {:ok, site_id} <- expect_param_key(params, "site_id"),
178195
{:ok, goal_id} <- expect_param_key(params, "goal_id"),
179-
{:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin]),
196+
{:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin, :editor]),
180197
:ok <- Goals.delete(goal_id, site) do
181198
json(conn, %{"deleted" => true})
182199
else

extra/lib/plausible_web/live/funnel_settings.ex

+2-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ defmodule PlausibleWeb.Live.FunnelSettings do
1919
Plausible.Sites.get_for_user!(current_user, domain, [
2020
:owner,
2121
:admin,
22+
:editor,
2223
:super_admin
2324
])
2425
end)
@@ -110,7 +111,7 @@ defmodule PlausibleWeb.Live.FunnelSettings do
110111
Plausible.Sites.get_for_user!(
111112
socket.assigns.current_user,
112113
socket.assigns.domain,
113-
[:owner, :admin]
114+
[:owner, :admin, :editor]
114115
)
115116

116117
id = String.to_integer(id)

extra/lib/plausible_web/live/funnel_settings/form.ex

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
1616
Plausible.Sites.get_for_user!(socket.assigns.current_user, domain, [
1717
:owner,
1818
:admin,
19+
:editor,
1920
:super_admin
2021
])
2122

lib/plausible/auth/auth.ex

+49-14
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ defmodule Plausible.Auth do
77
use Plausible.Repo
88
alias Plausible.Auth
99
alias Plausible.RateLimit
10+
alias Plausible.Teams
1011

1112
@rate_limits %{
1213
login_ip: %{
@@ -71,9 +72,9 @@ defmodule Plausible.Auth do
7172

7273
def delete_user(user) do
7374
Repo.transaction(fn ->
74-
case Plausible.Teams.get_by_owner(user) do
75-
{:ok, team} ->
76-
for site <- Plausible.Teams.owned_sites(team) do
75+
case Teams.get_by_owner(user) do
76+
{:ok, %{setup_complete: false} = team} ->
77+
for site <- Teams.owned_sites(team) do
7778
Plausible.Site.Removal.run(site)
7879
end
7980

@@ -84,15 +85,39 @@ defmodule Plausible.Auth do
8485
)
8586

8687
Repo.delete!(team)
88+
Repo.delete!(user)
89+
90+
{:ok, team} ->
91+
check_can_leave_team!(team)
92+
Repo.delete!(user)
8793

88-
_ ->
89-
:skip
94+
{:error, :multiple_teams} ->
95+
check_can_leave_teams!(user)
96+
Repo.delete!(user)
97+
98+
{:error, :no_team} ->
99+
Repo.delete!(user)
90100
end
91101

92-
Repo.delete!(user)
102+
:deleted
93103
end)
94104
end
95105

106+
defp check_can_leave_teams!(user) do
107+
user
108+
|> Teams.Users.owned_teams()
109+
|> Enum.reject(&(&1.setup_complete == false))
110+
|> Enum.map(fn team ->
111+
check_can_leave_team!(team)
112+
end)
113+
end
114+
115+
defp check_can_leave_team!(team) do
116+
if Teams.Memberships.owners_count(team) <= 1 do
117+
Repo.rollback(:is_only_team_owner)
118+
end
119+
end
120+
96121
on_ee do
97122
def is_super_admin?(nil), do: false
98123
def is_super_admin?(%Plausible.Auth.User{id: id}), do: is_super_admin?(id)
@@ -107,17 +132,12 @@ defmodule Plausible.Auth do
107132
@spec create_api_key(Auth.User.t(), String.t(), String.t()) ::
108133
{:ok, Auth.ApiKey.t()} | {:error, Ecto.Changeset.t() | :upgrade_required}
109134
def create_api_key(user, name, key) do
110-
team =
111-
case Plausible.Teams.get_by_owner(user) do
112-
{:ok, team} -> team
113-
_ -> nil
114-
end
115-
116135
params = %{name: name, user_id: user.id, key: key}
117136
changeset = Auth.ApiKey.changeset(%Auth.ApiKey{}, params)
118137

119-
with :ok <- Plausible.Billing.Feature.StatsAPI.check_availability(team),
120-
do: Repo.insert(changeset)
138+
with :ok <- check_stats_api_available(user) do
139+
Repo.insert(changeset)
140+
end
121141
end
122142

123143
@spec delete_api_key(Auth.User.t(), integer()) :: :ok | {:error, :not_found}
@@ -148,6 +168,21 @@ defmodule Plausible.Auth do
148168
end
149169
end
150170

171+
defp check_stats_api_available(user) do
172+
case Plausible.Teams.get_by_owner(user) do
173+
{:ok, team} ->
174+
Plausible.Billing.Feature.StatsAPI.check_availability(team)
175+
176+
{:error, :no_team} ->
177+
Plausible.Billing.Feature.StatsAPI.check_availability(nil)
178+
179+
{:error, :multiple_teams} ->
180+
# NOTE: Loophole to allow creating API keys when user is a member
181+
# on multiple teams.
182+
:ok
183+
end
184+
end
185+
151186
defp rate_limit_key(%Auth.User{id: id}), do: id
152187
defp rate_limit_key(%Plug.Conn{} = conn), do: PlausibleWeb.RemoteIP.get(conn)
153188
end

lib/plausible/auth/user.ex

+3-17
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,6 @@ defmodule Plausible.Auth.User do
3434
# Field for purely informational purposes in CRM context
3535
field :notes, :string
3636

37-
# Fields used only by CRM for mapping to the ones in the owned team
38-
field :trial_expiry_date, :date, virtual: true
39-
field :allow_next_upgrade_override, :boolean, virtual: true
40-
field :accept_traffic_until, :date, virtual: true
41-
4237
# Fields for TOTP authentication. See `Plausible.Auth.TOTP`.
4338
field :totp_enabled, :boolean, default: false
4439
field :totp_secret, Plausible.Auth.TOTP.EncryptedBinary
@@ -49,8 +44,8 @@ defmodule Plausible.Auth.User do
4944
has_many :team_memberships, Plausible.Teams.Membership
5045
has_many :api_keys, Plausible.Auth.ApiKey
5146
has_one :google_auth, Plausible.Site.GoogleAuth
52-
has_one :owner_membership, Plausible.Teams.Membership, where: [role: :owner]
53-
has_one :my_team, through: [:owner_membership, :team]
47+
has_many :owner_memberships, Plausible.Teams.Membership, where: [role: :owner]
48+
has_many :owned_teams, through: [:owner_memberships, :team]
5449

5550
timestamps()
5651
end
@@ -113,16 +108,7 @@ defmodule Plausible.Auth.User do
113108

114109
def changeset(user, attrs \\ %{}) do
115110
user
116-
|> cast(attrs, [
117-
:email,
118-
:name,
119-
:email_verified,
120-
:theme,
121-
:notes,
122-
:trial_expiry_date,
123-
:allow_next_upgrade_override,
124-
:accept_traffic_until
125-
])
111+
|> cast(attrs, [:email, :name, :email_verified, :theme, :notes])
126112
|> validate_required([:email, :name, :email_verified])
127113
|> unique_constraint(:email)
128114
end

0 commit comments

Comments
 (0)