diff --git a/lib/plausible/teams/invitations.ex b/lib/plausible/teams/invitations.ex index 041f47812c2c..caa5780ad6d9 100644 --- a/lib/plausible/teams/invitations.ex +++ b/lib/plausible/teams/invitations.ex @@ -35,7 +35,17 @@ defmodule Plausible.Teams.Invitations do end end - def find_team_invitations(user) do + def find_team_invitations(%Teams.Team{} = team) do + Repo.all( + from ti in Teams.Invitation, + inner_join: inviter in assoc(ti, :inviter), + where: ti.team_id == ^team.id, + where: ti.role != :guest, + preload: [inviter: inviter] + ) + end + + def find_team_invitations(%Plausible.Auth.User{} = user) do Repo.all( from ti in Teams.Invitation, inner_join: inviter in assoc(ti, :inviter), diff --git a/lib/plausible/teams/memberships.ex b/lib/plausible/teams/memberships.ex index 41d4c5fee670..e09497e7ec37 100644 --- a/lib/plausible/teams/memberships.ex +++ b/lib/plausible/teams/memberships.ex @@ -7,6 +7,18 @@ defmodule Plausible.Teams.Memberships do alias Plausible.Repo alias Plausible.Teams + def all_members(team) do + query = + from tm in Teams.Membership, + inner_join: u in assoc(tm, :user), + where: tm.team_id == ^team.id, + where: tm.role != :guest, + order_by: [asc: u.id], + preload: [user: u] + + Repo.all(query) + end + def all_pending_site_transfers(email) do email |> pending_site_transfers_query() diff --git a/lib/plausible/teams/memberships/remove.ex b/lib/plausible/teams/memberships/remove.ex index 9aa1562e2c43..7ba440884d8e 100644 --- a/lib/plausible/teams/memberships/remove.ex +++ b/lib/plausible/teams/memberships/remove.ex @@ -8,14 +8,17 @@ defmodule Plausible.Teams.Memberships.Remove do def remove(nil, _, _), do: {:error, :permission_denied} - def remove(team, user_id, current_user) do + def remove(team, user_id, current_user, opts \\ []) do with {:ok, team_membership} <- Memberships.get_team_membership(team, user_id), {:ok, current_user_role} <- Memberships.team_role(team, current_user), :ok <- check_can_remove_membership(current_user_role, team_membership.role), :ok <- check_owner_can_get_removed(team, team_membership.role) do team_membership = Repo.preload(team_membership, [:team, :user]) Repo.delete!(team_membership) - send_team_member_removed_email(team_membership) + + if Keyword.get(opts, :send_email?, true) do + send_team_member_removed_email(team_membership) + end {:ok, team_membership} end @@ -35,7 +38,7 @@ defmodule Plausible.Teams.Memberships.Remove do defp check_owner_can_get_removed(_team, _role), do: :ok - defp send_team_member_removed_email(team_membership) do + def send_team_member_removed_email(team_membership) do team_membership |> PlausibleWeb.Email.team_member_removed() |> Plausible.Mailer.send() diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex index 587d86c8b50b..73f093e7f082 100644 --- a/lib/plausible_web/components/generic.ex +++ b/lib/plausible_web/components/generic.ex @@ -33,7 +33,7 @@ defmodule PlausibleWeb.Components.Generic do "border border-gray-300 dark:border-gray-500 text-red-700 bg-white dark:bg-gray-900 hover:text-red-500 dark:hover:text-red-400 focus:border-blue-300 dark:text-red-500 active:text-red-800" } - @button_base_class "whitespace-nowrap truncate inline-flex items-center justify-center gap-x-2 font-medium rounded-md px-3.5 py-2.5 text-sm shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700" + @button_base_class "whitespace-nowrap truncate inline-flex items-center justify-center gap-x-2 font-medium rounded-md px-3 py-2 text-sm shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700" attr(:type, :string, default: "button") attr(:theme, :string, default: "primary") diff --git a/lib/plausible_web/controllers/settings_controller.ex b/lib/plausible_web/controllers/settings_controller.ex index b47c91231906..116b971181a7 100644 --- a/lib/plausible_web/controllers/settings_controller.ex +++ b/lib/plausible_web/controllers/settings_controller.ex @@ -40,7 +40,8 @@ defmodule PlausibleWeb.SettingsController do render(conn, :team_general, team_name_changeset: name_changeset, - layout: {PlausibleWeb.LayoutView, :settings} + layout: {PlausibleWeb.LayoutView, :settings}, + connect_live_socket: true ) end diff --git a/lib/plausible_web/live/components/team.ex b/lib/plausible_web/live/components/team.ex new file mode 100644 index 000000000000..2c313736bcf8 --- /dev/null +++ b/lib/plausible_web/live/components/team.ex @@ -0,0 +1,122 @@ +defmodule PlausibleWeb.Live.Components.Team do + use Phoenix.Component + import PlausibleWeb.Components.Generic + alias Plausible.Auth.User + + attr :user, User, required: true + attr :label, :string, default: nil + attr :role, :atom, default: nil + attr :my_role, :atom, required: true + attr :disabled, :boolean, default: false + attr :remove_disabled, :boolean, default: false + + def member(assigns) do + ~H""" +
+
+ + + {@user.name} + + {@label} + + +
{@user.email} +
+
+ <.dropdown class="relative"> + <:button class="role bg-transparent text-gray-800 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 focus-visible:outline-gray-100 whitespace-nowrap truncate inline-flex items-center gap-x-2 font-medium rounded-md px-3.5 py-2.5 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700"> + {@role |> to_string() |> String.capitalize()} + + + <:menu class="dropdown-items max-w-60"> + <.role_item + phx-value-email={@user.email} + phx-value-name={@user.name} + role={:owner} + disabled={@disabled or @role == :owner} + phx-click="update-role" + > + Manage the team without restrictions + + <.role_item + phx-value-email={@user.email} + phx-value-name={@user.name} + role={:admin} + disabled={@disabled or @role == :admin} + phx-click="update-role" + > + Manage all team settings + + <.role_item + phx-value-email={@user.email} + phx-value-name={@user.name} + role={:editor} + disabled={@disabled or @role == :editor} + phx-click="update-role" + > + Create and view new sites + + <.role_item + phx-value-email={@user.email} + phx-value-name={@user.name} + role={:billing} + disabled={@disabled or @role == :billing} + phx-click="update-role" + > + Manage subscription + + <.role_item + phx-value-email={@user.email} + phx-value-name={@user.name} + role={:viewer} + disabled={@disabled or @role == :viewer} + phx-click="update-role" + > + View all sites under your team + + <.dropdown_divider /> + <.dropdown_item + href="#" + disabled={@disabled or @remove_disabled} + phx-click="remove-member" + phx-value-email={@user.email} + phx-value-name={@user.name} + > +
+ Remove member +
+
+ Remove member from your team +
+ + + +
+
+
+ """ + end + + attr :role, :atom, required: true + attr :disabled, :boolean, default: false + slot :inner_block, required: true + attr :rest, :global + + def role_item(assigns) do + ~H""" + <.dropdown_item href="#" phx-value-role={@role} disabled={@disabled} {@rest}> +
{@role |> Atom.to_string() |> String.capitalize()}
+
+ {render_slot(@inner_block)} +
+ + """ + end +end diff --git a/lib/plausible_web/live/team_management.ex b/lib/plausible_web/live/team_management.ex new file mode 100644 index 000000000000..1d53388d564d --- /dev/null +++ b/lib/plausible_web/live/team_management.ex @@ -0,0 +1,288 @@ +defmodule PlausibleWeb.Live.TeamManagement do + use PlausibleWeb, :live_view + + alias Plausible.Repo + alias Plausible.Teams + alias PlausibleWeb.Live.Components.ComboBox + alias Plausible.Teams.Invitations.Candidates + alias Plausible.Auth.User + import PlausibleWeb.Live.Components.Team + + alias PlausibleWeb.Router.Helpers, as: Routes + alias PlausibleWeb.Live.TeamManagement.Layout + + def mount(_params, _session, socket) do + {:ok, reset(socket)} + end + + defp reset(%{assigns: %{current_user: current_user, my_team: my_team}} = socket) do + {:ok, my_role} = Teams.Memberships.team_role(my_team, current_user) + # XXX handle redirect here + true = my_role in [:owner, :admin] + + invitations_sent = Plausible.Teams.Invitations.find_team_invitations(my_team) + all_members = Teams.Memberships.all_members(my_team) + + layout = Layout.build_by_email(current_user, invitations_sent ++ all_members) + + assign(socket, + layout: layout, + my_role: my_role, + all_members: all_members, + invitations_pending: [], + invitations_sent: invitations_sent, + team_layout_changed?: false, + input_role: :viewer, + input_email: "" + ) + end + + def render(assigns) do + ~H""" + <.flash_messages flash={@flash} /> +
+ <.form for={} phx-submit="input-invitation" phx-change="form-changed"> +
+
+ <.input + name="input-email" + type="email" + value={@input_email} + placeholder="Enter e-mail to send invitation to" + phx-debounce={200} + mt?={false} + /> +
+ + <.dropdown class="relative"> + <:button class="role border rounded border-indigo-700 bg-transparent text-gray-800 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 focus-visible:outline-gray-100 whitespace-nowrap truncate inline-flex items-center gap-x-2 font-medium rounded-md px-3 py-2 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700"> + {@input_role |> Atom.to_string() |> String.capitalize()} + + + <:menu class="dropdown-items max-w-60"> + <.role_item role={:owner} disabled={@my_role != :owner} phx-click="switch-role"> + Manage the team without restrictions + + <.role_item + role={:admin} + disabled={@my_role not in [:owner, :admin]} + phx-click="switch-role" + > + Manage all team settings + + <.role_item + role={:editor} + disabled={@my_role not in [:owner, :admin]} + phx-click="switch-role" + > + Create and view new sites + + <.role_item + role={:billing} + disabled={@my_role not in [:owner, :admin]} + phx-click="switch-role" + > + Manage subscription + + <.role_item + role={:viewer} + disabled={@my_role not in [:owner, :admin]} + phx-click="switch-role" + > + View all sites under your team + + + + + <.button type="submit" mt?={false}> + Invite + +
+ + + <.member + :for={{email, entry} <- Layout.sorted_for_display(@layout)} + :if={entry.queued_op != :delete} + user={%User{email: entry.email, name: entry.name}} + role={entry.role} + label={entry.label} + my_role={@my_role} + remove_disabled={not Layout.removable?(@layout, email)} + /> + + <.button type="submit" phx-click="save-team-layout" disabled={not @team_layout_changed?}> + Save changes + +
+ """ + end + + @roles Plausible.Teams.Membership.roles() -- [:guest] + @roles_cast_map Enum.into(@roles, %{}, fn role -> {to_string(role), role} end) + + def handle_event("form-changed", params, socket) do + {:noreply, assign(socket, input_email: params["input-email"])} + end + + def handle_event("switch-role", %{"role" => role}, socket) do + socket = assign(socket, input_role: Map.fetch!(@roles_cast_map, role)) + {:noreply, socket} + end + + def handle_event( + "input-invitation", + %{"input-email" => email}, + %{assigns: %{layout: layout, input_role: role}} = socket + ) do + email = String.trim(email) + + existing_entry = + Enum.find_value(layout, fn {key_email, entry} -> + if key_email == email, do: entry + end) + + socket = + cond do + existing_entry && existing_entry.queued_op == :delete -> + # bring back previously deleted entry (either invitation or membership), and only update role + socket + |> update_layout(email, nil, queued_op: :update, role: role) + |> assign(input_email: "") + + existing_entry -> + # trying to add e-mail that's already in the layout + socket + |> assign(input_email: email) + |> put_live_flash( + :error, + "Make sure the e-mail is valid and is not taken already in your team layout" + ) + + valid_email?(email) -> + invitation = %Teams.Invitation{email: email, role: role} + + socket + |> update_layout(email, invitation, queued_op: :send) + |> assign(input_email: "") + |> put_live_flash( + :success, + "Invitation pending. Will be sent once you save changes" + ) + + true -> + socket + |> assign(input_email: email) + |> put_live_flash( + :error, + "Make sure the e-mail is valid and is not taken already in your team layout" + ) + end + + {:noreply, socket} + end + + def handle_event("save-team-layout", _params, %{assigns: assigns} = socket) do + result = + Repo.transaction(fn -> + assigns.layout + |> Layout.sorted_for_persistence() + |> Enum.reduce([], fn {_, entry}, acc -> + case Layout.Entry.persist(entry, assigns) do + {:ok, :ignore} -> acc + {:ok, persist_result} -> [persist_result | acc] + {:error, error} -> Repo.rollback(error) + end + end) + end) + + socket = + case result do + {:ok, _} -> + socket + |> reset() + |> put_live_flash(:success, "Team layout updated successfully") + + {:error, error} -> + socket + |> put_live_flash(:error, inspect(error)) + end + + # socket = + # case result do + # {:ok, invitations} -> + # Enum.each(invitations, fn invitation -> + # invitee = Plausible.Auth.find_user_by(email: invitation.email) + # Teams.Invitations.InviteToTeam.send_invitation_email(invitation, invitee) + # end) + # + # {:error, :already_a_member} -> + # socket + # |> put_live_flash( + # :error, + # "Can't invite e-mails that belong to team members already" + # ) + # + # {:error, {:over_limit, limit}} = _ -> + # socket + # |> put_live_flash( + # :error, + # "Your account is limited to #{limit} team members. You can upgrade your plan to increase this limit" + # ) + # end + # + {:noreply, socket} + end + + def handle_event("remove-member", %{"email" => email}, %{assigns: %{layout: layout}} = socket) do + socket = + with :ok <- Layout.verify_removable(layout, email) do + socket + |> update_layout(email, nil, queued_op: :delete) + |> put_live_flash( + :success, + "Team layout change will be effective once you save your changes" + ) + else + {:error, message} -> + socket + |> put_live_flash( + :error, + message + ) + end + + {:noreply, socket} + end + + def handle_event("update-role", %{"email" => email, "role" => role}, socket) do + socket = + update_layout(socket, email, nil, + role: Map.fetch!(@roles_cast_map, role), + queued_op: :update + ) + + {:noreply, socket} + end + + defp valid_email?(email) do + String.contains?(email, "@") and String.contains?(email, ".") + end + + defp update_layout(%{assigns: %{layout: layout}} = socket, email, object, attrs) do + case object do + nil -> + entry = Map.fetch!(layout, email) + + assign(socket, + layout: Layout.put(layout, email, Layout.Entry.patch(entry, attrs)), + team_layout_changed?: true + ) + + _ -> + assign(socket, + layout: Layout.put(layout, email, Layout.Entry.new(object, attrs)), + team_layout_changed?: true + ) + end + end +end diff --git a/lib/plausible_web/live/team_management/layout.ex b/lib/plausible_web/live/team_management/layout.ex new file mode 100644 index 000000000000..f498758138ec --- /dev/null +++ b/lib/plausible_web/live/team_management/layout.ex @@ -0,0 +1,74 @@ +defmodule PlausibleWeb.Live.TeamManagement.Layout do + alias Plausible.Teams + + alias PlausibleWeb.Live.TeamManagement.Layout.Entry + + def build_by_email(current_user, entities) do + Enum.reduce(entities, %{}, fn + %Teams.Invitation{id: existing} = invitation, acc when is_integer(existing) -> + Map.put(acc, invitation.email, Entry.new(invitation)) + + %Teams.Invitation{id: nil} = pending, acc -> + Map.put(acc, pending.email, Entry.new(pending)) + + %Teams.Membership{} = membership, acc -> + Map.put( + acc, + membership.user.email, + Entry.new(membership, + label: if(current_user.id == membership.user.id, do: "You", else: "Team Member") + ) + ) + end) + end + + def put(layout, email, entry) do + Map.put(layout, email, entry) + end + + def verify_removable(layout, email) do + with :ok <- ensure_at_least_one_owner(layout, email) do + :ok + end + end + + def removable?(layout, email) do + verify_removable(layout, email) == :ok + end + + def sorted_for_display(layout) do + Enum.sort_by(layout, fn {email, entry} -> + primary_criterion = + case entry.type do + :invitation_pending -> 0 + :invitation_sent -> 1 + :membership -> 2 + end + + secondary_criterion = String.first(entry.name) + tertiary_criterion = String.first(email) + {primary_criterion, secondary_criterion, tertiary_criterion} + end) + end + + def sorted_for_persistence(layout) do + # sort by deletions first, so team member limits are triggered accurately + Enum.sort_by(layout, fn {_email, entry} -> + case entry.queued_op do + :delete -> 0 + _ -> 1 + end + end) + end + + defp ensure_at_least_one_owner(layout, email) do + if Enum.find(layout, fn {_email, entry} -> + entry.email != email and + entry.role == :owner and + entry.type == :membership and + entry.queued_op != :delete + end), + do: :ok, + else: {:error, "The team has to have at least one owner"} + end +end diff --git a/lib/plausible_web/live/team_management/layout/entry.ex b/lib/plausible_web/live/team_management/layout/entry.ex new file mode 100644 index 000000000000..64a48b4bb8b1 --- /dev/null +++ b/lib/plausible_web/live/team_management/layout/entry.ex @@ -0,0 +1,111 @@ +defmodule PlausibleWeb.Live.TeamManagement.Layout.Entry do + alias Plausible.Teams + + defstruct [:email, :name, :role, :type, :label, :meta, :queued_op] + + def new(object, attrs \\ []) + + def new( + %Teams.Invitation{id: existing} = invitation, + attrs + ) + when is_integer(existing) do + %__MODULE__{ + name: "Invited User", + email: invitation.email, + role: invitation.role, + type: :invitation_sent, + label: "Invitation Sent", + meta: invitation + } + |> Map.merge(Enum.into(attrs, %{})) + end + + def new(%Teams.Invitation{id: nil} = pending, attrs) do + %__MODULE__{ + name: "Invited User", + email: pending.email, + role: pending.role, + type: :invitation_pending, + label: "Invitation Pending", + meta: pending + } + |> Map.merge(Enum.into(attrs, %{})) + end + + def new(%Teams.Membership{} = membership, attrs) do + %__MODULE__{ + name: membership.user.name, + role: membership.role, + email: membership.user.email, + type: :membership, + meta: membership + } + |> Map.merge(Enum.into(attrs, %{})) + end + + def patch(%__MODULE__{} = entry, attrs) do + struct!(entry, attrs) + end + + def persist(%__MODULE__{queued_op: nil}, _context) do + {:ok, :ignore} + end + + def persist(%__MODULE__{type: :invitation_pending, queued_op: :delete}, _context) do + {:ok, :ignore} + end + + def persist( + %__MODULE__{email: email, role: role, type: :invitation_pending, queued_op: :send}, + context + ) do + Teams.Invitations.InviteToTeam.invite(context.my_team, context.current_user, email, role, + send_email?: false + ) + end + + def persist( + %__MODULE__{type: :invitation_sent, email: email, role: role, queued_op: :update}, + context + ) do + Teams.Invitations.InviteToTeam.invite(context.my_team, context.current_user, email, role, + send_email?: false + ) + end + + def persist( + %__MODULE__{type: :invitation_sent, queued_op: :delete, meta: meta}, + context + ) do + Plausible.Teams.Invitations.Remove.remove( + context.my_team, + meta.invitation_id, + context.current_user + ) + end + + def persist( + %__MODULE__{type: :membership, queued_op: :delete, meta: meta}, + context + ) do + Plausible.Teams.Memberships.Remove.remove( + context.my_team, + meta.user.id, + context.current_user, + send_email?: false + ) + end + + def persist( + %__MODULE__{type: :membership, queued_op: :update, role: role, meta: meta}, + context + ) do + Plausible.Teams.Memberships.UpdateRole.update( + context.my_team, + meta.user.id, + "#{role}", + context.current_user + ) + end +end diff --git a/lib/plausible_web/live/team_setup.ex b/lib/plausible_web/live/team_setup.ex index a33a43ea3c0d..f4fde23a1cad 100644 --- a/lib/plausible_web/live/team_setup.ex +++ b/lib/plausible_web/live/team_setup.ex @@ -13,10 +13,16 @@ defmodule PlausibleWeb.Live.TeamSetup do alias PlausibleWeb.Router.Helpers, as: Routes + import PlausibleWeb.Live.Components.Team + def mount(params, _session, socket) do my_team = socket.assigns.my_team enabled? = Teams.enabled?(my_team) + {:ok, my_role} = Teams.Memberships.team_role(my_team, socket.assigns.current_user) + true = my_role in [:owner, :admin] + socket = assign(socket, my_role: my_role) + # XXX: remove dev param, once manual testing is considered done socket = case {enabled?, my_team, params["dev"]} do @@ -51,10 +57,6 @@ defmodule PlausibleWeb.Live.TeamSetup do {:ok, socket} end - def handle_params(_params, _uri, socket) do - {:noreply, socket} - end - def render(assigns) do ~H""" <.flash_messages flash={@flash} /> @@ -101,10 +103,10 @@ defmodule PlausibleWeb.Live.TeamSetup do - <.member user={@current_user} role={:owner} you?={true} /> + <.member user={@current_user} role={@my_role} disabled={true} label="You" my_role={@my_role} /> <%= for {{email, name}, role} <- @candidates_selected do %> - <.member user={%User{email: email, name: name}} role={role} /> + <.member user={%User{email: email, name: name}} role={role} my_role={@my_role} /> <% end %> <:footer> @@ -116,98 +118,6 @@ defmodule PlausibleWeb.Live.TeamSetup do """ end - attr :user, User, required: true - attr :you?, :boolean, default: false - attr :role, :atom, default: nil - - def member(assigns) do - ~H""" -
-
- - - {@user.name} - - You - - -
{@user.email} -
-
- <.dropdown class="relative"> - <:button class="role bg-transparent text-gray-800 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 focus-visible:outline-gray-100 whitespace-nowrap truncate inline-flex items-center gap-x-2 font-medium rounded-md px-3.5 py-2.5 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700"> - {@role |> to_string() |> String.capitalize()} - - - <:menu class="dropdown-items max-w-60"> - <.dropdown_item - href="#" - disabled={@you? or @role == :admin} - phx-click="update-role" - phx-value-role={:admin} - phx-value-email={@user.email} - phx-value-name={@user.name} - > -
Admin
-
- Manage all team settings -
- - - <.dropdown_item - href="#" - disabled={@you? or @role == :editor} - phx-click="update-role" - phx-value-role={:editor} - phx-value-email={@user.email} - phx-value-name={@user.name} - > -
Editor
-
- Create and view new sites -
- - - <.dropdown_item - href="#" - disabled={@you? or @role == :viewer} - phx-click="update-role" - phx-value-role={:viewer} - phx-value-email={@user.email} - phx-value-name={@user.name} - > -
Viewer
-
- Can only view all sites under your team -
- - - <.dropdown_divider /> - <.dropdown_item - href="#" - disabled={@you?} - phx-click="remove-member" - phx-value-email={@user.email} - phx-value-name={@user.name} - > -
- Remove member -
-
- Remove member from your team -
- - - -
-
-
- """ - end - def handle_info( {:candidate_selected, %{email: email, role: role}}, %{assigns: %{my_team: team, candidates_selected: candidates, current_user: current_user}} = diff --git a/lib/plausible_web/templates/settings/team_general.html.heex b/lib/plausible_web/templates/settings/team_general.html.heex index bbd038d66b1b..2deb037a39ee 100644 --- a/lib/plausible_web/templates/settings/team_general.html.heex +++ b/lib/plausible_web/templates/settings/team_general.html.heex @@ -19,4 +19,16 @@ + <.tile> + <:title> + Team Members + + <:subtitle> + Add, remove or change your team memberships. Take your time, changes apply on save. + + {live_render(@conn, PlausibleWeb.Live.TeamManagement, + id: "team-setup", + session: %{} + )} +