mirror of
https://github.com/plausible/analytics.git
synced 2024-12-27 03:21:37 +03:00
eceac8afd5
* Allow e-mail exclusion in team members quota * Exclude invitee from quota on invitation create * Enable invitation submission but report errors on quota violation * Use a single interface for team members quota * Check the `Keyword.validate/2` result * Update test/plausible_web/controllers/site/membership_controller_test.exs Co-authored-by: Uku Taht <Uku.taht@gmail.com> --------- Co-authored-by: Uku Taht <Uku.taht@gmail.com>
250 lines
8.4 KiB
Elixir
250 lines
8.4 KiB
Elixir
defmodule PlausibleWeb.Site.MembershipController do
|
|
@moduledoc """
|
|
This controller deals with user management via the UI in Site Settings -> People. It's important to enforce permissions in this controller.
|
|
|
|
Owner - Can manage users, can trigger a 'transfer ownership' request
|
|
Admin - Can manage users
|
|
Viewer - Can not access user management settings
|
|
Anyone - Can accept invitations
|
|
|
|
Everything else should be explicitly disallowed.
|
|
"""
|
|
|
|
use PlausibleWeb, :controller
|
|
use Plausible.Repo
|
|
alias Plausible.Sites
|
|
alias Plausible.Site.{Membership, Memberships}
|
|
|
|
@only_owner_is_allowed_to [:transfer_ownership_form, :transfer_ownership]
|
|
|
|
plug PlausibleWeb.RequireAccountPlug
|
|
plug PlausibleWeb.AuthorizeSiteAccess, [:owner] when action in @only_owner_is_allowed_to
|
|
|
|
plug PlausibleWeb.AuthorizeSiteAccess,
|
|
[:owner, :admin] when action not in @only_owner_is_allowed_to
|
|
|
|
def invite_member_form(conn, _params) do
|
|
site =
|
|
conn.assigns.current_user.id
|
|
|> Sites.get_for_user!(conn.assigns.site.domain)
|
|
|> Plausible.Repo.preload(:owner)
|
|
|
|
limit = Plausible.Billing.Quota.team_member_limit(site.owner)
|
|
usage = Plausible.Billing.Quota.team_member_usage(site.owner)
|
|
below_limit? = Plausible.Billing.Quota.below_limit?(usage, limit)
|
|
|
|
render(
|
|
conn,
|
|
"invite_member_form.html",
|
|
site: site,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"},
|
|
team_member_limit: limit,
|
|
is_at_limit: not below_limit?,
|
|
skip_plausible_tracking: true
|
|
)
|
|
end
|
|
|
|
def invite_member(conn, %{"email" => email, "role" => role}) do
|
|
site_domain = conn.assigns[:site].domain
|
|
|
|
site =
|
|
Sites.get_for_user!(conn.assigns[:current_user].id, site_domain)
|
|
|> Plausible.Repo.preload(:owner)
|
|
|
|
case Memberships.create_invitation(site, conn.assigns.current_user, email, role) do
|
|
{:ok, invitation} ->
|
|
conn
|
|
|> put_flash(
|
|
:success,
|
|
"#{email} has been invited to #{site_domain} as #{PlausibleWeb.SiteView.with_indefinite_article("#{invitation.role}")}"
|
|
)
|
|
|> redirect(external: Routes.site_path(conn, :settings_people, site.domain))
|
|
|
|
{:error, :already_a_member} ->
|
|
render(conn, "invite_member_form.html",
|
|
error: "Cannot send invite because #{email} is already a member of #{site.domain}",
|
|
site: site,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"},
|
|
skip_plausible_tracking: true
|
|
)
|
|
|
|
{:error, {:over_limit, limit}} ->
|
|
render(conn, "invite_member_form.html",
|
|
error:
|
|
"Your account is limited to #{limit} team members. You can upgrade your plan to increase this limit.",
|
|
site: site,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"},
|
|
skip_plausible_tracking: true,
|
|
is_at_limit: true,
|
|
team_member_limit: limit
|
|
)
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
error_msg =
|
|
case changeset.errors[:invitation] do
|
|
{"already sent", _} ->
|
|
"This invitation has been already sent. To send again, remove it from pending invitations first."
|
|
|
|
_ ->
|
|
"Something went wrong."
|
|
end
|
|
|
|
conn
|
|
|> put_flash(:error, error_msg)
|
|
|> redirect(external: Routes.site_path(conn, :settings_people, site.domain))
|
|
end
|
|
end
|
|
|
|
def transfer_ownership_form(conn, _params) do
|
|
site_domain = conn.assigns[:site].domain
|
|
site = Sites.get_for_user!(conn.assigns[:current_user].id, site_domain)
|
|
|
|
render(
|
|
conn,
|
|
"transfer_ownership_form.html",
|
|
site: site,
|
|
layout: {PlausibleWeb.LayoutView, "focus.html"},
|
|
skip_plausible_tracking: true
|
|
)
|
|
end
|
|
|
|
def transfer_ownership(conn, %{"email" => email}) do
|
|
site_domain = conn.assigns[:site].domain
|
|
site = Sites.get_for_user!(conn.assigns[:current_user].id, site_domain)
|
|
|
|
case Memberships.create_invitation(site, conn.assigns.current_user, email, :owner) do
|
|
{:ok, _invitation} ->
|
|
conn
|
|
|> put_flash(:success, "Site transfer request has been sent to #{email}")
|
|
|> redirect(external: Routes.site_path(conn, :settings_people, site.domain))
|
|
|
|
{:error, :transfer_to_self} ->
|
|
conn
|
|
|> put_flash(:ttl, :timer.seconds(5))
|
|
|> put_flash(:error_title, "Transfer error")
|
|
|> put_flash(:error, "Can't transfer ownership to existing owner")
|
|
|> redirect(external: Routes.site_path(conn, :settings_people, site.domain))
|
|
|
|
{:error, changeset} ->
|
|
errors = Plausible.ChangesetHelpers.traverse_errors(changeset)
|
|
|
|
message =
|
|
case errors do
|
|
%{invitation: ["already sent" | _]} -> "Invitation has already been sent"
|
|
_other -> "Site transfer request to #{email} has failed"
|
|
end
|
|
|
|
conn
|
|
|> put_flash(:ttl, :timer.seconds(5))
|
|
|> put_flash(:error_title, "Transfer error")
|
|
|> put_flash(:error, message)
|
|
|> redirect(external: Routes.site_path(conn, :settings_people, site.domain))
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Updates the role of a user. The user being updated could be the same or different from the user taking
|
|
the action. When updating the role, it's important to enforce permissions:
|
|
|
|
Owner - Can update anyone's role except for themselves. If they want to change their own role, they have to use the 'transfer ownership' feature.
|
|
Admin - Can update anyone's role except for owners. Can downgrade their own access to 'viewer'. Can promote a viewer to admin.
|
|
"""
|
|
@role_mappings Membership
|
|
|> Ecto.Enum.mappings(:role)
|
|
|> Enum.map(fn {k, v} -> {v, k} end)
|
|
|> Enum.into(%{})
|
|
|
|
def update_role(conn, %{"id" => id, "new_role" => new_role_str}) do
|
|
%{site: site, current_user: current_user, current_user_role: current_user_role} = conn.assigns
|
|
|
|
membership = Repo.get!(Membership, id) |> Repo.preload(:user)
|
|
new_role = Map.fetch!(@role_mappings, new_role_str)
|
|
|
|
can_grant_role? =
|
|
if membership.user.id == current_user.id do
|
|
can_grant_role_to_self?(current_user_role, new_role)
|
|
else
|
|
can_grant_role_to_other?(current_user_role, new_role)
|
|
end
|
|
|
|
if can_grant_role? do
|
|
membership =
|
|
membership
|
|
|> Ecto.Changeset.change()
|
|
|> Membership.set_role(new_role)
|
|
|> Repo.update!()
|
|
|
|
redirect_target =
|
|
if membership.user.id == current_user.id and new_role == :viewer do
|
|
"/#{URI.encode_www_form(site.domain)}"
|
|
else
|
|
Routes.site_path(conn, :settings_people, site.domain)
|
|
end
|
|
|
|
conn
|
|
|> put_flash(
|
|
:success,
|
|
"#{membership.user.name} is now #{PlausibleWeb.SiteView.with_indefinite_article(new_role_str)}"
|
|
)
|
|
|> redirect(external: redirect_target)
|
|
else
|
|
conn
|
|
|> put_flash(:error, "You are not allowed to grant the #{new_role} role")
|
|
|> redirect(external: Routes.site_path(conn, :settings_people, site.domain))
|
|
end
|
|
end
|
|
|
|
defp can_grant_role_to_self?(:admin, :viewer), do: true
|
|
defp can_grant_role_to_self?(_, _), do: false
|
|
|
|
defp can_grant_role_to_other?(:owner, :admin), do: true
|
|
defp can_grant_role_to_other?(:owner, :viewer), do: true
|
|
defp can_grant_role_to_other?(:admin, :admin), do: true
|
|
defp can_grant_role_to_other?(:admin, :viewer), do: true
|
|
defp can_grant_role_to_other?(_, _), do: false
|
|
|
|
def remove_member(conn, %{"id" => id}) do
|
|
site = conn.assigns[:site]
|
|
site_id = site.id
|
|
|
|
membership_q =
|
|
from m in Membership,
|
|
where: m.id == ^id,
|
|
where: m.site_id == ^site_id,
|
|
inner_join: user in assoc(m, :user),
|
|
inner_join: site in assoc(m, :site),
|
|
preload: [user: user, site: site]
|
|
|
|
membership = Repo.one(membership_q)
|
|
|
|
if membership do
|
|
Repo.delete!(membership)
|
|
|
|
membership
|
|
|> PlausibleWeb.Email.site_member_removed()
|
|
|> Plausible.Mailer.send()
|
|
|
|
redirect_target =
|
|
if membership.user.id == conn.assigns[:current_user].id do
|
|
"/#{URI.encode_www_form(membership.site.domain)}"
|
|
else
|
|
Routes.site_path(conn, :settings_people, site.domain)
|
|
end
|
|
|
|
conn
|
|
|> put_flash(
|
|
:success,
|
|
"#{membership.user.name} has been removed from #{site.domain}"
|
|
)
|
|
|> redirect(external: redirect_target)
|
|
else
|
|
conn
|
|
|> put_flash(
|
|
:error,
|
|
"Failed to find membership to remove"
|
|
)
|
|
|> redirect(external: Routes.site_path(conn, :settings_people, site.domain))
|
|
end
|
|
end
|
|
end
|