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