Calmer multiple teams experience (#5526)

* Offer team switcher on /sites if applicable

- in case of empty My Personal Sites view, and with
  another team with sites being available
- redirect straight to first team upon invoking team
  switcher, if there's only one available
- redirect to /sites from team switcher, if there
  are no set-up teams available

* Remove unused test helper

* Store and use last team identifier

* Remove alert about starting trial when adding first site

* Format

* Update lib/plausible_web/live/sites.ex

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>

---------

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
This commit is contained in:
Adam Rutkowski 2025-06-24 18:11:27 +02:00 committed by GitHub
parent 24d4ae6a58
commit ef11425693
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 141 additions and 44 deletions

View File

@ -40,6 +40,9 @@ defmodule Plausible.Auth.User do
field :totp_token, :string
field :totp_last_used_at, :naive_datetime
# for context perseverance across sessions
field :last_team_identifier, Ecto.UUID
on_ee do
# Fields for SSO
field :type, Ecto.Enum, values: [:standard, :sso]

View File

@ -41,6 +41,13 @@ defmodule Plausible.Users do
:ok
end
@spec remember_last_team(Auth.User.t(), String.t() | nil) :: :ok
def remember_last_team(%Auth.User{id: user_id}, team_identifier) do
q = from(u in Auth.User, where: u.id == ^user_id)
Repo.update_all(q, set: [last_team_identifier: team_identifier])
:ok
end
@spec has_email_code?(Auth.User.t()) :: boolean()
def has_email_code?(user) do
Auth.EmailVerification.any?(user)

View File

@ -93,8 +93,17 @@ defmodule PlausibleWeb.AuthController do
}
end)
case teams do
[] ->
redirect(conn, to: Routes.site_path(conn, :index))
[%{identifier: sole_team_identifier}] ->
redirect(conn, to: Routes.site_path(conn, :index, __team: sole_team_identifier))
[_ | _] ->
render(conn, "select_team.html", teams_selection: teams)
end
end
def activate_form(conn, params) do
user = conn.assigns.current_user

View File

@ -22,7 +22,7 @@ defmodule PlausibleWeb.Live.Sites do
:team_invitations,
Teams.Invitations.all(socket.assigns.current_user)
)
|> assign(:filter_text, params["filter_text"] || "")
|> assign(:filter_text, String.trim(params["filter_text"] || ""))
{:ok, socket}
end
@ -88,13 +88,23 @@ defmodule PlausibleWeb.Live.Sites do
</div>
</div>
<p
:if={String.trim(@filter_text) != "" and @sites.entries == []}
class="mt-4 dark:text-gray-100"
>
<p :if={@filter_text != "" and @sites.entries == []} class="mt-4 dark:text-gray-100 text-center">
No sites found. Please search for something else.
</p>
<p
:if={
@has_sites? and not Teams.setup?(@current_team) and @sites.entries == [] and
@filter_text == ""
}
class="mt-4 dark:text-gray-100 text-center"
>
You currently have no personal sites. Are you looking for your teams sites?
<.styled_link href={Routes.auth_path(@socket, :select_team)}>
Go to your team &rarr;
</.styled_link>
</p>
<div :if={@has_sites?}>
<ul class="my-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<%= for site <- @sites.entries do %>
@ -719,6 +729,7 @@ defmodule PlausibleWeb.Live.Sites do
end
defp set_filter_text(socket, filter_text) do
filter_text = String.trim(filter_text)
uri = socket.assigns.uri
uri_params =

View File

@ -20,7 +20,9 @@ defmodule PlausibleWeb.AuthPlug do
user = user_session.user
current_team_id_from_session = Plug.Conn.get_session(conn, "current_team_id")
current_team_id = conn.params["__team"] || current_team_id_from_session
current_team_id =
conn.params["__team"] || current_team_id_from_session || user.last_team_identifier
{current_team, current_team_role} =
if current_team_id do
@ -35,9 +37,11 @@ defmodule PlausibleWeb.AuthPlug do
conn =
cond do
current_team && current_team_id != current_team_id_from_session ->
Plausible.Users.remember_last_team(user, current_team_id)
Plug.Conn.put_session(conn, "current_team_id", current_team_id)
is_nil(current_team) && not is_nil(current_team_id_from_session) ->
Plausible.Users.remember_last_team(user, nil)
Plug.Conn.delete_session(conn, "current_team_id")
true ->

View File

@ -14,35 +14,6 @@
resource="sites"
/>
<%= if ee?() and (is_nil(@current_team) or is_nil(@current_team.trial_expiry_date)) do %>
<div class="rounded-md bg-blue-50 dark:bg-transparent dark:border border-blue-200 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-blue-500 dark:text-blue-300"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<div class="text-sm text-blue-700 dark:text-blue-300">
<p>
When you create your first site, your account will enter a 30 day free trial.
</p>
</div>
</div>
</div>
</div>
<% end %>
<div class="my-6">
<.input
help_text="Just the naked domain or subdomain without 'www', 'https' etc."

View File

@ -1724,6 +1724,48 @@ defmodule PlausibleWeb.AuthControllerTest do
end
end
describe "GET /team/select" do
setup [:create_user, :log_in]
test "redirects to /sites if no teams available", %{conn: conn} do
conn = get(conn, Routes.auth_path(conn, :select_team))
assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
end
test "redirects to /sites?__team if one team set up available", %{conn: conn, user: user} do
new_site(owner: user)
team = team_of(user)
assert Plausible.Teams.complete_setup(team)
conn = get(conn, Routes.auth_path(conn, :select_team))
assert redirected_to(conn, 302) == Routes.site_path(conn, :index, __team: team.identifier)
end
test "displays team switcher if >1 teams available", %{conn: conn, user: user} do
t1 = new_site(owner: user).team
t2 = new_site().team
add_member(t2, user: user, role: :viewer)
Plausible.Teams.complete_setup(t1)
Plausible.Teams.complete_setup(t2)
conn = get(conn, Routes.auth_path(conn, :select_team))
assert html = html_response(conn, 200)
assert text(html) =~ "Switch your current team"
assert element_exists?(
html,
~s|a[href="#{Routes.site_path(conn, :index, __team: t1.identifier)}"]|
)
assert element_exists?(
html,
~s|a[href="#{Routes.site_path(conn, :index, __team: t2.identifier)}"]|
)
end
end
defp login_with_cookie(conn, email, password) do
conn
|> post(Routes.auth_path(conn, :login), %{

View File

@ -13,9 +13,28 @@ defmodule PlausibleWeb.Live.SitesTest do
test "renders empty sites page", %{conn: conn} do
{:ok, _lv, html} = live(conn, "/sites")
assert text(html) =~ "My Personal Sites"
text = text(html)
assert text =~ "My Personal Sites"
assert text =~ "You don't have any sites yet"
refute text =~ "You currently have no personal sites"
refute text =~ "Go to your team"
end
assert text(html) =~ "You don't have any sites yet"
test "renders team switcher link, if on personal sites with other teams available", %{
conn: conn,
user: user
} do
team2 = new_site().team
add_member(team2, user: user, role: :admin)
{:ok, _lv, html} = live(conn, "/sites")
text = text(html)
assert text =~ "My Personal Sites"
refute text =~ "You don't have any sites yet"
assert text =~ "You currently have no personal sites"
assert text =~ "Go to your team"
end
test "renders settings link when current team is set", %{user: user, conn: conn} do

View File

@ -123,4 +123,41 @@ defmodule PlausibleWeb.AuthPlugTest do
assert conn.assigns[:current_team].id == team.id
assert conn.assigns[:current_team_role] == :editor
end
test "stores team identifier when team changes", %{conn: conn, user: user} do
subscribe_to_plan(user, "123", inserted_at: NaiveDateTime.utc_now())
team = team_of(user)
assert is_nil(user.last_team_identifier)
conn =
conn
|> Plug.Adapters.Test.Conn.conn(:get, "/", %{__team: team.identifier})
|> AuthPlug.call(%{})
updated_user = Plausible.Repo.reload!(user)
assert updated_user.last_team_identifier == team.identifier
assert get_session(conn, "current_team_id") == team.identifier
end
test "clears team identifier when recently stored team identifier doesn't exist", %{
conn: conn,
user: user
} do
subscribe_to_plan(user, "123", inserted_at: NaiveDateTime.utc_now())
stale_team_id = Ecto.UUID.generate()
:ok = Plausible.Users.remember_last_team(user, stale_team_id)
assert Plausible.Repo.reload!(user).last_team_identifier
conn =
conn
|> put_session("current_team_id", stale_team_id)
|> Plug.Adapters.Test.Conn.conn(:get, "/", %{})
|> AuthPlug.call(%{})
updated_user = Plausible.Repo.reload!(user)
assert is_nil(updated_user.last_team_identifier)
refute get_session(conn, "current_team_id")
end
end

View File

@ -49,12 +49,6 @@ defmodule Plausible.Teams.Test do
|> insert(args)
end
def new_team() do
new_user()
|> Map.fetch!(:team_memberships)
|> List.first()
end
def new_user(args \\ []) do
{team_args, args} = Keyword.pop(args, :team, [])
{trial_expiry_date, args} = Keyword.pop(args, :trial_expiry_date)