diff --git a/CHANGELOG.md b/CHANGELOG.md index abf6e9254c..32b4617ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ All notable changes to this project will be documented in this file. when they occurred. - Fixed realtime and hourly graphs of visits overcounting - When reporting only `visitors` and `visits` per hour, count visits in each hour they were active in. +- Remove Subscription and Invoices menu from CE +- Fix email sending error "Mua.SMTPError" 503 Bad sequence of commands ## v3.0.0 - 2025-04-11 diff --git a/lib/plausible_web/components/layout.ex b/lib/plausible_web/components/layout.ex index 58c0782ec2..db5505c934 100644 --- a/lib/plausible_web/components/layout.ex +++ b/lib/plausible_web/components/layout.ex @@ -59,7 +59,7 @@ defmodule PlausibleWeb.Components.Layout do def settings_sidebar(assigns) do ~H""" -
+
<.settings_top_tab :for={%{key: key, value: value, icon: icon} = opts <- @options} selected_fn={@selected_fn} diff --git a/lib/plausible_web/live/components/form.ex b/lib/plausible_web/live/components/form.ex index bf496eb349..0a2be22ec1 100644 --- a/lib/plausible_web/live/components/form.ex +++ b/lib/plausible_web/live/components/form.ex @@ -429,7 +429,7 @@ defmodule PlausibleWeb.Live.Components.Form do assigns = assign(assigns, :options, flatten_options(options)) ~H""" - <.form for={@conn} class="lg:hidden py-4"> + <.form for={@conn} class="lg:hidden py-4" data-testid="mobile-nav-dropdown"> <.input value={ @options diff --git a/lib/plausible_web/views/layout_view.ex b/lib/plausible_web/views/layout_view.ex index 3840f01fd9..208664252e 100644 --- a/lib/plausible_web/views/layout_view.ex +++ b/lib/plausible_web/views/layout_view.ex @@ -93,10 +93,10 @@ defmodule PlausibleWeb.LayoutView do [ %{key: "Preferences", value: "preferences", icon: :cog_6_tooth}, %{key: "Security", value: "security", icon: :lock_closed}, - if(not Teams.setup?(current_team), + if(ee?() and not Teams.setup?(current_team), do: %{key: "Subscription", value: "billing/subscription", icon: :circle_stack} ), - if(not Teams.setup?(current_team) and subscription?, + if(ee?() and not Teams.setup?(current_team) and subscription?, do: %{key: "Invoices", value: "billing/invoices", icon: :banknotes} ), if(not Teams.setup?(current_team), @@ -115,10 +115,10 @@ defmodule PlausibleWeb.LayoutView do "Team", [ %{key: "General", value: "team/general", icon: :adjustments_horizontal}, - if(current_team_role in [:owner, :billing], + if(ee?() and current_team_role in [:owner, :billing], do: %{key: "Subscription", value: "billing/subscription", icon: :circle_stack} ), - if(current_team_role in [:owner, :billing] and subscription?, + if(ee?() and current_team_role in [:owner, :billing] and subscription?, do: %{key: "Invoices", value: "billing/invoices", icon: :banknotes} ), if(current_team_role in [:owner, :billing, :admin, :editor], diff --git a/mix.lock b/mix.lock index 7ef13586b9..873c21875d 100644 --- a/mix.lock +++ b/mix.lock @@ -92,7 +92,7 @@ "mjml": {:hex, :mjml, "3.1.0", "549e985bc03be1af563c62a34c8e62bdb8d0baaa6b31af705a5bdf67e20f22b7", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7.0", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "987674d296b14b628e5e5d2d8b910e6501cdfafa0239527d8b633880dc595344"}, "mjml_eex": {:hex, :mjml_eex, "0.11.0", "f0845730f4caccddea7c98ab5ad1485831446b7c09896fa5ed54b3fa0c431e72", [:mix], [{:erlexec, "~> 2.0", [hex: :erlexec, repo: "hexpm", optional: true]}, {:mjml, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :mjml, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0c60732fe766336ec504a94cad4ebf30405f05fa8920a544ff0ef936252438ac"}, "mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"}, - "mua": {:hex, :mua, "0.2.4", "a9172ab0a1ac8732cf2699d739ceac3febcb9b4ffc540260ad2e32c0b6632af9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "e7e4dacd5ad65f13e3542772e74a159c00bd2d5579e729e9bb72d2c73a266fb7"}, + "mua": {:hex, :mua, "0.2.5", "e99aa9646964a0109a2efcc8e684c6f8d90c60fb0191f52e1784cea296584daf", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "0e2b18024d0db8943a68e84fb5e2253d3225c8f61d8387cbfc581d66e34d8493"}, "nanoid": {:hex, :nanoid, "2.1.0", "d192a5bf1d774258bc49762b480fca0e3128178fa6d35a464af2a738526607fd", [:mix], [], "hexpm", "ebc7a342d02d213534a7f93a091d569b9fea7f26fcd3a638dc655060fc1f76ac"}, "nimble_csv": {:hex, :nimble_csv, "1.2.0", "4e26385d260c61eba9d4412c71cea34421f296d5353f914afe3f2e71cce97722", [:mix], [], "hexpm", "d0628117fcc2148178b034044c55359b26966c6eaa8e2ce15777be3bbc91b12a"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, diff --git a/test/plausible_web/controllers/settings_controller_test.exs b/test/plausible_web/controllers/settings_controller_test.exs index 39ada2c606..1d97a5fe7f 100644 --- a/test/plausible_web/controllers/settings_controller_test.exs +++ b/test/plausible_web/controllers/settings_controller_test.exs @@ -1345,10 +1345,41 @@ defmodule PlausibleWeb.SettingsControllerTest do end end + @menu_items [ + preferences: {"Preferences", "/settings/preferences"}, + security: {"Security", "/settings/security"}, + subscription: {"Subscription", "/settings/billing/subscription"}, + invoices: {"Invoices", "/settings/billing/invoices"}, + api_keys: {"API keys", "/settings/api-keys"}, + danger_zone: {"Danger zone", "/settings/danger-zone"}, + team_general: {"General", "/settings/team/general"}, + sso: {"Single Sign-On", "/settings/sso/info"}, + team_danger_zone: {"Danger zone", "/settings/team/delete"} + ] + on_ee do describe "Account Settings - SSO user" do setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in] + test "shows only expected menu items", %{conn: conn} do + conn = get(conn, Routes.settings_path(conn, :preferences)) + assert html = html_response(conn, 200) + + expected_account_menu = [:preferences, :security, :subscription, :api_keys] + + html + |> refute_unexpected_menu_items([ + :invoices, + :team_general, + :sso, + :team_danger_zone, + :danger_zone + ]) + |> Floki.parse_document!() + |> assert_sidebar_menu(expected_account_menu) + |> assert_mobile_menu(expected_account_menu) + end + test "does not allow to update name in preferences", %{conn: conn} do conn = get(conn, Routes.settings_path(conn, :preferences)) assert html = html_response(conn, 200) @@ -1375,67 +1406,69 @@ defmodule PlausibleWeb.SettingsControllerTest do assert html = html_response(conn, 200) assert text_of_element(html, "button[disabled]") =~ "Disable 2FA" end - - test "does not show account danger zone", %{conn: conn} do - conn = get(conn, Routes.settings_path(conn, :preferences)) - assert html = html_response(conn, 200) - refute html =~ "/settings/danger-zone" - end end end describe "Team Settings" do setup [:create_user, :log_in] - test "does not render team settings, when no team assigned", %{conn: conn} do + test "when no team is assigned & the user doesn't have a subscription, limited account menu is present", + %{conn: conn} do conn = get(conn, Routes.settings_path(conn, :preferences)) html = html_response(conn, 200) refute html =~ "Team" + + expected_account_menu = + if(ee?(), + do: [:preferences, :security, :subscription, :api_keys, :danger_zone], + else: [:preferences, :security, :api_keys, :danger_zone] + ) + + html + |> refute_unexpected_menu_items( + if(ee?(), + do: [:invoices, :team_general, :sso], + else: [:subscription, :invoices, :team_general, :sso] + ) + ) + |> Floki.parse_document!() + |> assert_sidebar_menu(expected_account_menu) + |> assert_mobile_menu(expected_account_menu) end - test "does not render invoices when no subscription present (no team assigned)", %{conn: conn} do - conn = get(conn, Routes.settings_path(conn, :preferences)) - html = html_response(conn, 200) - refute html =~ Routes.settings_path(conn, :invoices) - end - - test "does render invoices when subscription present (no team assigned)", %{ - conn: conn, - user: user - } do + test "when no team is assigned & the user has a subscription, the account menu contains invoices", + %{ + conn: conn, + user: user + } do subscribe_to_growth_plan(user) - conn = get(conn, Routes.settings_path(conn, :preferences)) - html = html_response(conn, 200) - assert html =~ Routes.settings_path(conn, :invoices) - end - - test "does not render invoices when no subscription (team set up)", %{ - conn: conn, - user: user - } do - {:ok, team} = Plausible.Teams.get_or_create(user) - team = Plausible.Teams.complete_setup(team) - conn = set_current_team(conn, team) - conn = get(conn, Routes.settings_path(conn, :preferences)) - html = html_response(conn, 200) - refute html =~ Routes.settings_path(conn, :invoices) - end - - test "does render invoices when subscription present (team assigned)", %{ - conn: conn, - user: user - } do - subscribe_to_growth_plan(user) - {:ok, team} = Plausible.Teams.get_or_create(user) - team = Plausible.Teams.complete_setup(team) - conn = set_current_team(conn, team) conn = get(conn, Routes.settings_path(conn, :preferences)) html = html_response(conn, 200) - assert html =~ Routes.settings_path(conn, :invoices) + + expected_account_menu = + if(ee?(), + do: [:preferences, :security, :subscription, :invoices, :api_keys, :danger_zone], + else: [:preferences, :security, :api_keys, :danger_zone] + ) + + html + |> refute_unexpected_menu_items( + if(ee?(), + do: [:team_general, :sso], + else: [:subscription, :invoices, :team_general, :sso] + ) + ) + |> Floki.parse_document!() + |> assert_sidebar_menu(expected_account_menu) + |> assert_mobile_menu(expected_account_menu) end - test "renders team settings, when team assigned and set up", %{conn: conn, user: user} do + test "when team is set up & there's no subscription, renders limited account & team menu", + %{ + conn: conn, + user: user + } do {:ok, team} = Plausible.Teams.get_or_create(user) team = Plausible.Teams.complete_setup(team) conn = set_current_team(conn, team) @@ -1443,6 +1476,66 @@ defmodule PlausibleWeb.SettingsControllerTest do html = html_response(conn, 200) assert html =~ ~r/Team.*#{Regex.escape(team.name)}/s assert html =~ team.name + + expected_account_menu = [ + :preferences, + :security, + :danger_zone + ] + + expected_team_menu = + if(ee?(), + do: [:team_general, :subscription, :api_keys, :sso, :team_danger_zone], + else: [:team_general, :api_keys, :team_danger_zone] + ) + + html + |> refute_unexpected_menu_items( + if(ee?(), do: [:invoices], else: [:subscription, :invoices]) + ) + |> Floki.parse_document!() + |> assert_sidebar_menu(expected_account_menu, expected_team_menu) + |> assert_mobile_menu(expected_account_menu, expected_team_menu) + end + + test "when team is set up, and there's a subscription, renders account & team menu with invoices", + %{ + conn: conn, + user: user + } do + subscribe_to_growth_plan(user) + {:ok, team} = Plausible.Teams.get_or_create(user) + team = Plausible.Teams.complete_setup(team) + conn = set_current_team(conn, team) + + conn = get(conn, Routes.settings_path(conn, :preferences)) + html = html_response(conn, 200) + assert html =~ ~r/Team.*#{Regex.escape(team.name)}/s + assert html =~ team.name + + expected_account_menu = [ + :preferences, + :security, + :danger_zone + ] + + expected_team_menu = + if(ee?(), + do: [ + :team_general, + :subscription, + :invoices, + :api_keys, + :sso, + :team_danger_zone + ], + else: [:team_general, :api_keys, :team_danger_zone] + ) + + html + |> Floki.parse_document!() + |> assert_sidebar_menu(expected_account_menu, expected_team_menu) + |> assert_mobile_menu(expected_account_menu, expected_team_menu) end test "does not render team settings, when team not set up", %{conn: conn, user: user} do @@ -1645,4 +1738,73 @@ defmodule PlausibleWeb.SettingsControllerTest do ) ) end + + defp assert_sidebar_menu(document, ordered_account_menu_keys, ordered_team_menu_keys \\ []) do + ordered_menu_keys = Enum.concat(ordered_account_menu_keys, ordered_team_menu_keys) + assert get_expected_menu(ordered_menu_keys) == get_sidebar_menu_items(document) + + document + end + + defp assert_mobile_menu( + document, + ordered_account_menu_keys, + ordered_team_menu_keys \\ [] + ) do + expected_account_items = + ordered_account_menu_keys + |> get_expected_menu() + |> Enum.map(fn {text, "/settings/" <> path_fragment} -> + {"Account: #{text}", path_fragment} + end) + + expected_team_items = + ordered_team_menu_keys + |> get_expected_menu() + |> Enum.map(fn {text, "/settings/" <> path_fragment} -> + {"Team: #{text}", path_fragment} + end) + + assert Enum.concat( + expected_account_items, + expected_team_items + ) == + get_mobile_menu_options(document) + + document + end + + defp get_expected_menu(ordered_menu_keys) do + ordered_menu_keys + |> Keyword.new(&{&1, nil}) + |> Keyword.intersect(@menu_items) + |> Keyword.values() + end + + defp refute_unexpected_menu_items(html, unexpected_menu_keys) do + refuted_menu_items = @menu_items |> Keyword.take(unexpected_menu_keys) |> Keyword.values() + + for {text, link} <- refuted_menu_items do + refute html =~ text + refute html =~ link + end + + html + end + + defp get_mobile_menu_options(document) do + Floki.find(document, "[data-testid='mobile-nav-dropdown'] option") + |> Enum.map(&parse_option/1) + end + + defp parse_option(option), + do: {Floki.text(option), Floki.attribute(option, "value") |> List.first()} + + defp get_sidebar_menu_items(document) do + Floki.find(document, "[data-testid='settings-sidebar'] a") + |> Enum.map(&parse_link/1) + end + + defp parse_link(link), + do: {Floki.text(link) |> String.trim(), Floki.attribute(link, "href") |> List.first()} end