From 7f150acd18e4003303232ac0bd9d8ed0d051a3fc Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 21 Jan 2025 12:53:55 +0100 Subject: [PATCH 1/9] Team management wip --- lib/plausible/teams/memberships.ex | 11 +++ lib/plausible_web/components/generic.ex | 2 +- .../controllers/settings_controller.ex | 3 +- lib/plausible_web/live/components/team.ex | 97 ++++++++++++++++++ lib/plausible_web/live/team_management.ex | 88 +++++++++++++++++ lib/plausible_web/live/team_setup.ex | 98 +------------------ .../templates/settings/team_general.html.heex | 12 +++ 7 files changed, 213 insertions(+), 98 deletions(-) create mode 100644 lib/plausible_web/live/components/team.ex create mode 100644 lib/plausible_web/live/team_management.ex diff --git a/lib/plausible/teams/memberships.ex b/lib/plausible/teams/memberships.ex index 41d4c5fee670..287cfd71c39c 100644 --- a/lib/plausible/teams/memberships.ex +++ b/lib/plausible/teams/memberships.ex @@ -7,6 +7,17 @@ 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, + 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_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..d2b8549bfe0f --- /dev/null +++ b/lib/plausible_web/live/components/team.ex @@ -0,0 +1,97 @@ +defmodule PlausibleWeb.Live.Components.Team do + use Phoenix.Component + import PlausibleWeb.Components.Generic + alias Plausible.Auth.User + + 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 +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..b1a07a941d8b --- /dev/null +++ b/lib/plausible_web/live/team_management.ex @@ -0,0 +1,88 @@ +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 + + def mount(_params, _session, socket) do + my_team = socket.assigns.my_team + + all_members = Teams.Memberships.all_members(my_team) + + socket = + assign(socket, + all_members: all_members, + team_state_changed?: false + ) + + {:ok, socket} + end + + def render(assigns) do + ~H""" +
+
+
+ <.input + name="invitee" + type="email" + value="" + 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"> + Admin + + <:menu class="dropdown-items max-w-60"> + <.dropdown_item href="#" phx-click="update-role" phx-value-role={:admin}> +
Admin
+
+ Manage all team settings +
+ + + + + <.button mt?={false}> + Invite + +
+ <.member + :for={team_membership <- @all_members} + user={team_membership.user} + role={team_membership.role} + you?={@current_user.id == team_membership.user.id} + /> +
+ + <.button phx-click="save-changes" type="submit" disabled={not @team_state_changed?}> + Save changes + + """ + end + + def handle_event("update-role", %{"name" => name, "email" => email, "role" => role}, socket) do + IO.inspect(binding()) + raise "stop" + end + + # defp reject_already_selected(candidates, candidates_selected) do + # candidates + # |> Enum.reject(fn {email, _} -> + # Enum.find(candidates_selected, fn + # {{^email, _}, _} -> true + # _ -> false + # end) + # end) + # end +end diff --git a/lib/plausible_web/live/team_setup.ex b/lib/plausible_web/live/team_setup.ex index a33a43ea3c0d..e026cf364b71 100644 --- a/lib/plausible_web/live/team_setup.ex +++ b/lib/plausible_web/live/team_setup.ex @@ -13,6 +13,8 @@ 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) @@ -51,10 +53,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} /> @@ -116,98 +114,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..12e08265fc50 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 + + {live_render(@conn, PlausibleWeb.Live.TeamManagement, + id: "team-setup", + session: %{} + )} + From c445562d540b9049aa1a7223346bab4c6937043e Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 21 Jan 2025 17:53:22 +0100 Subject: [PATCH 2/9] wip --- lib/plausible/teams/invitations.ex | 12 +- lib/plausible/teams/memberships.ex | 1 + lib/plausible_web/live/components/team.ex | 15 +- lib/plausible_web/live/team_management.ex | 190 ++++++++++++++++++---- lib/plausible_web/live/team_setup.ex | 2 +- 5 files changed, 177 insertions(+), 43 deletions(-) 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 287cfd71c39c..e09497e7ec37 100644 --- a/lib/plausible/teams/memberships.ex +++ b/lib/plausible/teams/memberships.ex @@ -12,6 +12,7 @@ defmodule Plausible.Teams.Memberships do 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] diff --git a/lib/plausible_web/live/components/team.ex b/lib/plausible_web/live/components/team.ex index d2b8549bfe0f..b5719078fb53 100644 --- a/lib/plausible_web/live/components/team.ex +++ b/lib/plausible_web/live/components/team.ex @@ -4,8 +4,9 @@ defmodule PlausibleWeb.Live.Components.Team do alias Plausible.Auth.User attr :user, User, required: true - attr :you?, :boolean, default: false + attr :label, :string, default: nil attr :role, :atom, default: nil + attr :disabled, :boolean, default: false def member(assigns) do ~H""" @@ -15,10 +16,10 @@ defmodule PlausibleWeb.Live.Components.Team do {@user.name} - You + {@label}
{@user.email} @@ -32,7 +33,7 @@ defmodule PlausibleWeb.Live.Components.Team do <:menu class="dropdown-items max-w-60"> <.dropdown_item href="#" - disabled={@you? or @role == :admin} + disabled={@disabled or @role == :admin} phx-click="update-role" phx-value-role={:admin} phx-value-email={@user.email} @@ -46,7 +47,7 @@ defmodule PlausibleWeb.Live.Components.Team do <.dropdown_item href="#" - disabled={@you? or @role == :editor} + disabled={@disabled or @role == :editor} phx-click="update-role" phx-value-role={:editor} phx-value-email={@user.email} @@ -60,7 +61,7 @@ defmodule PlausibleWeb.Live.Components.Team do <.dropdown_item href="#" - disabled={@you? or @role == :viewer} + disabled={@disabled or @role == :viewer} phx-click="update-role" phx-value-role={:viewer} phx-value-email={@user.email} @@ -75,7 +76,7 @@ defmodule PlausibleWeb.Live.Components.Team do <.dropdown_divider /> <.dropdown_item href="#" - disabled={@you?} + disabled={@disabled} phx-click="remove-member" phx-value-email={@user.email} phx-value-name={@user.name} diff --git a/lib/plausible_web/live/team_management.ex b/lib/plausible_web/live/team_management.ex index b1a07a941d8b..b5991254b9e2 100644 --- a/lib/plausible_web/live/team_management.ex +++ b/lib/plausible_web/live/team_management.ex @@ -14,11 +14,15 @@ defmodule PlausibleWeb.Live.TeamManagement do my_team = socket.assigns.my_team all_members = Teams.Memberships.all_members(my_team) + invitations_pending = [] socket = assign(socket, all_members: all_members, - team_state_changed?: false + invitations_pending: invitations_pending, + invitations_sent: Plausible.Teams.Invitations.find_team_invitations(my_team), + selected_invitation_role: :admin, + team_layout_changed?: false ) {:ok, socket} @@ -26,56 +30,174 @@ defmodule PlausibleWeb.Live.TeamManagement do def render(assigns) do ~H""" + <.flash_messages flash={@flash} />
-
-
- <.input - name="invitee" - type="email" - value="" - placeholder="Enter e-mail to send invitation to" - phx-debounce={200} - mt?={false} - /> + <.form for={} phx-submit="queue-invitation"> +
+
+ <.input + name="invitee" + type="email" + value="" + 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"> + Admin + + <:menu class="dropdown-items max-w-60"> + <.dropdown_item href="#" phx-click="update-role" phx-value-role={:admin}> +
Admin
+
+ Manage all team settings +
+ + + + + <.button type="submit" mt?={false}> + Invite +
+ + + <.member + :for={invitation <- @invitations_pending} + user={%User{email: invitation.email, name: "Invited User"}} + role={invitation.role} + label="Invitation Pending" + /> + + <.member + :for={invitation <- @invitations_sent} + user={%User{email: invitation.email, name: "Invited User"}} + role={invitation.role} + label="Invitation Sent" + /> - <.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"> - Admin - - <:menu class="dropdown-items max-w-60"> - <.dropdown_item href="#" phx-click="update-role" phx-value-role={:admin}> -
Admin
-
- Manage all team settings -
- - - - - <.button mt?={false}> - Invite - -
<.member :for={team_membership <- @all_members} user={team_membership.user} role={team_membership.role} - you?={@current_user.id == team_membership.user.id} + label={if @current_user.id == team_membership.user.id, do: "You"} /> -
- <.button phx-click="save-changes" type="submit" disabled={not @team_state_changed?}> - Save changes - + <.button type="submit" phx-click="save-team-layout" disabled={not @team_layout_changed?}> + Save changes + +
""" end + def handle_event("queue-invitation", %{"invitee" => email} = params, socket) do + socket = + if valid_email?(email) do + assign(socket, + invitations_pending: [ + %{email: email, role: :admin} | socket.assigns.invitations_pending + ], + team_layout_changed?: true + ) + else + socket + end + + {:noreply, socket} + end + + def handle_event("save-team-layout", _params, socket) do + inviter = socket.assigns.current_user + team = socket.assigns.my_team + + invite_fn = fn email, role -> + case Teams.Invitations.InviteToTeam.invite(team, inviter, email, role, send_email?: false) do + {:ok, invitation} -> invitation + {:error, error} -> Repo.rollback(error) + end + end + + result = + Repo.transaction(fn -> + Enum.map(socket.assigns.invitations_pending, fn %{email: email, role: role} -> + invite_fn.(email, role) + end) + 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) + + socket + |> put_live_flash(:success, "Invitations sent successfully") + |> assign(team_layout_changed?: false) + + # XXX: refresh here + + {: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}, socket) do + invitations_pending = Enum.reject(socket.assigns.invitations_pending, &(&1.email == email)) + invitation_to_delete = Enum.find(socket.assigns.invitations_sent, &(&1.email == email)) + + invitations_sent = + if invitation_to_delete do + Repo.delete!(invitation_to_delete) + Plausible.Teams.Invitations.find_team_invitations(socket.assigns.my_team) + else + socket.assigns.invitations_sent + end + + member_to_remove = Enum.find(socket.assigns.all_members, &(&1.user.email == email)) + + all_members = + if member_to_remove do + Plausible.Teams.Memberships.Remove.remove( + socket.assigns.my_team, + member_to_remove.id, + socket.assigns.current_user + ) + + Teams.Memberships.all_members(socket.assigns.my_team) + else + socket.assigns.all_members + end + + socket = + assign(socket, + all_members: all_members, + invitations_sent: invitations_sent, + invitations_pending: invitations_pending + ) + + {:noreply, socket} + end + def handle_event("update-role", %{"name" => name, "email" => email, "role" => role}, socket) do IO.inspect(binding()) raise "stop" end + defp valid_email?(email) do + String.contains?(email, "@") and String.contains?(email, ".") + end + # defp reject_already_selected(candidates, candidates_selected) do # candidates # |> Enum.reject(fn {email, _} -> diff --git a/lib/plausible_web/live/team_setup.ex b/lib/plausible_web/live/team_setup.ex index e026cf364b71..1a7b06077e10 100644 --- a/lib/plausible_web/live/team_setup.ex +++ b/lib/plausible_web/live/team_setup.ex @@ -99,7 +99,7 @@ defmodule PlausibleWeb.Live.TeamSetup do - <.member user={@current_user} role={:owner} you?={true} /> + <.member user={@current_user} role={:owner} disabled={true} label="You" /> <%= for {{email, name}, role} <- @candidates_selected do %> <.member user={%User{email: email, name: name}} role={role} /> From 75ffc87f9e779f44e1af2db608cbbad313df2ed1 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Wed, 22 Jan 2025 09:53:11 +0100 Subject: [PATCH 3/9] wip --- lib/plausible/teams/memberships/remove.ex | 9 ++- lib/plausible_web/live/team_management.ex | 81 ++++++++++++++++------- 2 files changed, 64 insertions(+), 26 deletions(-) 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/live/team_management.ex b/lib/plausible_web/live/team_management.ex index b5991254b9e2..9b61bfb8cd08 100644 --- a/lib/plausible_web/live/team_management.ex +++ b/lib/plausible_web/live/team_management.ex @@ -15,6 +15,8 @@ defmodule PlausibleWeb.Live.TeamManagement do all_members = Teams.Memberships.all_members(my_team) invitations_pending = [] + invitations_to_delete = [] + memberships_to_delete = [] socket = assign(socket, @@ -22,7 +24,9 @@ defmodule PlausibleWeb.Live.TeamManagement do invitations_pending: invitations_pending, invitations_sent: Plausible.Teams.Invitations.find_team_invitations(my_team), selected_invitation_role: :admin, - team_layout_changed?: false + team_layout_changed?: false, + invitations_to_delete: invitations_to_delete, + memberships_to_delete: memberships_to_delete ) {:ok, socket} @@ -122,6 +126,31 @@ defmodule PlausibleWeb.Live.TeamManagement do result = Repo.transaction(fn -> + Enum.each(socket.assigns.invitations_to_delete, fn invitation -> + Plausible.Teams.Invitations.Remove.remove( + socket.assigns.my_team, + invitation.invitation_id, + socket.assigns.current_user + ) + end) + + memberships_removed = + Enum.map(socket.assigns.memberships_to_delete, fn membership -> + {:ok, membership} = + Plausible.Teams.Memberships.Remove.remove( + socket.assigns.my_team, + membership.id, + socket.assigns.current_user, + send_email?: false + ) + + membership + end) + + Enum.each(memberships_removed, fn team_membership -> + Plausible.Teams.Memberships.Remove.send_team_member_removed_email(team_membership) + end) + Enum.map(socket.assigns.invitations_pending, fn %{email: email, role: role} -> invite_fn.(email, role) end) @@ -137,7 +166,16 @@ defmodule PlausibleWeb.Live.TeamManagement do socket |> put_live_flash(:success, "Invitations sent successfully") - |> assign(team_layout_changed?: false) + |> assign( + team_layout_changed?: false, + all_members: Teams.Memberships.all_members(socket.assigns.my_team), + invitations_sent: + Plausible.Teams.Invitations.find_team_invitations(socket.assigns.my_team), + invitations_to_delete: [], + memberships_to_delete: [], + invitations_pending: [], + team_layout_changed?: false + ) # XXX: refresh here @@ -154,38 +192,35 @@ defmodule PlausibleWeb.Live.TeamManagement do def handle_event("remove-member", %{"email" => email}, socket) do invitations_pending = Enum.reject(socket.assigns.invitations_pending, &(&1.email == email)) + + socket = assign(socket, invitations_pending: invitations_pending) + invitation_to_delete = Enum.find(socket.assigns.invitations_sent, &(&1.email == email)) - invitations_sent = + socket = if invitation_to_delete do - Repo.delete!(invitation_to_delete) - Plausible.Teams.Invitations.find_team_invitations(socket.assigns.my_team) + assign(socket, + invitations_to_delete: [invitation_to_delete | socket.assigns.invitations_to_delete], + invitations_sent: Enum.reject(socket.assigns.invitations_sent, &(&1.email == email)), + team_layout_changed?: true + ) else - socket.assigns.invitations_sent + socket end - member_to_remove = Enum.find(socket.assigns.all_members, &(&1.user.email == email)) + member_to_delete = Enum.find(socket.assigns.all_members, &(&1.user.email == email)) - all_members = - if member_to_remove do - Plausible.Teams.Memberships.Remove.remove( - socket.assigns.my_team, - member_to_remove.id, - socket.assigns.current_user + socket = + if member_to_delete do + assign(socket, + memberships_to_delete: [member_to_delete | socket.assigns.memberships_to_delete], + all_members: Enum.reject(socket.assigns.all_members, &(&1.user.email == email)), + team_layout_changed?: true ) - - Teams.Memberships.all_members(socket.assigns.my_team) else - socket.assigns.all_members + socket end - socket = - assign(socket, - all_members: all_members, - invitations_sent: invitations_sent, - invitations_pending: invitations_pending - ) - {:noreply, socket} end From c386da41b7638cf9f2ef7e83837b93f94fefa0ff Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Wed, 22 Jan 2025 10:23:24 +0100 Subject: [PATCH 4/9] wip --- lib/plausible_web/live/team_management.ex | 127 +++++++++++++--------- 1 file changed, 76 insertions(+), 51 deletions(-) diff --git a/lib/plausible_web/live/team_management.ex b/lib/plausible_web/live/team_management.ex index 9b61bfb8cd08..7c7bb64c6766 100644 --- a/lib/plausible_web/live/team_management.ex +++ b/lib/plausible_web/live/team_management.ex @@ -11,25 +11,7 @@ defmodule PlausibleWeb.Live.TeamManagement do alias PlausibleWeb.Router.Helpers, as: Routes def mount(_params, _session, socket) do - my_team = socket.assigns.my_team - - all_members = Teams.Memberships.all_members(my_team) - invitations_pending = [] - invitations_to_delete = [] - memberships_to_delete = [] - - socket = - assign(socket, - all_members: all_members, - invitations_pending: invitations_pending, - invitations_sent: Plausible.Teams.Invitations.find_team_invitations(my_team), - selected_invitation_role: :admin, - team_layout_changed?: false, - invitations_to_delete: invitations_to_delete, - memberships_to_delete: memberships_to_delete - ) - - {:ok, socket} + {:ok, reset(socket)} end def render(assigns) do @@ -98,16 +80,28 @@ defmodule PlausibleWeb.Live.TeamManagement do end def handle_event("queue-invitation", %{"invitee" => email} = params, socket) do + invitations_pending = socket.assigns.invitations_pending + email = String.trim(email) + socket = - if valid_email?(email) do - assign(socket, + if valid_email?(email) and not already_in?(socket, email) do + socket + |> assign( invitations_pending: [ - %{email: email, role: :admin} | socket.assigns.invitations_pending + %{email: email, role: :admin} | invitations_pending ], team_layout_changed?: true ) + |> put_live_flash( + :success, + "Invitation pending. Will be sent once you save changes." + ) else socket + |> put_live_flash( + :error, + "Make sure the e-mail is valid and is not taken already in your team layout" + ) end {:noreply, socket} @@ -115,10 +109,13 @@ defmodule PlausibleWeb.Live.TeamManagement do def handle_event("save-team-layout", _params, socket) do inviter = socket.assigns.current_user - team = socket.assigns.my_team + my_team = socket.assigns.my_team + current_user = socket.assigns.current_user invite_fn = fn email, role -> - case Teams.Invitations.InviteToTeam.invite(team, inviter, email, role, send_email?: false) do + case Teams.Invitations.InviteToTeam.invite(my_team, inviter, email, role, + send_email?: false + ) do {:ok, invitation} -> invitation {:error, error} -> Repo.rollback(error) end @@ -128,9 +125,9 @@ defmodule PlausibleWeb.Live.TeamManagement do Repo.transaction(fn -> Enum.each(socket.assigns.invitations_to_delete, fn invitation -> Plausible.Teams.Invitations.Remove.remove( - socket.assigns.my_team, + my_team, invitation.invitation_id, - socket.assigns.current_user + current_user ) end) @@ -138,9 +135,9 @@ defmodule PlausibleWeb.Live.TeamManagement do Enum.map(socket.assigns.memberships_to_delete, fn membership -> {:ok, membership} = Plausible.Teams.Memberships.Remove.remove( - socket.assigns.my_team, - membership.id, - socket.assigns.current_user, + my_team, + membership.user.id, + current_user, send_email?: false ) @@ -165,19 +162,16 @@ defmodule PlausibleWeb.Live.TeamManagement do end) socket - |> put_live_flash(:success, "Invitations sent successfully") - |> assign( - team_layout_changed?: false, - all_members: Teams.Memberships.all_members(socket.assigns.my_team), - invitations_sent: - Plausible.Teams.Invitations.find_team_invitations(socket.assigns.my_team), - invitations_to_delete: [], - memberships_to_delete: [], - invitations_pending: [], - team_layout_changed?: false - ) + # TODO: build descriptive message + |> put_live_flash(:success, "Team layout updated successfully.") + |> reset() - # XXX: refresh here + {: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 @@ -208,7 +202,9 @@ defmodule PlausibleWeb.Live.TeamManagement do socket end - member_to_delete = Enum.find(socket.assigns.all_members, &(&1.user.email == email)) + member_to_delete = + Enum.find(socket.assigns.all_members, &(&1.user.email == email)) + |> IO.inspect(label: :member_to_delete) socket = if member_to_delete do @@ -221,6 +217,17 @@ defmodule PlausibleWeb.Live.TeamManagement do socket end + socket = + if invitation_to_delete || member_to_delete do + put_live_flash( + socket, + :success, + "Team layout change will be effective once you save your changes." + ) + else + socket + end + {:noreply, socket} end @@ -233,13 +240,31 @@ defmodule PlausibleWeb.Live.TeamManagement do String.contains?(email, "@") and String.contains?(email, ".") end - # defp reject_already_selected(candidates, candidates_selected) do - # candidates - # |> Enum.reject(fn {email, _} -> - # Enum.find(candidates_selected, fn - # {{^email, _}, _} -> true - # _ -> false - # end) - # end) - # end + defp reset(socket) do + my_team = socket.assigns.my_team + + all_members = Teams.Memberships.all_members(my_team) + invitations_sent = Plausible.Teams.Invitations.find_team_invitations(my_team) + invitations_pending = [] + invitations_to_delete = [] + memberships_to_delete = [] + + assign(socket, + all_members: all_members, + invitations_pending: invitations_pending, + invitations_sent: invitations_sent, + selected_invitation_role: :admin, + team_layout_changed?: false, + invitations_to_delete: invitations_to_delete, + memberships_to_delete: memberships_to_delete + ) + end + + defp already_in?(socket, email) do + not is_nil( + Enum.find(socket.assigns.invitations_sent, &(&1.email == email)) || + Enum.find(socket.assigns.all_members, &(&1.user.email == email)) || + Enum.find(socket.assigns.invitations_pending, &(&1.email == email)) + ) + end end From 26f7e489fa6f957369ba4d2d1d977e5d82da6609 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Fri, 24 Jan 2025 15:30:33 +0100 Subject: [PATCH 5/9] wip --- lib/plausible_web/live/components/team.ex | 82 ++++++++----- lib/plausible_web/live/team_management.ex | 134 +++++++++++++++++----- lib/plausible_web/live/team_setup.ex | 8 +- 3 files changed, 160 insertions(+), 64 deletions(-) diff --git a/lib/plausible_web/live/components/team.ex b/lib/plausible_web/live/components/team.ex index b5719078fb53..0995a56b7769 100644 --- a/lib/plausible_web/live/components/team.ex +++ b/lib/plausible_web/live/components/team.ex @@ -6,6 +6,7 @@ defmodule PlausibleWeb.Live.Components.Team do 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 def member(assigns) do @@ -19,7 +20,7 @@ defmodule PlausibleWeb.Live.Components.Team do :if={@label} class="ml-1 dark:bg-indigo-600 dark:text-gray-200 bg-gray-100 text-gray-500 text-xs px-1 rounded" > - {@label} + {@label}

{@user.email} @@ -31,48 +32,51 @@ defmodule PlausibleWeb.Live.Components.Team do <:menu class="dropdown-items max-w-60"> - <.dropdown_item - href="#" - disabled={@disabled or @role == :admin} + <.role_item + phx-value-email={@user.email} + phx-value-name={@user.name} + role={:owner} + disabled={@disabled or @role == :owner} phx-click="update-role" - phx-value-role={:admin} + > + 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" > -
Admin
-
- Manage all team settings -
- - - <.dropdown_item - href="#" + 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" - phx-value-role={:editor} + > + Create and view new sites + + <.role_item phx-value-email={@user.email} phx-value-name={@user.name} - > -
Editor
-
- Create and view new sites -
- - - <.dropdown_item - href="#" - disabled={@disabled or @role == :viewer} + role={:billing} + disabled={@disabled or @role == :billing} phx-click="update-role" - phx-value-role={:viewer} + > + 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" > -
Viewer
-
- Can only view all sites under your team -
- - + View all sites under your team + <.dropdown_divider /> <.dropdown_item href="#" @@ -95,4 +99,20 @@ defmodule PlausibleWeb.Live.Components.Team do """ 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 index 7c7bb64c6766..0dbc1f975ae0 100644 --- a/lib/plausible_web/live/team_management.ex +++ b/lib/plausible_web/live/team_management.ex @@ -14,6 +14,36 @@ defmodule PlausibleWeb.Live.TeamManagement do {:ok, reset(socket)} end + defp reset(socket) do + my_team = socket.assigns.my_team + + {:ok, my_role} = Teams.Memberships.team_role(my_team, socket.assigns.current_user) + all_members = Teams.Memberships.all_members(my_team) + invitations_sent = Plausible.Teams.Invitations.find_team_invitations(my_team) + invitations_pending = [] + invitations_to_delete = [] + memberships_to_delete = [] + + invitations_to_update = [] + memberships_to_update = [] + + selected_input_role = :viewer + + assign(socket, + my_role: my_role, + all_members: all_members, + invitations_pending: invitations_pending, + invitations_sent: invitations_sent, + selected_invitation_role: :admin, + team_layout_changed?: false, + invitations_to_delete: invitations_to_delete, + memberships_to_delete: memberships_to_delete, + selected_input_role: selected_input_role, + invitations_to_update: invitations_to_update, + memberships_to_update: memberships_to_update + ) + end + def render(assigns) do ~H""" <.flash_messages flash={@flash} /> @@ -33,15 +63,41 @@ defmodule PlausibleWeb.Live.TeamManagement do <.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"> - Admin + {@selected_input_role |> Atom.to_string() |> String.capitalize()} + <:menu class="dropdown-items max-w-60"> - <.dropdown_item href="#" phx-click="update-role" phx-value-role={:admin}> -
Admin
-
- Manage all team settings -
- + <.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 + @@ -56,6 +112,7 @@ defmodule PlausibleWeb.Live.TeamManagement do user={%User{email: invitation.email, name: "Invited User"}} role={invitation.role} label="Invitation Pending" + my_role={@my_role} /> <.member @@ -63,6 +120,7 @@ defmodule PlausibleWeb.Live.TeamManagement do user={%User{email: invitation.email, name: "Invited User"}} role={invitation.role} label="Invitation Sent" + my_role={@my_role} /> <.member @@ -70,6 +128,7 @@ defmodule PlausibleWeb.Live.TeamManagement do user={team_membership.user} role={team_membership.role} label={if @current_user.id == team_membership.user.id, do: "You"} + my_role={@my_role} /> <.button type="submit" phx-click="save-team-layout" disabled={not @team_layout_changed?}> @@ -79,7 +138,15 @@ defmodule PlausibleWeb.Live.TeamManagement do """ end - def handle_event("queue-invitation", %{"invitee" => email} = params, socket) do + @roles Plausible.Teams.Membership.roles() + @roles_str Enum.map(@roles, &to_string(&1)) + + def handle_event("switch-role", %{"role" => role}, socket) when role in @roles_str do + socket = assign(socket, selected_input_role: String.to_existing_atom(role)) + {:noreply, socket} + end + + def handle_event("queue-invitation", %{"invitee" => email}, socket) do invitations_pending = socket.assigns.invitations_pending email = String.trim(email) @@ -88,7 +155,7 @@ defmodule PlausibleWeb.Live.TeamManagement do socket |> assign( invitations_pending: [ - %{email: email, role: :admin} | invitations_pending + %{email: email, role: socket.assigns.selected_input_role} | invitations_pending ], team_layout_changed?: true ) @@ -232,34 +299,39 @@ defmodule PlausibleWeb.Live.TeamManagement do end def handle_event("update-role", %{"name" => name, "email" => email, "role" => role}, socket) do - IO.inspect(binding()) - raise "stop" + invitations_sent = socket.assigns.invitations_sent + + socket = + cond do + invitation_sent = Enum.find(invitations_sent, &(&1.email == email)) -> + invitations_to_update = socket.assigns.invitations_to_update + altered_invitation = %{invitation_sent | role: role} + + invitations_sent = + Enum.reduce(invitations_sent, [], fn invitation, acc -> + if invitation.email == email do + [altered_invitation | acc] + else + [invitation | acc] + end + end) + |> Enum.reverse() + + assign(socket, + invitations_sent: invitations_sent, + team_layout_changed?: true, + invitations_to_update: [altered_invitation | invitations_to_update] + ) + end + + IO.inspect(socket.assigns.invitations_to_update, label: :to_upd) + {:noreply, socket} end defp valid_email?(email) do String.contains?(email, "@") and String.contains?(email, ".") end - defp reset(socket) do - my_team = socket.assigns.my_team - - all_members = Teams.Memberships.all_members(my_team) - invitations_sent = Plausible.Teams.Invitations.find_team_invitations(my_team) - invitations_pending = [] - invitations_to_delete = [] - memberships_to_delete = [] - - assign(socket, - all_members: all_members, - invitations_pending: invitations_pending, - invitations_sent: invitations_sent, - selected_invitation_role: :admin, - team_layout_changed?: false, - invitations_to_delete: invitations_to_delete, - memberships_to_delete: memberships_to_delete - ) - end - defp already_in?(socket, email) do not is_nil( Enum.find(socket.assigns.invitations_sent, &(&1.email == email)) || diff --git a/lib/plausible_web/live/team_setup.ex b/lib/plausible_web/live/team_setup.ex index 1a7b06077e10..f4fde23a1cad 100644 --- a/lib/plausible_web/live/team_setup.ex +++ b/lib/plausible_web/live/team_setup.ex @@ -19,6 +19,10 @@ defmodule PlausibleWeb.Live.TeamSetup 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 @@ -99,10 +103,10 @@ defmodule PlausibleWeb.Live.TeamSetup do - <.member user={@current_user} role={:owner} disabled={true} label="You" /> + <.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> From b58079b08c61c11fe188475e720e1bf7ecc5cd93 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Sat, 25 Jan 2025 16:44:40 +0100 Subject: [PATCH 6/9] wip --- lib/plausible_web/live/components/team.ex | 8 +- lib/plausible_web/live/team_management.ex | 469 +++++++++++------- .../templates/settings/team_general.html.heex | 2 +- 3 files changed, 284 insertions(+), 195 deletions(-) diff --git a/lib/plausible_web/live/components/team.ex b/lib/plausible_web/live/components/team.ex index 0995a56b7769..2c313736bcf8 100644 --- a/lib/plausible_web/live/components/team.ex +++ b/lib/plausible_web/live/components/team.ex @@ -8,6 +8,7 @@ defmodule PlausibleWeb.Live.Components.Team do 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""" @@ -80,12 +81,15 @@ defmodule PlausibleWeb.Live.Components.Team do <.dropdown_divider /> <.dropdown_item href="#" - disabled={@disabled} + disabled={@disabled or @remove_disabled} phx-click="remove-member" phx-value-email={@user.email} phx-value-name={@user.name} > -
+
Remove member
diff --git a/lib/plausible_web/live/team_management.ex b/lib/plausible_web/live/team_management.ex index 0dbc1f975ae0..759d1ecea3b0 100644 --- a/lib/plausible_web/live/team_management.ex +++ b/lib/plausible_web/live/team_management.ex @@ -14,33 +14,89 @@ defmodule PlausibleWeb.Live.TeamManagement do {:ok, reset(socket)} end - defp reset(socket) do - my_team = socket.assigns.my_team + defmodule LayoutEntry do + defstruct [:email, :name, :role, :type, :label, :meta, :queued_op] + end - {:ok, my_role} = Teams.Memberships.team_role(my_team, socket.assigns.current_user) - all_members = Teams.Memberships.all_members(my_team) - invitations_sent = Plausible.Teams.Invitations.find_team_invitations(my_team) - invitations_pending = [] - invitations_to_delete = [] - memberships_to_delete = [] + def to_entry(object, attrs \\ []) + + def to_entry( + %Teams.Invitation{id: existing} = invitation, + attrs + ) + when is_integer(existing) do + %LayoutEntry{ + name: "Invited User", + email: invitation.email, + role: invitation.role, + type: :invitation_sent, + label: "Invitation Sent", + meta: invitation + } + |> Map.merge(Enum.into(attrs, %{})) + end + + def to_entry(%Teams.Invitation{id: nil} = pending, attrs) do + %LayoutEntry{ + name: "Invited User", + email: pending.email, + role: pending.role, + type: :invitation_pending, + label: "Invitation Pending", + meta: pending + } + |> Map.merge(Enum.into(attrs, %{})) + end + + def to_entry(%Teams.Membership{} = membership, attrs) do + %LayoutEntry{ + name: membership.user.name, + role: membership.role, + email: membership.user.email, + type: :membership, + meta: membership + } + |> Map.merge(Enum.into(attrs, %{})) + end - invitations_to_update = [] - memberships_to_update = [] + def layout_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, to_entry(invitation)) - selected_input_role = :viewer + %Teams.Invitation{id: nil} = pending, acc -> + Map.put(acc, pending.email, to_entry(pending)) + + %Teams.Membership{} = membership, acc -> + Map.put( + acc, + membership.user.email, + to_entry(membership, + label: if(current_user.id == membership.user.id, do: "You", else: "Team Member") + ) + ) + end) + 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_by_email(current_user, invitations_sent ++ all_members) assign(socket, + layout: layout, my_role: my_role, all_members: all_members, - invitations_pending: invitations_pending, + invitations_pending: [], invitations_sent: invitations_sent, - selected_invitation_role: :admin, team_layout_changed?: false, - invitations_to_delete: invitations_to_delete, - memberships_to_delete: memberships_to_delete, - selected_input_role: selected_input_role, - invitations_to_update: invitations_to_update, - memberships_to_update: memberships_to_update + input_role: :viewer, + input_email: "" ) end @@ -48,13 +104,13 @@ defmodule PlausibleWeb.Live.TeamManagement do ~H""" <.flash_messages flash={@flash} />
- <.form for={} phx-submit="queue-invitation"> + <.form for={} phx-submit="input-invitation" phx-change="form-changed">
<.input - name="invitee" + name="input-email" type="email" - value="" + value={@input_email} placeholder="Enter e-mail to send invitation to" phx-debounce={200} mt?={false} @@ -63,7 +119,7 @@ defmodule PlausibleWeb.Live.TeamManagement do <.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"> - {@selected_input_role |> Atom.to_string() |> String.capitalize()} + {@input_role |> Atom.to_string() |> String.capitalize()} <:menu class="dropdown-items max-w-60"> @@ -108,27 +164,13 @@ defmodule PlausibleWeb.Live.TeamManagement do <.member - :for={invitation <- @invitations_pending} - user={%User{email: invitation.email, name: "Invited User"}} - role={invitation.role} - label="Invitation Pending" - my_role={@my_role} - /> - - <.member - :for={invitation <- @invitations_sent} - user={%User{email: invitation.email, name: "Invited User"}} - role={invitation.role} - label="Invitation Sent" - my_role={@my_role} - /> - - <.member - :for={team_membership <- @all_members} - user={team_membership.user} - role={team_membership.role} - label={if @current_user.id == team_membership.user.id, do: "You"} + :for={{email, entry} <- sorted_layout(@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 removable?(@layout, email)} /> <.button type="submit" phx-click="save-team-layout" disabled={not @team_layout_changed?}> @@ -138,205 +180,248 @@ defmodule PlausibleWeb.Live.TeamManagement do """ end - @roles Plausible.Teams.Membership.roles() - @roles_str Enum.map(@roles, &to_string(&1)) + @roles Plausible.Teams.Membership.roles() -- [:guest] + @roles_cast_map Enum.into(@roles, %{}, fn role -> {to_string(role), role} end) - def handle_event("switch-role", %{"role" => role}, socket) when role in @roles_str do - socket = assign(socket, selected_input_role: String.to_existing_atom(role)) - {:noreply, socket} + def handle_event("form-changed", params, socket) do + {:noreply, assign(socket, input_email: params["input-email"])} end - def handle_event("queue-invitation", %{"invitee" => email}, socket) do - invitations_pending = socket.assigns.invitations_pending - email = String.trim(email) - - socket = - if valid_email?(email) and not already_in?(socket, email) do - socket - |> assign( - invitations_pending: [ - %{email: email, role: socket.assigns.selected_input_role} | invitations_pending - ], - team_layout_changed?: true - ) - |> put_live_flash( - :success, - "Invitation pending. Will be sent once you save changes." - ) - else - socket - |> put_live_flash( - :error, - "Make sure the e-mail is valid and is not taken already in your team layout" - ) - 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("save-team-layout", _params, socket) do - inviter = socket.assigns.current_user - my_team = socket.assigns.my_team - current_user = socket.assigns.current_user - - invite_fn = fn email, role -> - case Teams.Invitations.InviteToTeam.invite(my_team, inviter, email, role, - send_email?: false - ) do - {:ok, invitation} -> invitation - {:error, error} -> Repo.rollback(error) - end - end - - result = - Repo.transaction(fn -> - Enum.each(socket.assigns.invitations_to_delete, fn invitation -> - Plausible.Teams.Invitations.Remove.remove( - my_team, - invitation.invitation_id, - current_user - ) - end) - - memberships_removed = - Enum.map(socket.assigns.memberships_to_delete, fn membership -> - {:ok, membership} = - Plausible.Teams.Memberships.Remove.remove( - my_team, - membership.user.id, - current_user, - send_email?: false - ) - - membership - end) - - Enum.each(memberships_removed, fn team_membership -> - Plausible.Teams.Memberships.Remove.send_team_member_removed_email(team_membership) - end) + def handle_event( + "input-invitation", + %{"input-email" => email}, + %{assigns: %{layout: layout, input_role: role}} = socket + ) do + email = String.trim(email) - Enum.map(socket.assigns.invitations_pending, fn %{email: email, role: role} -> - invite_fn.(email, role) - end) + existing_entry = + Enum.find_value(layout, fn {key_email, entry} -> + if key_email == email, do: entry 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) - + cond do + existing_entry && existing_entry.queued_op == :delete -> + # bring back previously deleted entry (either invitation or membership), and only update role socket - # TODO: build descriptive message - |> put_live_flash(:success, "Team layout updated successfully.") - |> reset() + |> update_layout(email, nil, queued_op: :update, role: role) + |> assign(input_email: "") - {:error, :already_a_member} -> + existing_entry -> + # trying to add e-mail that's already in the layout socket + |> assign(input_email: email) |> put_live_flash( :error, - "Can't invite e-mails that belong to team members already" + "Make sure the e-mail is valid and is not taken already in your team layout" ) - {:error, {:over_limit, limit}} = _ -> + 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, - "Your account is limited to #{limit} team members. You can upgrade your plan to increase this limit." + "Make sure the e-mail is valid and is not taken already in your team layout" ) end {:noreply, socket} end - def handle_event("remove-member", %{"email" => email}, socket) do - invitations_pending = Enum.reject(socket.assigns.invitations_pending, &(&1.email == email)) - - socket = assign(socket, invitations_pending: invitations_pending) + def handle_event("save-team-layout", _params, %{assigns: assigns} = socket) do + result = + Repo.transaction(fn -> + assigns.layout + |> sorted_layout() + |> Enum.reduce([], fn {_, entry}, acc -> + case persist_entry(entry, assigns) do + {:ok, :ignore} -> acc + {:ok, persist_result} -> [persist_result | acc] + {:error, error} -> Repo.rollback(error) + end + end) + end) - invitation_to_delete = Enum.find(socket.assigns.invitations_sent, &(&1.email == email)) + IO.inspect(result) + + # 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) + # + # socket + # # TODO: build descriptive message + # |> put_live_flash(:success, "Team layout updated successfully") + # |> reset() + # + # {: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 = - if invitation_to_delete do - assign(socket, - invitations_to_delete: [invitation_to_delete | socket.assigns.invitations_to_delete], - invitations_sent: Enum.reject(socket.assigns.invitations_sent, &(&1.email == email)), - team_layout_changed?: true + with :ok <- 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 - socket + {:error, message} -> + socket + |> put_live_flash( + :error, + message + ) end - member_to_delete = - Enum.find(socket.assigns.all_members, &(&1.user.email == email)) - |> IO.inspect(label: :member_to_delete) + {:noreply, socket} + end + def handle_event("update-role", %{"email" => email, "role" => role}, socket) do socket = - if member_to_delete do + 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 verify_removable(layout, email) do + with :ok <- ensure_at_least_one_owner(layout, email) do + :ok + end + end + + defp removable?(layout, email) do + verify_removable(layout, email) == :ok + 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.queued_op != :delete + end), + do: :ok, + else: {:error, "The team has to have at least one owner"} + end + + defp sorted_layout(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 + + defp update_layout(%{assigns: %{layout: layout}} = socket, email, object, attrs) do + case object do + nil -> + entry = Map.fetch!(layout, email) + assign(socket, - memberships_to_delete: [member_to_delete | socket.assigns.memberships_to_delete], - all_members: Enum.reject(socket.assigns.all_members, &(&1.user.email == email)), + layout: Map.put(layout, email, struct!(entry, attrs)), team_layout_changed?: true ) - else - socket - end - socket = - if invitation_to_delete || member_to_delete do - put_live_flash( - socket, - :success, - "Team layout change will be effective once you save your changes." + _ -> + assign(socket, + layout: Map.put(layout, email, to_entry(object, attrs)), + team_layout_changed?: true ) - else - socket - end + end + end - {:noreply, socket} + defp persist_entry(%LayoutEntry{queued_op: nil}, _context) do end - def handle_event("update-role", %{"name" => name, "email" => email, "role" => role}, socket) do - invitations_sent = socket.assigns.invitations_sent + defp persist_entry(%LayoutEntry{type: :invitation_pending, queued_op: :delete}, _context) do + {:ok, :ignore} + end - socket = - cond do - invitation_sent = Enum.find(invitations_sent, &(&1.email == email)) -> - invitations_to_update = socket.assigns.invitations_to_update - altered_invitation = %{invitation_sent | role: role} - - invitations_sent = - Enum.reduce(invitations_sent, [], fn invitation, acc -> - if invitation.email == email do - [altered_invitation | acc] - else - [invitation | acc] - end - end) - |> Enum.reverse() - - assign(socket, - invitations_sent: invitations_sent, - team_layout_changed?: true, - invitations_to_update: [altered_invitation | invitations_to_update] - ) - end + defp persist_entry( + %LayoutEntry{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 - IO.inspect(socket.assigns.invitations_to_update, label: :to_upd) - {:noreply, socket} + defp persist_entry( + %LayoutEntry{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 - defp valid_email?(email) do - String.contains?(email, "@") and String.contains?(email, ".") + defp persist_entry( + %LayoutEntry{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 - defp already_in?(socket, email) do - not is_nil( - Enum.find(socket.assigns.invitations_sent, &(&1.email == email)) || - Enum.find(socket.assigns.all_members, &(&1.user.email == email)) || - Enum.find(socket.assigns.invitations_pending, &(&1.email == email)) + defp persist_entry( + %LayoutEntry{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 end diff --git a/lib/plausible_web/templates/settings/team_general.html.heex b/lib/plausible_web/templates/settings/team_general.html.heex index 12e08265fc50..2deb037a39ee 100644 --- a/lib/plausible_web/templates/settings/team_general.html.heex +++ b/lib/plausible_web/templates/settings/team_general.html.heex @@ -24,7 +24,7 @@ Team Members <:subtitle> - Add, remove or change your team memberships + Add, remove or change your team memberships. Take your time, changes apply on save. {live_render(@conn, PlausibleWeb.Live.TeamManagement, id: "team-setup", From 0aa6511c9bdf0f79dde40a197617c91163243c36 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Sat, 25 Jan 2025 16:57:37 +0100 Subject: [PATCH 7/9] wip --- lib/plausible_web/live/team_management.ex | 29 ++++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/lib/plausible_web/live/team_management.ex b/lib/plausible_web/live/team_management.ex index 759d1ecea3b0..4f90f4c44783 100644 --- a/lib/plausible_web/live/team_management.ex +++ b/lib/plausible_web/live/team_management.ex @@ -258,7 +258,16 @@ defmodule PlausibleWeb.Live.TeamManagement do end) end) - IO.inspect(result) + socket = + case result do + {:ok, _} -> + socket + |> put_live_flash(:success, "Team layout updated successfully") + + {:error, error} -> + socket + |> put_live_flash(:error, inspect(error)) + end # socket = # case result do @@ -268,11 +277,6 @@ defmodule PlausibleWeb.Live.TeamManagement do # Teams.Invitations.InviteToTeam.send_invitation_email(invitation, invitee) # end) # - # socket - # # TODO: build descriptive message - # |> put_live_flash(:success, "Team layout updated successfully") - # |> reset() - # # {:error, :already_a_member} -> # socket # |> put_live_flash( @@ -378,6 +382,7 @@ defmodule PlausibleWeb.Live.TeamManagement do end defp persist_entry(%LayoutEntry{queued_op: nil}, _context) do + {:ok, :ignore} end defp persist_entry(%LayoutEntry{type: :invitation_pending, queued_op: :delete}, _context) do @@ -424,4 +429,16 @@ defmodule PlausibleWeb.Live.TeamManagement do send_email?: false ) end + + defp persist_entry( + %LayoutEntry{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 From ec47ab65ae9dfad64ac583c5a06a0816d621e390 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Sat, 25 Jan 2025 17:13:22 +0100 Subject: [PATCH 8/9] wip --- lib/plausible_web/live/team_management.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/plausible_web/live/team_management.ex b/lib/plausible_web/live/team_management.ex index 4f90f4c44783..bd8512fee1cd 100644 --- a/lib/plausible_web/live/team_management.ex +++ b/lib/plausible_web/live/team_management.ex @@ -342,7 +342,10 @@ defmodule PlausibleWeb.Live.TeamManagement do 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.queued_op != :delete + 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"} From ac65349d12e8ec20bb204dd7dfd2486eefec3b86 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Sat, 25 Jan 2025 17:43:30 +0100 Subject: [PATCH 9/9] wip --- lib/plausible_web/live/team_management.ex | 179 +----------------- .../live/team_management/layout.ex | 74 ++++++++ .../live/team_management/layout/entry.ex | 111 +++++++++++ 3 files changed, 195 insertions(+), 169 deletions(-) create mode 100644 lib/plausible_web/live/team_management/layout.ex create mode 100644 lib/plausible_web/live/team_management/layout/entry.ex diff --git a/lib/plausible_web/live/team_management.ex b/lib/plausible_web/live/team_management.ex index bd8512fee1cd..1d53388d564d 100644 --- a/lib/plausible_web/live/team_management.ex +++ b/lib/plausible_web/live/team_management.ex @@ -9,75 +9,12 @@ defmodule PlausibleWeb.Live.TeamManagement do 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 - defmodule LayoutEntry do - defstruct [:email, :name, :role, :type, :label, :meta, :queued_op] - end - - def to_entry(object, attrs \\ []) - - def to_entry( - %Teams.Invitation{id: existing} = invitation, - attrs - ) - when is_integer(existing) do - %LayoutEntry{ - name: "Invited User", - email: invitation.email, - role: invitation.role, - type: :invitation_sent, - label: "Invitation Sent", - meta: invitation - } - |> Map.merge(Enum.into(attrs, %{})) - end - - def to_entry(%Teams.Invitation{id: nil} = pending, attrs) do - %LayoutEntry{ - name: "Invited User", - email: pending.email, - role: pending.role, - type: :invitation_pending, - label: "Invitation Pending", - meta: pending - } - |> Map.merge(Enum.into(attrs, %{})) - end - - def to_entry(%Teams.Membership{} = membership, attrs) do - %LayoutEntry{ - name: membership.user.name, - role: membership.role, - email: membership.user.email, - type: :membership, - meta: membership - } - |> Map.merge(Enum.into(attrs, %{})) - end - - def layout_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, to_entry(invitation)) - - %Teams.Invitation{id: nil} = pending, acc -> - Map.put(acc, pending.email, to_entry(pending)) - - %Teams.Membership{} = membership, acc -> - Map.put( - acc, - membership.user.email, - to_entry(membership, - label: if(current_user.id == membership.user.id, do: "You", else: "Team Member") - ) - ) - end) - 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 @@ -86,7 +23,7 @@ defmodule PlausibleWeb.Live.TeamManagement do invitations_sent = Plausible.Teams.Invitations.find_team_invitations(my_team) all_members = Teams.Memberships.all_members(my_team) - layout = layout_by_email(current_user, invitations_sent ++ all_members) + layout = Layout.build_by_email(current_user, invitations_sent ++ all_members) assign(socket, layout: layout, @@ -164,13 +101,13 @@ defmodule PlausibleWeb.Live.TeamManagement do <.member - :for={{email, entry} <- sorted_layout(@layout)} + :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 removable?(@layout, email)} + remove_disabled={not Layout.removable?(@layout, email)} /> <.button type="submit" phx-click="save-team-layout" disabled={not @team_layout_changed?}> @@ -248,9 +185,9 @@ defmodule PlausibleWeb.Live.TeamManagement do result = Repo.transaction(fn -> assigns.layout - |> sorted_layout() + |> Layout.sorted_for_persistence() |> Enum.reduce([], fn {_, entry}, acc -> - case persist_entry(entry, assigns) do + case Layout.Entry.persist(entry, assigns) do {:ok, :ignore} -> acc {:ok, persist_result} -> [persist_result | acc] {:error, error} -> Repo.rollback(error) @@ -262,6 +199,7 @@ defmodule PlausibleWeb.Live.TeamManagement do case result do {:ok, _} -> socket + |> reset() |> put_live_flash(:success, "Team layout updated successfully") {:error, error} -> @@ -297,7 +235,7 @@ defmodule PlausibleWeb.Live.TeamManagement do def handle_event("remove-member", %{"email" => email}, %{assigns: %{layout: layout}} = socket) do socket = - with :ok <- verify_removable(layout, email) do + with :ok <- Layout.verify_removable(layout, email) do socket |> update_layout(email, nil, queued_op: :delete) |> put_live_flash( @@ -330,118 +268,21 @@ defmodule PlausibleWeb.Live.TeamManagement do String.contains?(email, "@") and String.contains?(email, ".") end - defp verify_removable(layout, email) do - with :ok <- ensure_at_least_one_owner(layout, email) do - :ok - end - end - - defp removable?(layout, email) do - verify_removable(layout, email) == :ok - 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 - - defp sorted_layout(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 - defp update_layout(%{assigns: %{layout: layout}} = socket, email, object, attrs) do case object do nil -> entry = Map.fetch!(layout, email) assign(socket, - layout: Map.put(layout, email, struct!(entry, attrs)), + layout: Layout.put(layout, email, Layout.Entry.patch(entry, attrs)), team_layout_changed?: true ) _ -> assign(socket, - layout: Map.put(layout, email, to_entry(object, attrs)), + layout: Layout.put(layout, email, Layout.Entry.new(object, attrs)), team_layout_changed?: true ) end end - - defp persist_entry(%LayoutEntry{queued_op: nil}, _context) do - {:ok, :ignore} - end - - defp persist_entry(%LayoutEntry{type: :invitation_pending, queued_op: :delete}, _context) do - {:ok, :ignore} - end - - defp persist_entry( - %LayoutEntry{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 - - defp persist_entry( - %LayoutEntry{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 - - defp persist_entry( - %LayoutEntry{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 - - defp persist_entry( - %LayoutEntry{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 - - defp persist_entry( - %LayoutEntry{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_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