352 lines
11 KiB
Elixir
352 lines
11 KiB
Elixir
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} />
|
|
|
|
<PlausibleWeb.Components.Billing.Notice.limit_exceeded
|
|
:if={@team_members_limit != 0 and at_limit?(@layout, @team_members_limit)}
|
|
current_role={@current_team_role}
|
|
current_team={@current_team}
|
|
limit={@team_members_limit}
|
|
resource="members"
|
|
class="mb-4"
|
|
/>
|
|
<div>
|
|
<.form id="team-layout-form" for={} phx-submit="input-invitation" phx-change="form-changed">
|
|
<div class="flex gap-x-3 mb-8">
|
|
<div class="flex-1">
|
|
<.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}
|
|
/>
|
|
</div>
|
|
|
|
<.dropdown id="input-role-picker">
|
|
<:button class="role border rounded-sm 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()}
|
|
<Heroicons.chevron_down mini class="size-4 mt-0.5" />
|
|
</:button>
|
|
<: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_item
|
|
role={:admin}
|
|
disabled={@my_role not in [:owner, :admin]}
|
|
phx-click="switch-role"
|
|
>
|
|
Manage all team settings
|
|
</.role_item>
|
|
<.role_item
|
|
role={:editor}
|
|
disabled={@my_role not in [:owner, :admin]}
|
|
phx-click="switch-role"
|
|
>
|
|
Create and view new sites
|
|
</.role_item>
|
|
<.role_item
|
|
role={:billing}
|
|
disabled={@my_role not in [:owner, :admin]}
|
|
phx-click="switch-role"
|
|
>
|
|
Manage subscription
|
|
</.role_item>
|
|
<.role_item
|
|
role={:viewer}
|
|
disabled={@my_role not in [:owner, :admin]}
|
|
phx-click="switch-role"
|
|
>
|
|
View all sites under your team
|
|
</.role_item>
|
|
</:menu>
|
|
</.dropdown>
|
|
|
|
<.button
|
|
id="invite-member"
|
|
type="submit"
|
|
mt?={false}
|
|
disabled={at_limit?(@layout, @team_members_limit) or @my_role not in [:admin, :owner]}
|
|
>
|
|
Invite
|
|
</.button>
|
|
</div>
|
|
</.form>
|
|
|
|
<div id="member-list">
|
|
<.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]
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div :if={Layout.has_guests?(@layout)} class="flex items-center mt-4 mb-4" id="guests-hr">
|
|
<hr class="grow border-t border-gray-200 dark:border-gray-600" />
|
|
<span class="mx-4 text-gray-500 text-sm">
|
|
Guests
|
|
</span>
|
|
<hr class="grow border-t border-gray-200 dark:border-gray-600" />
|
|
</div>
|
|
|
|
<div :if={Layout.has_guests?(@layout)} id="guest-list">
|
|
<.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]}
|
|
/>
|
|
</div>
|
|
|
|
<.button
|
|
:if={@mode == :team_setup}
|
|
id="save-layout"
|
|
type="submit"
|
|
phx-click="save-team-layout"
|
|
class="mt-8 w-full"
|
|
>
|
|
Create Team
|
|
</.button>
|
|
</div>
|
|
"""
|
|
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
|