defmodule PlausibleWeb.Live.TeamManagement do @moduledoc """ Live view for enqueuing and applying team membership adjustments. """ use PlausibleWeb, :live_view alias Plausible.Teams alias Plausible.Auth.User import PlausibleWeb.Live.Components.Team alias Plausible.Teams.Management.Layout def mount(_params, session, socket) do mode = if session["mode"] == "team-setup" do :team_setup else :team_management end {:ok, socket |> assign(mode: mode) |> reset()} end defp reset(%{assigns: %{current_user: current_user, current_team: current_team}} = socket) do {:ok, my_role} = Teams.Memberships.team_role(current_team, current_user) layout = Layout.init(current_team) team_members_limit = Plausible.Teams.Billing.team_member_limit(current_team) assign(socket, team_members_limit: team_members_limit, layout: layout, my_role: my_role, team_layout_changed?: false, input_role: :viewer, input_email: "" ) end def render(assigns) do ~H""" <.flash_messages flash={@flash} />
<.form id="team-layout-form" for={} phx-submit="input-invitation" phx-change="form-changed">
<.input name="input-email" type="email" value={@input_email} placeholder="Enter e-mail" phx-debounce={200} readonly={at_limit?(@layout, @team_members_limit) or @my_role not in [:admin, :owner]} mt?={false} />
<.dropdown id="input-role-picker"> <:button class="role inline-flex items-center gap-x-2 font-medium rounded-md px-3 py-2 text-sm border border-gray-300 dark:border-gray-750 rounded-md text-gray-800 dark:text-gray-100 dark:bg-gray-750 dark:hover:bg-gray-700 focus-visible:outline-gray-100 whitespace-nowrap truncate shadow-xs hover:shadow-sm transition-all duration-150 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 id="invite-member" type="submit" mt?={false} disabled={at_limit?(@layout, @team_members_limit) or @my_role not in [:admin, :owner]} > Invite
<.member :for={{email, entry} <- Layout.sorted_for_display(@layout)} :if={entry.role != :guest} user={%User{email: entry.email, name: entry.name}} role={entry.role} label={entry_label(entry, @current_user)} my_role={@my_role} remove_disabled={not Layout.removable?(@layout, email)} disabled={ (entry.role == :owner && Layout.owners_count(@layout) == 1) or @my_role not in [:owner, :admin] } />

Guests
<.member :for={{email, entry} <- Layout.sorted_for_display(@layout)} :if={entry.role == :guest} user={%User{email: entry.email, name: entry.name}} role={entry.role} label={entry_label(entry, @current_user)} my_role={@my_role} remove_disabled={not Layout.removable?(@layout, email)} disabled={@my_role not in [:owner, :admin]} />
<.button :if={@mode == :team_setup} id="save-layout" type="submit" phx-click="save-team-layout" class="mt-8 w-full" > Create Team
""" 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 = Map.get(layout, email) 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(Layout.update_role(layout, email, 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) -> socket |> update_layout(Layout.schedule_send(layout, email, role)) |> assign(input_email: "") 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, socket ) do socket = save_team_layout(socket) {:noreply, socket} end def handle_event("remove-member", %{"email" => email}, %{assigns: %{layout: layout}} = socket) do socket = case Layout.verify_removable(layout, email) do :ok -> update_layout(socket, Layout.schedule_delete(layout, email)) {:error, message} -> socket |> put_live_flash( :error, message ) end {:noreply, socket} end def handle_event( "update-role", %{"email" => email, "role" => role}, %{assigns: %{layout: layout}} = socket ) do socket = update_layout(socket, Layout.update_role(layout, email, Map.fetch!(@roles_cast_map, role))) |> push_event("js-exec", %{ to: "#member-row-#{:erlang.phash2(email)}", attr: "data-role-changed" }) {:noreply, socket} end defp valid_email?(email) do String.contains?(email, "@") and String.contains?(email, ".") end defp update_layout(socket, layout) do socket = assign(socket, layout: layout, team_layout_changed?: true ) if socket.assigns.mode == :team_management do save_team_layout(socket) else socket end end defp save_team_layout( %{assigns: %{layout: layout, current_team: current_team, current_user: current_user}} = socket ) do result = Layout.persist(layout, %{ current_user: current_user, current_team: Plausible.Repo.reload!(current_team) }) case {result, socket.assigns.mode} do {{:ok, _}, :team_setup} -> socket |> put_flash(:success, "Your team is now created") |> redirect( to: Routes.settings_path(socket, :team_general, __team: current_team.identifier) ) {{:ok, _}, :team_management} -> reset(socket) {{:error, :permission_denied}, _} -> socket |> put_live_flash( :error, "Permission denied" ) {{:error, :only_one_owner}, _} -> socket |> put_live_flash( :error, "The team has to have at least one owner" ) {{:error, :disabled_2fa}, _} -> socket |> put_live_flash( :error, "User must have 2FA enabled to become an owner" ) {{: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 end defp entry_label(%Layout.Entry{role: :guest, type: :membership}, _), do: nil defp entry_label(%Layout.Entry{type: :invitation_pending}, _), do: "Invitation pending" defp entry_label(%Layout.Entry{type: :invitation_sent}, _), do: "Invitation sent" defp entry_label(%Layout.Entry{meta: %{user: %{id: id, type: :sso}}}, %{id: id}), do: "You (SSO)" defp entry_label(%Layout.Entry{meta: %{user: %{id: id}}}, %{id: id}), do: "You" defp entry_label(%Layout.Entry{meta: %{user: %{type: :sso}}}, _), do: "SSO" defp entry_label(_, _), do: nil def at_limit?(layout, limit) do not Plausible.Billing.Quota.below_limit?( Layout.active_count(layout) - 1, limit ) end end