From bf010a1537ed26b644efea293daddf5dcdd00b2c Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 19 Feb 2025 10:33:25 +0100 Subject: [PATCH] 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 --- config/runtime.exs | 5 + extra/lib/plausible/help_scout.ex | 33 ++- .../api/external_sites_controller.ex | 33 ++- .../lib/plausible_web/live/funnel_settings.ex | 3 +- .../live/funnel_settings/form.ex | 1 + lib/plausible/auth/auth.ex | 63 +++-- lib/plausible/auth/user.ex | 20 +- lib/plausible/auth/user_admin.ex | 169 ++----------- lib/plausible/billing/billing.ex | 28 ++- lib/plausible/billing/enterprise_plan.ex | 3 - .../billing/enterprise_plan_admin.ex | 37 +-- lib/plausible/billing/site_locker.ex | 10 +- lib/plausible/crm_extensions.ex | 67 ++++-- lib/plausible/site.ex | 4 +- lib/plausible/site/admin.ex | 105 +++++++-- lib/plausible/site/memberships.ex | 6 +- .../site/memberships/accept_invitation.ex | 87 +++---- .../site/memberships/create_invitation.ex | 2 +- lib/plausible/site/removal.ex | 2 +- lib/plausible/sites.ex | 76 +++--- lib/plausible/teams.ex | 60 ++--- lib/plausible/teams/billing.ex | 2 +- lib/plausible/teams/invitations.ex | 34 +-- .../teams/invitations/invite_to_team.ex | 2 +- lib/plausible/teams/memberships.ex | 20 ++ lib/plausible/teams/sites.ex | 18 +- lib/plausible/teams/team.ex | 7 +- lib/plausible/teams/team_admin.ex | 222 ++++++++++++++++++ lib/plausible/teams/users.ex | 25 ++ .../controllers/admin_controller.ex | 157 ++++++++++--- .../api/external_query_api_controller.ex | 2 +- .../api/external_stats_controller.ex | 6 +- .../controllers/api/internal_controller.ex | 7 +- .../controllers/auth_controller.ex | 79 ++++++- .../controllers/invitation_controller.ex | 9 +- .../controllers/site/membership_controller.ex | 6 +- .../controllers/site_controller.ex | 17 +- .../controllers/stats_controller.ex | 12 +- lib/plausible_web/email.ex | 22 +- lib/plausible_web/live/auth_context.ex | 49 +++- lib/plausible_web/live/goal_settings.ex | 2 +- lib/plausible_web/live/goal_settings/form.ex | 4 +- .../live/imports_exports_settings.ex | 1 + lib/plausible_web/live/installation.ex | 1 + .../live/plugins/api/settings.ex | 1 + .../live/plugins/api/token_form.ex | 1 + lib/plausible_web/live/props_settings.ex | 1 + lib/plausible_web/live/props_settings/form.ex | 1 + lib/plausible_web/live/shields/countries.ex | 1 + lib/plausible_web/live/shields/hostnames.ex | 1 + .../live/shields/ip_addresses.ex | 1 + lib/plausible_web/live/shields/pages.ex | 1 + lib/plausible_web/live/sites.ex | 17 +- lib/plausible_web/live/verification.ex | 1 + lib/plausible_web/plugs/auth_plug.ex | 37 ++- .../plugs/authorize_public_api.ex | 6 +- .../plugs/authorize_site_access.ex | 2 +- lib/plausible_web/router.ex | 12 +- .../templates/auth/select_team.html.heex | 32 +++ .../templates/layout/_header.html.heex | 38 ++- .../membership/invite_member_form.html.heex | 2 +- .../templates/site/settings_funnels.html.heex | 2 +- .../templates/site/settings_props.html.heex | 2 +- .../templates/stats/site_locked.html.heex | 9 +- lib/plausible_web/user_auth.ex | 10 +- .../accept_traffic_until_notification.ex | 6 +- lib/workers/check_usage.ex | 26 +- lib/workers/notify_annual_renewal.ex | 35 ++- lib/workers/send_site_setup_emails.ex | 22 +- lib/workers/send_trial_notifications.ex | 46 ++-- test/plausible/auth/auth_test.exs | 9 + .../billing/enterprise_plan_admin_test.exs | 8 +- test/plausible/help_scout_test.exs | 19 +- test/plausible/site/admin_test.exs | 85 +++++++ .../memberships/accept_invitation_test.exs | 114 ++++++++- test/plausible/site/sites_test.exs | 87 +++++++ test/plausible/teams_test.exs | 211 +++++++++++++++++ .../controllers/admin_controller_test.exs | 35 ++- .../api/external_sites_controller_test.exs | 17 ++ .../controllers/auth_controller_test.exs | 56 +++++ .../invitation_controller_test.exs | 18 ++ .../controllers/site_controller_test.exs | 15 ++ .../controllers/stats_controller_test.exs | 8 +- test/plausible_web/live/choose_plan_test.exs | 3 +- .../api/controllers/capabilities_test.exs | 4 +- .../api/controllers/custom_props_test.exs | 8 +- .../plugins/api/controllers/funnels_test.exs | 4 +- .../plugins/api/controllers/goals_test.exs | 8 +- .../plugs/authorize_public_api_test.exs | 24 ++ test/support/teams/test.ex | 10 +- .../workers/send_trial_notifications_test.exs | 58 +++-- 91 files changed, 1979 insertions(+), 653 deletions(-) create mode 100644 lib/plausible/teams/team_admin.ex create mode 100644 lib/plausible_web/templates/auth/select_team.html.heex diff --git a/config/runtime.exs b/config/runtime.exs index e5faab8a0134..47b702e5ad1a 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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] diff --git a/extra/lib/plausible/help_scout.ex b/extra/lib/plausible/help_scout.ex index 11e4d9adb2c0..02952cc14d68 100644 --- a/extra/lib/plausible/help_scout.ex +++ b/extra/lib/plausible/help_scout.ex @@ -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 diff --git a/extra/lib/plausible_web/controllers/api/external_sites_controller.ex b/extra/lib/plausible_web/controllers/api/external_sites_controller.ex index b6d8aa1c81da..0a311f49e372 100644 --- a/extra/lib/plausible_web/controllers/api/external_sites_controller.ex +++ b/extra/lib/plausible_web/controllers/api/external_sites_controller.ex @@ -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, %{ @@ -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() @@ -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) @@ -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) @@ -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, @@ -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 @@ -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 = @@ -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 @@ -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 diff --git a/extra/lib/plausible_web/live/funnel_settings.ex b/extra/lib/plausible_web/live/funnel_settings.ex index f45b905fd7ea..ae0f852e4a62 100644 --- a/extra/lib/plausible_web/live/funnel_settings.ex +++ b/extra/lib/plausible_web/live/funnel_settings.ex @@ -19,6 +19,7 @@ defmodule PlausibleWeb.Live.FunnelSettings do Plausible.Sites.get_for_user!(current_user, domain, [ :owner, :admin, + :editor, :super_admin ]) end) @@ -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) diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index 05bcdc90ace8..a9b0dabdec8f 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -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 ]) diff --git a/lib/plausible/auth/auth.ex b/lib/plausible/auth/auth.ex index 2ad4ebb295a7..45423cd369b4 100644 --- a/lib/plausible/auth/auth.ex +++ b/lib/plausible/auth/auth.ex @@ -7,6 +7,7 @@ defmodule Plausible.Auth do use Plausible.Repo alias Plausible.Auth alias Plausible.RateLimit + alias Plausible.Teams @rate_limits %{ login_ip: %{ @@ -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 @@ -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) @@ -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} @@ -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 diff --git a/lib/plausible/auth/user.ex b/lib/plausible/auth/user.ex index e210798f6fe9..ef4e0e373b17 100644 --- a/lib/plausible/auth/user.ex +++ b/lib/plausible/auth/user.ex @@ -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 @@ -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 @@ -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 diff --git a/lib/plausible/auth/user_admin.ex b/lib/plausible/auth/user_admin.ex index 4e24f343ebf2..aeeab66f48fc 100644 --- a/lib/plausible/auth/user_admin.ex +++ b/lib/plausible/auth/user_admin.ex @@ -1,24 +1,13 @@ defmodule Plausible.Auth.UserAdmin do use Plausible.Repo use Plausible - require Plausible.Billing.Subscription.Status - alias Plausible.Billing.Subscription 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: [my_team: [subscription: ^subscripton_q]]) + from(r in query, preload: [:owned_teams]) end def custom_show_query(_conn, _schema, query) do - from(u in query, - 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 - } - ) + from(u in query, preload: [:owned_teams]) end def form_fields(_) do @@ -26,78 +15,31 @@ defmodule Plausible.Auth.UserAdmin do name: nil, 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} ] 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 + def delete(_conn, %{data: user}) do + case Plausible.Auth.delete_user(user) do + {:ok, :deleted} -> + :ok - {:ok, user} + {:error, :is_only_team_owner} -> + "The user is the only public team owner on one or more teams." end end - def delete(_conn, %{data: user}) do - Plausible.Auth.delete_user(user) - end - def index(_) do [ name: nil, email: nil, - inserted_at: %{name: "Created at", value: &format_date(&1.inserted_at)}, - trial_expiry_date: %{name: "Trial expiry", value: &format_date(&1.trial_expiry_date)}, - 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) - } + owned_teams: %{value: &Phoenix.HTML.raw(teams(&1.owned_teams))}, + inserted_at: %{name: "Created at", value: &format_date(&1.inserted_at)} ] end def resource_actions(_) do [ - unlock: %{ - name: "Unlock", - action: fn _, user -> unlock(user) end - }, - lock: %{ - name: "Lock", - action: fn _, user -> lock(user) end - }, reset_2fa: %{ name: "Reset 2FA", action: fn _, user -> disable_2fa(user) end @@ -105,94 +47,21 @@ defmodule Plausible.Auth.UserAdmin do ] 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 Plausible.Auth.TOTP.force_disable(user) end - defp grace_period_status(user) do - grace_period = user.my_team && user.my_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 - - defp subscription_plan(user) do - subscription = user.my_team && user.my_team.subscription - - if Subscription.Status.active?(subscription) && subscription.paddle_subscription_id do - quota = PlausibleWeb.AuthView.subscription_quota(subscription) - interval = PlausibleWeb.AuthView.subscription_interval(subscription) - - {:safe, ~s(#{quota} \(#{interval}\))} - 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(#{status_str})} - else - status_str - end - - Plausible.Teams.on_trial?(team) -> - "On trial" - - true -> - "Trial expired" - end + def teams([]) do + "(none)" end - defp manage_url(%{paddle_subscription_id: paddle_id} = _subscription) do - Plausible.Billing.PaddleApi.vendors_domain() <> - "/subscriptions/customers/manage/" <> paddle_id + def teams(teams) do + teams + |> Enum.map_join("
\n", fn team -> + """ + #{team.name} + """ + end) end defp format_date(nil), do: "--" diff --git a/lib/plausible/billing/billing.ex b/lib/plausible/billing/billing.ex index 245d78f53f7d..9314df1fc0c2 100644 --- a/lib/plausible/billing/billing.ex +++ b/lib/plausible/billing/billing.ex @@ -89,7 +89,7 @@ defmodule Plausible.Billing do subscription = Subscription |> Repo.get_by(paddle_subscription_id: params["subscription_id"]) - |> Repo.preload(team: :owner) + |> Repo.preload(team: :owners) if subscription do changeset = @@ -99,9 +99,11 @@ defmodule Plausible.Billing do updated = Repo.update!(changeset) - subscription.team.owner - |> PlausibleWeb.Email.cancellation_email() - |> Plausible.Mailer.send() + for owner <- subscription.team.owners do + owner + |> PlausibleWeb.Email.cancellation_email() + |> Plausible.Mailer.send() + end updated end @@ -138,9 +140,16 @@ defmodule Plausible.Billing do Teams.get!(team_id) {:user_id, user_id} -> - user = Repo.get!(Auth.User, user_id) - {:ok, team} = Teams.get_or_create(user) - team + # Given a guest or non-owner member user initiates the new subscription payment + # and becomes an owner of an existing team already with a subscription in between, + # 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 @@ -212,7 +221,7 @@ defmodule Plausible.Billing do Teams.Team |> Repo.get!(subscription.team_id) |> Teams.with_subscription() - |> Repo.preload(:owner) + |> Repo.preload(:owners) if subscription.id != team.subscription.id do Sentry.capture_message("Susbscription ID mismatch", @@ -236,7 +245,8 @@ defmodule Plausible.Billing 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]) end diff --git a/lib/plausible/billing/enterprise_plan.ex b/lib/plausible/billing/enterprise_plan.ex index 36ccd0a1d6a9..54cd4efa118e 100644 --- a/lib/plausible/billing/enterprise_plan.ex +++ b/lib/plausible/billing/enterprise_plan.ex @@ -24,9 +24,6 @@ defmodule Plausible.Billing.EnterprisePlan do field :features, Plausible.Billing.Ecto.FeatureList, default: [] 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 timestamps() diff --git a/lib/plausible/billing/enterprise_plan_admin.ex b/lib/plausible/billing/enterprise_plan_admin.ex index e0b56ef5a802..b8ed3fbe3d65 100644 --- a/lib/plausible/billing/enterprise_plan_admin.ex +++ b/lib/plausible/billing/enterprise_plan_admin.ex @@ -2,7 +2,7 @@ defmodule Plausible.Billing.EnterprisePlanAdmin do use Plausible.Repo @numeric_fields [ - "user_id", + "team_id", "paddle_plan_id", "monthly_pageview_limit", "site_limit", @@ -18,7 +18,7 @@ defmodule Plausible.Billing.EnterprisePlanAdmin do def form_fields(_schema) do [ - user_id: nil, + team_id: nil, paddle_plan_id: nil, billing_interval: %{choices: [{"Yearly", "yearly"}, {"Monthly", "monthly"}]}, monthly_pageview_limit: nil, @@ -40,25 +40,19 @@ defmodule Plausible.Billing.EnterprisePlanAdmin do from(r in query, 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(o.email, ^search_term) or ilike(o.name, ^search_term), - preload: [team: {t, owner: o}] - ) - end - - 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} + or_where: ilike(o.email, ^search_term), + or_where: ilike(o.name, ^search_term), + or_where: ilike(t.name, ^search_term), + preload: [team: {t, owners: o}] ) end def index(_) do [ id: nil, - user_email: %{value: &get_user_email/1}, + user_email: %{value: &owner_emails(&1.team)}, paddle_plan_id: nil, billing_interval: nil, monthly_pageview_limit: nil, @@ -68,20 +62,15 @@ defmodule Plausible.Billing.EnterprisePlanAdmin do ] end - defp get_user_email(plan), do: plan.team.owner.email + defp owner_emails(team) do + team.owners + |> Enum.map_join("
", & &1.email) + |> Phoenix.HTML.raw() + end def create_changeset(schema, attrs) do 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) end diff --git a/lib/plausible/billing/site_locker.ex b/lib/plausible/billing/site_locker.ex index 666bb0e04849..df871331e2e4 100644 --- a/lib/plausible/billing/site_locker.ex +++ b/lib/plausible/billing/site_locker.ex @@ -26,7 +26,7 @@ defmodule Plausible.Billing.SiteLocker do Plausible.Teams.end_grace_period(team) if send_email? do - team = Repo.preload(team, :owner) + team = Repo.preload(team, :owners) send_grace_period_end_email(team) end @@ -64,8 +64,10 @@ defmodule Plausible.Billing.SiteLocker do usage = Teams.Billing.monthly_pageview_usage(team) suggested_plan = Plausible.Billing.Plans.suggest(team, usage.last_cycle.total) - team.owner - |> PlausibleWeb.Email.dashboard_locked(usage, suggested_plan) - |> Plausible.Mailer.send() + for owner <- team.owners do + owner + |> PlausibleWeb.Email.dashboard_locked(usage, suggested_plan) + |> Plausible.Mailer.send() + end end end diff --git a/lib/plausible/crm_extensions.ex b/lib/plausible/crm_extensions.ex index 054b6106f003..c0aa28760e77 100644 --- a/lib/plausible/crm_extensions.ex +++ b/lib/plausible/crm_extensions.ex @@ -9,12 +9,31 @@ defmodule Plausible.CrmExtensions do # Kaffy uses String.to_existing_atom when listing params @custom_search :custom_search + def javascripts(%{assigns: %{context: "teams", resource: "team", entry: %{} = team}}) do + [ + Phoenix.HTML.raw(""" + + """) + ] + end + def javascripts(%{assigns: %{context: "auth", resource: "user", entry: %{} = user}}) do [ Phoenix.HTML.raw("""