Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion lib/plausible_web/components/layout.ex
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ defmodule PlausibleWeb.Components.Layout do

def settings_sidebar(assigns) do
~H"""
<div class="flex flex-col gap-0.5 -ml-2">
<div class="flex flex-col gap-0.5 -ml-2" data-testid="settings-sidebar">
<.settings_top_tab
:for={%{key: key, value: value, icon: icon} = opts <- @options}
selected_fn={@selected_fn}
Expand Down
2 changes: 1 addition & 1 deletion lib/plausible_web/live/components/form.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions lib/plausible_web/views/layout_view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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],
Expand Down
2 changes: 1 addition & 1 deletion mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
232 changes: 197 additions & 35 deletions test/plausible_web/controllers/settings_controller_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -1375,74 +1406,136 @@ 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"
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)
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 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)

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 "does not render invoices when no subscription (team 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)
conn = get(conn, Routes.settings_path(conn, :preferences))
html = html_response(conn, 200)
refute html =~ Routes.settings_path(conn, :invoices)
end
assert html =~ ~r/Team.*#{Regex.escape(team.name)}/s
assert html =~ team.name

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)
expected_account_menu = [
:preferences,
:security,
:danger_zone
]

conn = get(conn, Routes.settings_path(conn, :preferences))
html = html_response(conn, 200)
assert html =~ Routes.settings_path(conn, :invoices)
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 "renders team settings, when team assigned and set up", %{conn: conn, user: user} do
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
Expand Down Expand Up @@ -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