Remove kaffy (#5423)
* Deal with `FeatruesList` proxy * Remove kaffy: first pass * Remove admin controller * Remove kaffy: last batch * unlock dependency * Remove kaffy links * !fixup
This commit is contained in:
parent
bdc44d1d33
commit
efc55e323d
|
|
@ -841,41 +841,6 @@ config :ref_inspector,
|
|||
config :ua_inspector,
|
||||
init: {Plausible.Release, :configure_ua_inspector}
|
||||
|
||||
if config_env() in [:dev, :staging, :prod, :test] do
|
||||
config :kaffy,
|
||||
otp_app: :plausible,
|
||||
ecto_repo: Plausible.Repo,
|
||||
router: PlausibleWeb.Router,
|
||||
admin_title: "Plausible Admin",
|
||||
extensions: [Plausible.CrmExtensions],
|
||||
resources: [
|
||||
auth: [
|
||||
resources: [
|
||||
user: [schema: Plausible.Auth.User, admin: Plausible.Auth.UserAdmin],
|
||||
api_key: [schema: Plausible.Auth.ApiKey, admin: Plausible.Auth.ApiKeyAdmin]
|
||||
]
|
||||
],
|
||||
teams: [
|
||||
resources: [
|
||||
team: [schema: Plausible.Teams.Team, admin: Plausible.Teams.TeamAdmin]
|
||||
]
|
||||
],
|
||||
sites: [
|
||||
resources: [
|
||||
site: [schema: Plausible.Site, admin: Plausible.SiteAdmin]
|
||||
]
|
||||
],
|
||||
billing: [
|
||||
resources: [
|
||||
enterprise_plan: [
|
||||
schema: Plausible.Billing.EnterprisePlan,
|
||||
admin: Plausible.Billing.EnterprisePlanAdmin
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
geo_opts =
|
||||
cond do
|
||||
maxmind_license_key ->
|
||||
|
|
|
|||
|
|
@ -102,9 +102,6 @@ defmodule PlausibleWeb.Live.CustomerSupport do
|
|||
<.styled_link onclick="window.history.go(-1); return false;">
|
||||
← Previous
|
||||
</.styled_link>
|
||||
<.styled_link :if={@current} class="text-xs" href={kaffy_url(@current, @id)}>
|
||||
open in Kaffy
|
||||
</.styled_link>
|
||||
</div>
|
||||
<.live_component
|
||||
:if={@current}
|
||||
|
|
@ -239,20 +236,4 @@ defmodule PlausibleWeb.Live.CustomerSupport do
|
|||
|> assign(:uri, uri)
|
||||
|> push_patch(to: URI.to_string(uri), replace: true)
|
||||
end
|
||||
|
||||
defp kaffy_url(nil, _id), do: ""
|
||||
|
||||
defp kaffy_url(current, id) do
|
||||
r =
|
||||
current.type()
|
||||
|
||||
kaffy_r =
|
||||
case r do
|
||||
"user" -> "auth"
|
||||
"team" -> "teams"
|
||||
"site" -> "sites"
|
||||
end
|
||||
|
||||
"/crm/#{kaffy_r}/#{r}/#{id}"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -39,11 +39,6 @@ defmodule Plausible.Auth.ApiKey do
|
|||
|> unique_constraint([:team_id, :user_id], error_key: :team)
|
||||
end
|
||||
|
||||
# NOTE: needed only because of lacking introspection in Kaffy
|
||||
def changeset(struct, attrs) do
|
||||
changeset(struct, nil, attrs)
|
||||
end
|
||||
|
||||
def update(struct, attrs \\ %{}) do
|
||||
struct
|
||||
|> cast(attrs, [:name, :user_id, :scopes])
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
defmodule Plausible.Auth.ApiKeyAdmin do
|
||||
@moduledoc """
|
||||
Stats and Sites API key logic for CRM.
|
||||
"""
|
||||
use Plausible.Repo
|
||||
|
||||
alias Plausible.Auth
|
||||
alias Plausible.Teams
|
||||
|
||||
def search_fields(_schema) do
|
||||
[
|
||||
:name,
|
||||
user: [:name, :email],
|
||||
team: [:name, :identifier]
|
||||
]
|
||||
end
|
||||
|
||||
def custom_index_query(_conn, _schema, query) do
|
||||
from(r in query, preload: [:user, team: :owners])
|
||||
end
|
||||
|
||||
def create_changeset(schema, attrs) do
|
||||
team = Teams.get(attrs["team_identifier"])
|
||||
|
||||
user_id =
|
||||
case Integer.parse(Map.get(attrs, "user_id", "")) do
|
||||
{user_id, ""} -> user_id
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
user = user_id && Auth.find_user_by(id: user_id)
|
||||
|
||||
team =
|
||||
case {team, user} do
|
||||
{%{} = team, _} ->
|
||||
team
|
||||
|
||||
{nil, %{} = user} ->
|
||||
{:ok, team} = Teams.get_or_create(user)
|
||||
|
||||
team
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
|
||||
Auth.ApiKey.changeset(schema, team, attrs)
|
||||
end
|
||||
|
||||
def update_changeset(entry, attrs) do
|
||||
Auth.ApiKey.update(entry, attrs)
|
||||
end
|
||||
|
||||
@plaintext_key_help """
|
||||
The value of the API key is sensitive data like a password. Once created, the value of they will never be revealed again. Make sure to copy/paste this into a secure place before hitting 'save'. When sending the key to a customer, use a secure E2EE system that destructs the message after a certain period like https://bitwarden.com/products/send
|
||||
"""
|
||||
|
||||
@team_identifier_help """
|
||||
Team under which the key is to be created. Defaults to user's personal team when left empty.
|
||||
"""
|
||||
|
||||
def form_fields(_) do
|
||||
[
|
||||
name: nil,
|
||||
key: %{create: :readonly, update: :hidden, help_text: @plaintext_key_help},
|
||||
key_prefix: %{create: :hidden, update: :readonly},
|
||||
scopes: %{
|
||||
choices: [
|
||||
{"Stats API", Jason.encode!(["stats:read:*"])},
|
||||
{"Sites API", Jason.encode!(["sites:provision:*"])}
|
||||
]
|
||||
},
|
||||
team_identifier: %{update: :hidden, help_text: @team_identifier_help},
|
||||
user_id: nil
|
||||
]
|
||||
end
|
||||
|
||||
def index(_) do
|
||||
[
|
||||
key_prefix: nil,
|
||||
name: nil,
|
||||
scopes: nil,
|
||||
owner: %{value: &get_owner/1},
|
||||
team: %{value: &get_team/1}
|
||||
]
|
||||
end
|
||||
|
||||
defp get_team(api_key) do
|
||||
team_name =
|
||||
case api_key.team && api_key.team.owners do
|
||||
[owner] ->
|
||||
if api_key.team.setup_complete do
|
||||
api_key.team.name
|
||||
else
|
||||
owner.name
|
||||
end
|
||||
|
||||
[_ | _] ->
|
||||
api_key.team.name
|
||||
|
||||
nil ->
|
||||
"(none)"
|
||||
end
|
||||
|> html_escape()
|
||||
|
||||
if api_key.team do
|
||||
Phoenix.HTML.raw("""
|
||||
<a href="/crm/teams/team/#{api_key.team.id}">#{team_name}</a>
|
||||
""")
|
||||
else
|
||||
team_name
|
||||
end
|
||||
end
|
||||
|
||||
defp get_owner(api_key) do
|
||||
escaped_name = html_escape(api_key.user.name)
|
||||
escaped_email = html_escape(api_key.user.email)
|
||||
|
||||
owner_html =
|
||||
"""
|
||||
<a href="/crm/auth/user/#{api_key.user.id}">#{escaped_name}</a>
|
||||
<br/>
|
||||
#{escaped_email}
|
||||
"""
|
||||
|
||||
{:safe, owner_html}
|
||||
end
|
||||
|
||||
defp html_escape(string) do
|
||||
string
|
||||
|> Phoenix.HTML.html_escape()
|
||||
|> Phoenix.HTML.safe_to_string()
|
||||
end
|
||||
end
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
defmodule Plausible.Auth.UserAdmin do
|
||||
use Plausible.Repo
|
||||
use Plausible
|
||||
|
||||
def custom_index_query(_conn, _schema, query) do
|
||||
from(r in query, preload: [:owned_teams])
|
||||
end
|
||||
|
||||
def custom_show_query(_conn, _schema, query) do
|
||||
from(u in query, preload: [:owned_teams])
|
||||
end
|
||||
|
||||
def form_fields(_) do
|
||||
[
|
||||
name: nil,
|
||||
email: nil,
|
||||
previous_email: nil,
|
||||
notes: %{type: :textarea, rows: 6}
|
||||
]
|
||||
end
|
||||
|
||||
def delete(_conn, %{data: user}) do
|
||||
case Plausible.Auth.delete_user(user) do
|
||||
{:ok, :deleted} ->
|
||||
{:ok, user}
|
||||
|
||||
{:error, :active_subscription} ->
|
||||
{user, "User's personal team has an active subscription which must be canceled first."}
|
||||
|
||||
{:error, :is_only_team_owner} ->
|
||||
{user, "The user is the only public team owner on one or more teams."}
|
||||
end
|
||||
end
|
||||
|
||||
def index(_) do
|
||||
[
|
||||
name: nil,
|
||||
email: nil,
|
||||
owned_teams: %{value: &Phoenix.HTML.raw(teams(&1.owned_teams))},
|
||||
inserted_at: %{name: "Created at", value: &format_date(&1.inserted_at)}
|
||||
]
|
||||
end
|
||||
|
||||
def resource_actions(_) do
|
||||
[
|
||||
reset_2fa: %{
|
||||
name: "Reset 2FA",
|
||||
action: fn _, user -> disable_2fa(user) end
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def disable_2fa(user) do
|
||||
Plausible.Auth.TOTP.force_disable(user)
|
||||
end
|
||||
|
||||
def teams([]) do
|
||||
"(none)"
|
||||
end
|
||||
|
||||
def teams(teams) do
|
||||
teams
|
||||
|> Enum.map_join("<br>\n", fn team ->
|
||||
"""
|
||||
<a href="/crm/teams/team/#{team.id}">#{html_escape(team.name)}</a>
|
||||
"""
|
||||
end)
|
||||
end
|
||||
|
||||
defp format_date(nil), do: "--"
|
||||
|
||||
defp format_date(date) do
|
||||
Calendar.strftime(date, "%b %-d, %Y")
|
||||
end
|
||||
|
||||
def html_escape(string) do
|
||||
string
|
||||
|> Phoenix.HTML.html_escape()
|
||||
|> Phoenix.HTML.safe_to_string()
|
||||
end
|
||||
end
|
||||
|
|
@ -30,43 +30,3 @@ defmodule Plausible.Billing.Ecto.Feature do
|
|||
{:ok, Atom.to_string(mod.name())}
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Plausible.Billing.Ecto.FeatureList do
|
||||
@moduledoc """
|
||||
Ecto type representing a list of features. This is a proxy for
|
||||
`{:array, Plausible.Billing.Ecto.Feature}` and is required for Kaffy to
|
||||
render the HTML input correctly.
|
||||
"""
|
||||
|
||||
use Ecto.Type
|
||||
|
||||
def type, do: {:array, Plausible.Billing.Ecto.Feature}
|
||||
def cast(list), do: Ecto.Type.cast(type(), list)
|
||||
def load(list), do: Ecto.Type.load(type(), list)
|
||||
def dump(list), do: Ecto.Type.dump(type(), list)
|
||||
|
||||
# XXX: remove with kaffy
|
||||
def render_form(_conn, changeset, form, field, _options) do
|
||||
features = Ecto.Changeset.get_field(changeset, field)
|
||||
|
||||
checkboxes =
|
||||
for mod <- Plausible.Billing.Feature.list(), not mod.free?() do
|
||||
[
|
||||
{:safe, ~s(<label style="padding-right: 15px;">)},
|
||||
{:safe,
|
||||
~s(<input type="checkbox" name="#{form.name}[#{field}][]" "#{form.name}_#{field}_#{mod.name()}" value="#{mod.name()}" style="margin-right: 3px;" #{if mod in features, do: "checked", else: ""}>)},
|
||||
mod.display_name(),
|
||||
{:safe, ~s(</label>)}
|
||||
]
|
||||
end
|
||||
|
||||
[
|
||||
{:safe, ~s(<div class="form-group">)},
|
||||
{:safe, ~s(<label for="#{form.name}_#{field}">#{Phoenix.Naming.humanize(field)}</label>)},
|
||||
{:safe, ~s(<div class="form-control">)},
|
||||
checkboxes,
|
||||
{:safe, ~s(</div>)},
|
||||
{:safe, ~s(</div>)}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -19,18 +19,4 @@ defmodule Plausible.Billing.Ecto.Limit do
|
|||
|
||||
def dump(:unlimited), do: {:ok, -1}
|
||||
def dump(other), do: Ecto.Type.dump(:integer, other)
|
||||
|
||||
# XXX: remove with kaffy
|
||||
def render_form(_conn, changeset, form, field, _options) do
|
||||
{:ok, value} = changeset |> Ecto.Changeset.get_field(field) |> dump()
|
||||
|
||||
[
|
||||
{:safe, ~s(<div class="form-group">)},
|
||||
{:safe, ~s(<label for="#{form.name}_#{field}">#{Phoenix.Naming.humanize(field)}</label>)},
|
||||
{:safe,
|
||||
~s(<input id="#{form.name}_#{field}" name="#{form.name}[#{field}]" class="form-control" value="#{value}" min="-1" type="number" />)},
|
||||
{:safe, ~s(<p class="help_text">Use -1 for unlimited.</p>)},
|
||||
{:safe, ~s(</div>)}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ defmodule Plausible.Billing.EnterprisePlan do
|
|||
field :monthly_pageview_limit, :integer
|
||||
field :site_limit, :integer
|
||||
field :team_member_limit, Plausible.Billing.Ecto.Limit
|
||||
field :features, Plausible.Billing.Ecto.FeatureList, default: []
|
||||
field :features, {:array, Plausible.Billing.Ecto.Feature}, default: []
|
||||
field :hourly_api_request_limit, :integer
|
||||
|
||||
belongs_to :team, Plausible.Teams.Team
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
defmodule Plausible.Billing.EnterprisePlanAdmin do
|
||||
use Plausible.Repo
|
||||
|
||||
@numeric_fields [
|
||||
"team_id",
|
||||
"paddle_plan_id",
|
||||
"monthly_pageview_limit",
|
||||
"site_limit",
|
||||
"team_member_limit",
|
||||
"hourly_api_request_limit"
|
||||
]
|
||||
|
||||
def search_fields(_schema) do
|
||||
[
|
||||
:paddle_plan_id
|
||||
]
|
||||
end
|
||||
|
||||
def form_fields(_schema) do
|
||||
[
|
||||
team_id: nil,
|
||||
paddle_plan_id: nil,
|
||||
billing_interval: %{choices: [{"Yearly", "yearly"}, {"Monthly", "monthly"}]},
|
||||
monthly_pageview_limit: nil,
|
||||
site_limit: nil,
|
||||
team_member_limit: nil,
|
||||
hourly_api_request_limit: nil,
|
||||
features: nil
|
||||
]
|
||||
end
|
||||
|
||||
def custom_index_query(conn, _schema, query) do
|
||||
search =
|
||||
(conn.params["custom_search"] || "")
|
||||
|> String.trim()
|
||||
|> String.replace("%", "\%")
|
||||
|> String.replace("_", "\_")
|
||||
|
||||
search_term = "%#{search}%"
|
||||
|
||||
from(r in query,
|
||||
inner_join: t in assoc(r, :team),
|
||||
inner_join: o in assoc(t, :owners),
|
||||
or_where: ilike(r.paddle_plan_id, ^search_term),
|
||||
or_where: ilike(o.email, ^search_term),
|
||||
or_where: ilike(o.name, ^search_term),
|
||||
or_where: ilike(t.name, ^search_term),
|
||||
preload: [team: {t, owners: o}]
|
||||
)
|
||||
end
|
||||
|
||||
def index(_) do
|
||||
[
|
||||
id: nil,
|
||||
user_email: %{value: &owner_emails(&1.team)},
|
||||
paddle_plan_id: nil,
|
||||
billing_interval: nil,
|
||||
monthly_pageview_limit: nil,
|
||||
site_limit: nil,
|
||||
team_member_limit: nil,
|
||||
hourly_api_request_limit: nil
|
||||
]
|
||||
end
|
||||
|
||||
defp owner_emails(team) do
|
||||
team.owners
|
||||
|> Enum.map_join("<br>", & &1.email)
|
||||
|> Phoenix.HTML.raw()
|
||||
end
|
||||
|
||||
def create_changeset(schema, attrs) do
|
||||
attrs = sanitize_attrs(attrs)
|
||||
|
||||
Plausible.Billing.EnterprisePlan.changeset(struct(schema, site_limit: 10_000), attrs)
|
||||
end
|
||||
|
||||
def update_changeset(enterprise_plan, attrs) do
|
||||
attrs =
|
||||
attrs
|
||||
|> Map.put_new("features", [])
|
||||
|> sanitize_attrs()
|
||||
|
||||
Plausible.Billing.EnterprisePlan.changeset(enterprise_plan, attrs)
|
||||
end
|
||||
|
||||
defp sanitize_attrs(attrs) do
|
||||
attrs
|
||||
|> Enum.map(&clear_attr/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp clear_attr({key, value}) when key in @numeric_fields do
|
||||
value =
|
||||
value
|
||||
|> to_string()
|
||||
|> String.replace(~r/[^0-9-]/, "")
|
||||
|> String.trim()
|
||||
|
||||
{key, value}
|
||||
end
|
||||
|
||||
defp clear_attr({key, value}) when is_binary(value) do
|
||||
{key, String.trim(value)}
|
||||
end
|
||||
|
||||
defp clear_attr(other) do
|
||||
other
|
||||
end
|
||||
end
|
||||
|
|
@ -18,7 +18,7 @@ defmodule Plausible.Billing.Plan do
|
|||
field :generation, :integer
|
||||
field :kind, Ecto.Enum, values: [:starter, :growth, :business]
|
||||
|
||||
field :features, Plausible.Billing.Ecto.FeatureList
|
||||
field :features, {:array, Plausible.Billing.Ecto.Feature}
|
||||
field :monthly_pageview_limit, :integer
|
||||
field :site_limit, :integer
|
||||
field :team_member_limit, Plausible.Billing.Ecto.Limit
|
||||
|
|
|
|||
|
|
@ -1,280 +0,0 @@
|
|||
defmodule Plausible.CrmExtensions do
|
||||
@moduledoc """
|
||||
Extensions for Kaffy CRM
|
||||
"""
|
||||
|
||||
use Plausible
|
||||
|
||||
on_ee do
|
||||
# Kaffy uses String.to_existing_atom when listing params
|
||||
@custom_search :custom_search
|
||||
|
||||
def javascripts(%{assigns: %{context: "teams", resource: "team", entry: %{} = team}}) do
|
||||
[
|
||||
Phoenix.HTML.raw("""
|
||||
<script type="text/javascript">
|
||||
(async () => {
|
||||
const response = await fetch("/crm/teams/team/#{team.id}/usage?embed=true")
|
||||
const usageHTML = await response.text()
|
||||
const cardBody = document.querySelector(".card-body")
|
||||
if (cardBody) {
|
||||
const usageDOM = document.createElement("div")
|
||||
usageDOM.innerHTML = usageHTML
|
||||
cardBody.prepend(usageDOM)
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
""")
|
||||
]
|
||||
end
|
||||
|
||||
def javascripts(%{assigns: %{context: "auth", resource: "user", entry: %{} = user}}) do
|
||||
[
|
||||
Phoenix.HTML.raw("""
|
||||
<script type="text/javascript">
|
||||
(async () => {
|
||||
const response = await fetch("/crm/auth/user/#{user.id}/info")
|
||||
const usageHTML = await response.text()
|
||||
const cardBody = document.querySelector(".card-body")
|
||||
if (cardBody) {
|
||||
const usageDOM = document.createElement("div")
|
||||
usageDOM.innerHTML = usageHTML
|
||||
cardBody.prepend(usageDOM)
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
""")
|
||||
]
|
||||
end
|
||||
|
||||
def javascripts(%{
|
||||
assigns: %{context: "sites", resource: "site", entry: %{domain: domain, id: id}}
|
||||
}) do
|
||||
base_url = PlausibleWeb.Endpoint.url()
|
||||
|
||||
[
|
||||
Phoenix.HTML.raw("""
|
||||
<script type="text/javascript">
|
||||
(() => {
|
||||
const cardBody = document.querySelector(".card-body")
|
||||
if (cardBody) {
|
||||
const buttonDOM = document.createElement("div")
|
||||
buttonDOM.className = "mb-3 w-full text-right"
|
||||
buttonDOM.innerHTML = '<div><a class="btn btn-outline-primary" href="#{base_url <> "/" <> URI.encode_www_form(domain)}" target="_blank">Open Dashboard</a><a class="mt-1 ml-4" target="_blank" href="/cs/sites/site/#{id}">Open in CS</a></div>'
|
||||
cardBody.prepend(buttonDOM)
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
""")
|
||||
]
|
||||
end
|
||||
|
||||
def javascripts(%{
|
||||
assigns: %{context: "billing", resource: "enterprise_plan", changeset: %{}}
|
||||
}) do
|
||||
[
|
||||
Phoenix.HTML.raw("""
|
||||
<script type="text/javascript">
|
||||
(() => {
|
||||
const statsFeature = document.querySelector(`input[type=checkbox][value=stats_api]`)
|
||||
const sitesFeature = document.querySelector(`input[type=checkbox][value=sites_api]`)
|
||||
|
||||
statsFeature.addEventListener("change", () => {
|
||||
if (!statsFeature.checked) {
|
||||
sitesFeature.checked = false
|
||||
}
|
||||
return true;
|
||||
})
|
||||
|
||||
sitesFeature.addEventListener("change", () => {
|
||||
if (sitesFeature.checked) {
|
||||
statsFeature.checked = true
|
||||
}
|
||||
return true;
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
"""),
|
||||
Phoenix.HTML.raw("""
|
||||
<script type="text/javascript">
|
||||
(async () => {
|
||||
const CHECK_INTERVAL = 300
|
||||
|
||||
const teamPicker = document.querySelector("#pick-raw-resource")
|
||||
if (teamPicker) {
|
||||
teamPicker.style.display = "none";
|
||||
}
|
||||
const teamIdField = document.querySelector("#enterprise_plan_team_id") ||
|
||||
document.querySelector("#team_id")
|
||||
const teamIdLabel = document.querySelector("label[for=enterprise_plan_team_id]")
|
||||
const dataList = document.createElement("datalist")
|
||||
dataList.id = "team-choices"
|
||||
teamIdField.after(dataList)
|
||||
teamIdField.setAttribute("list", "team-choices")
|
||||
teamIdField.setAttribute("type", "text")
|
||||
teamIdField.setAttribute("autocomplete", "off")
|
||||
const labelSpan = document.createElement("span")
|
||||
teamIdLabel.appendChild(labelSpan)
|
||||
|
||||
let updateAction;
|
||||
|
||||
const updateLabel = async (id) => {
|
||||
id = Number(id)
|
||||
|
||||
if (!isNaN(id) && id > 0) {
|
||||
const response = await fetch(`/crm/billing/search/team-by-id/${id}`)
|
||||
labelSpan.innerHTML = ` <i>(${await response.text()})</i>`
|
||||
}
|
||||
}
|
||||
|
||||
const updateSearch = async () => {
|
||||
const search = teamIdField.value
|
||||
|
||||
updateLabel(search)
|
||||
|
||||
const response = await fetch("/crm/billing/search/team", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ search: search })
|
||||
})
|
||||
|
||||
const list = await response.json()
|
||||
|
||||
const options =
|
||||
list.map(([label, value]) => {
|
||||
const option = document.createElement("option")
|
||||
option.setAttribute("label", label)
|
||||
option.textContent = value
|
||||
|
||||
return option
|
||||
})
|
||||
|
||||
dataList.replaceChildren(...options)
|
||||
}
|
||||
|
||||
updateLabel(teamIdField.value)
|
||||
|
||||
teamIdField.addEventListener("input", async (e) => {
|
||||
if (updateAction) {
|
||||
clearTimeout(updateAction)
|
||||
updateAction = null
|
||||
}
|
||||
|
||||
updateAction = setTimeout(() => updateSearch(), CHECK_INTERVAL)
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
"""),
|
||||
Phoenix.HTML.raw("""
|
||||
<script type="text/javascript">
|
||||
(() => {
|
||||
const fields = ["monthly_pageview_limit", "site_limit"].map(p => document.getElementById(`enterprise_plan_${p}`))
|
||||
fields.forEach(field => {
|
||||
field.type = "input"
|
||||
field.addEventListener("keyup", numberFormatCallback)
|
||||
field.addEventListener("change", numberFormatCallback)
|
||||
|
||||
field.dispatchEvent(new Event("change"))
|
||||
})
|
||||
|
||||
function numberFormatCallback(e) {
|
||||
const numeric = Number(e.target.value.replace(/[^0-9]/g, ''))
|
||||
const value = numeric > 0 ? new Intl.NumberFormat("en-GB").format(numeric) : ''
|
||||
e.target.value = value
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
"""),
|
||||
Phoenix.HTML.raw("""
|
||||
<script type="text/javascript">
|
||||
(async () => {
|
||||
const CHECK_INTERVAL = 300
|
||||
const teamIdField = document.getElementById("enterprise_plan_team_id") || document.getElementById("team_id")
|
||||
let planRequest
|
||||
let lastValue = Number(teamIdField.value)
|
||||
let currentValue = lastValue
|
||||
|
||||
setTimeout(prefillCallback, CHECK_INTERVAL)
|
||||
|
||||
async function prefillCallback() {
|
||||
currentValue = Number(teamIdField.value)
|
||||
if (Number.isInteger(currentValue)
|
||||
&& currentValue > 0
|
||||
&& currentValue != lastValue
|
||||
&& !planRequest) {
|
||||
planRequest = await fetch("/crm/billing/team/" + currentValue + "/current_plan")
|
||||
const result = await planRequest.json()
|
||||
|
||||
fillForm(result)
|
||||
|
||||
lastValue = currentValue
|
||||
planRequest = null
|
||||
}
|
||||
|
||||
setTimeout(prefillCallback, CHECK_INTERVAL)
|
||||
}
|
||||
|
||||
function fillForm(result) {
|
||||
[
|
||||
'billing_interval',
|
||||
'monthly_pageview_limit',
|
||||
'site_limit',
|
||||
'team_member_limit',
|
||||
'hourly_api_request_limit'
|
||||
].forEach(name => {
|
||||
const prefillValue = result[name] || ""
|
||||
const field = document.getElementById('enterprise_plan_' + name)
|
||||
|
||||
field.value = prefillValue
|
||||
field.dispatchEvent(new Event("change"))
|
||||
});
|
||||
|
||||
['stats_api', 'props', 'funnels', 'revenue_goals', 'site_segments'].forEach(feature => {
|
||||
const checked = result.features.includes(feature)
|
||||
const field = document.querySelector(`input[type=checkbox][value=${feature}]`)
|
||||
if (field) {
|
||||
field.checked = checked
|
||||
}
|
||||
});
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
""")
|
||||
]
|
||||
end
|
||||
|
||||
def javascripts(%{assigns: %{context: context}})
|
||||
when context in ["teams", "sites", "billing"] do
|
||||
[
|
||||
Phoenix.HTML.raw("""
|
||||
<script type="text/javascript">
|
||||
(() => {
|
||||
const publicField = document.querySelector("#kaffy-search-field")
|
||||
const searchForm = document.querySelector("#kaffy-filters-form")
|
||||
const searchField = document.querySelector("#kaffy-filter-search")
|
||||
|
||||
if (publicField && searchForm && searchField) {
|
||||
publicField.name = "#{@custom_search}"
|
||||
searchField.name = "#{@custom_search}"
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
publicField.value = params.get("#{@custom_search}")
|
||||
|
||||
const searchInput = document.createElement("input")
|
||||
searchInput.name = "search"
|
||||
searchInput.type = "hidden"
|
||||
searchInput.value = ""
|
||||
|
||||
searchForm.appendChild(searchInput)
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
""")
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
def javascripts(_) do
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
defmodule Plausible.SiteAdmin do
|
||||
use Plausible.Repo
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Plausible.Teams
|
||||
|
||||
def ordering(_schema) do
|
||||
[desc: :inserted_at]
|
||||
end
|
||||
|
||||
def search_fields(_schema) do
|
||||
[
|
||||
:domain
|
||||
]
|
||||
end
|
||||
|
||||
def custom_index_query(conn, _schema, query) do
|
||||
search =
|
||||
(conn.params["custom_search"] || "")
|
||||
|> String.trim()
|
||||
|> String.replace("%", "\%")
|
||||
|> String.replace("_", "\_")
|
||||
|
||||
search_term = "%#{search}%"
|
||||
|
||||
member_query =
|
||||
from s in Plausible.Site,
|
||||
left_join: gm in assoc(s, :guest_memberships),
|
||||
left_join: tm in assoc(gm, :team_membership),
|
||||
left_join: u in assoc(tm, :user),
|
||||
where: s.id == parent_as(:site).id,
|
||||
where: ilike(u.email, ^search_term) or ilike(u.name, ^search_term),
|
||||
select: 1
|
||||
|
||||
from(r in query,
|
||||
as: :site,
|
||||
inner_join: o in assoc(r, :owners),
|
||||
inner_join: t in assoc(r, :team),
|
||||
preload: [owners: o, team: t, guest_memberships: [team_membership: :user]],
|
||||
or_where: type(t.identifier, :string) == ^search,
|
||||
or_where: ilike(t.name, ^search_term),
|
||||
or_where: ilike(r.domain, ^search_term),
|
||||
or_where: ilike(o.email, ^search_term),
|
||||
or_where: ilike(o.name, ^search_term),
|
||||
or_where: exists(member_query)
|
||||
)
|
||||
end
|
||||
|
||||
def before_update(_conn, changeset) do
|
||||
if Ecto.Changeset.get_change(changeset, :native_stats_start_at) do
|
||||
{:ok, Ecto.Changeset.put_change(changeset, :stats_start_date, nil)}
|
||||
else
|
||||
{:ok, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
def form_fields(_) do
|
||||
[
|
||||
domain: %{update: :readonly},
|
||||
timezone: %{choices: Plausible.Timezones.options()},
|
||||
public: nil,
|
||||
native_stats_start_at: %{
|
||||
type: :string,
|
||||
label: "Native stats start time",
|
||||
help_text:
|
||||
"Cutoff time for native stats in UTC timezone. Expected format: YYYY-MM-DDTHH:mm:ss"
|
||||
},
|
||||
ingest_rate_limit_scale_seconds: %{
|
||||
help_text: "Time scale for which events rate-limiting is calculated. Default: 60"
|
||||
},
|
||||
ingest_rate_limit_threshold: %{
|
||||
help_text:
|
||||
"Keep empty to disable rate limiting, set to 0 to bar all events. Any positive number sets the limit."
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def index(_) do
|
||||
[
|
||||
domain: nil,
|
||||
inserted_at: %{name: "Created at", value: &format_date(&1.inserted_at)},
|
||||
timezone: nil,
|
||||
public: nil,
|
||||
team: %{value: &get_team/1},
|
||||
owners: %{value: &get_owners/1},
|
||||
other_guest_members: %{value: &get_other_members/1},
|
||||
limits: %{
|
||||
value: fn site ->
|
||||
rate_limiting_status =
|
||||
case site.ingest_rate_limit_threshold do
|
||||
nil -> ""
|
||||
0 -> "🛑 BLOCKED"
|
||||
n -> "⏱ #{n}/#{site.ingest_rate_limit_scale_seconds}s (per server)"
|
||||
end
|
||||
|
||||
team_limits =
|
||||
if site.team.accept_traffic_until &&
|
||||
Date.after?(Date.utc_today(), site.team.accept_traffic_until) do
|
||||
"💸 Rejecting traffic"
|
||||
end
|
||||
|
||||
{:safe, Enum.join([rate_limiting_status, team_limits], "<br/><br/>")}
|
||||
end
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def list_actions(_conn) do
|
||||
[
|
||||
transfer_ownership: %{
|
||||
name: "Transfer ownership",
|
||||
inputs: [
|
||||
%{name: "email", title: "New Owner Email", default: nil}
|
||||
],
|
||||
action: fn conn, sites, params -> transfer_ownership(conn, sites, params) end
|
||||
},
|
||||
transfer_ownership_direct: %{
|
||||
name: "Transfer ownership without invite",
|
||||
inputs: [
|
||||
%{name: "email", title: "New Owner Email", default: nil},
|
||||
%{name: "team_id", title: "Team Identifier", default: nil}
|
||||
],
|
||||
action: fn conn, sites, params -> transfer_ownership_direct(conn, sites, params) end
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
defp transfer_ownership(_conn, [], _params) do
|
||||
{:error, "Please select at least one site from the list"}
|
||||
end
|
||||
|
||||
defp transfer_ownership(conn, sites, %{"email" => email}) do
|
||||
inviter = conn.assigns.current_user
|
||||
|
||||
with {:ok, new_owner} <- Plausible.Auth.get_user_by(email: email),
|
||||
{:ok, _} <-
|
||||
Teams.Invitations.InviteToSite.bulk_invite(
|
||||
sites,
|
||||
inviter,
|
||||
new_owner.email,
|
||||
:owner,
|
||||
check_permissions: false
|
||||
) do
|
||||
:ok
|
||||
else
|
||||
{:error, :user_not_found} ->
|
||||
{:error, "User could not be found"}
|
||||
|
||||
{:error, %Ecto.Changeset{}} ->
|
||||
{:error, "Site transfer request has failed for one of the sites"}
|
||||
end
|
||||
end
|
||||
|
||||
defp transfer_ownership_direct(_conn, [], _params) do
|
||||
{:error, "Please select at least one site from the list"}
|
||||
end
|
||||
|
||||
defp transfer_ownership_direct(_conn, sites, %{"email" => email} = params) do
|
||||
with {:ok, new_owner} <- Plausible.Auth.get_user_by(email: email),
|
||||
{:ok, team} <- get_team_by_id(params["team_id"]),
|
||||
{:ok, _} <- Teams.Sites.Transfer.bulk_transfer(sites, new_owner, team) do
|
||||
:ok
|
||||
else
|
||||
{:error, :user_not_found} ->
|
||||
{:error, "User could not be found"}
|
||||
|
||||
{:error, :transfer_to_self} ->
|
||||
{:error, "The site is already in the picked team"}
|
||||
|
||||
{:error, :no_plan} ->
|
||||
{:error, "The new owner does not have a subscription"}
|
||||
|
||||
{:error, :multiple_teams} ->
|
||||
{:error, "The new owner owns more than one team"}
|
||||
|
||||
{:error, :permission_denied} ->
|
||||
{:error, "The new owner can't add sites in the selected team"}
|
||||
|
||||
{:error, :invalid_team_id} ->
|
||||
{:error, "The provided team identifier is invalid"}
|
||||
|
||||
{:error, {:over_plan_limits, limits}} ->
|
||||
{:error, "Plan limits exceeded for one of the sites: #{Enum.join(limits, ", ")}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_team_by_id(id) when is_binary(id) and byte_size(id) > 0 do
|
||||
case Ecto.UUID.cast(id) do
|
||||
{:ok, team_id} ->
|
||||
{:ok, Teams.get(team_id)}
|
||||
|
||||
:error ->
|
||||
{:error, :invalid_team_id}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_team_by_id(_) do
|
||||
{:ok, nil}
|
||||
end
|
||||
|
||||
defp format_date(date) do
|
||||
Calendar.strftime(date, "%b %-d, %Y")
|
||||
end
|
||||
|
||||
defp get_team(site) do
|
||||
team_name =
|
||||
case site.owners do
|
||||
[owner] ->
|
||||
if site.team.setup_complete do
|
||||
site.team.name
|
||||
else
|
||||
owner.name
|
||||
end
|
||||
|
||||
[_ | _] ->
|
||||
site.team.name
|
||||
end
|
||||
|> html_escape()
|
||||
|
||||
"""
|
||||
<a href="/crm/teams/team/#{site.team.id}">#{team_name}</a>
|
||||
"""
|
||||
|> Phoenix.HTML.raw()
|
||||
end
|
||||
|
||||
defp get_owners(site) do
|
||||
owners_html =
|
||||
Enum.map(site.owners, fn owner ->
|
||||
escaped_name = html_escape(owner.name)
|
||||
escaped_email = html_escape(owner.email)
|
||||
|
||||
"""
|
||||
<a href="/crm/auth/user/#{owner.id}">#{escaped_name}</a>
|
||||
<br/>
|
||||
#{escaped_email}
|
||||
"""
|
||||
end)
|
||||
|
||||
{:safe, Enum.join(owners_html, "<br/><br/>")}
|
||||
end
|
||||
|
||||
defp get_other_members(site) do
|
||||
site.guest_memberships
|
||||
|> Enum.map_join(", ", fn m ->
|
||||
id = m.team_membership.user.id
|
||||
email = html_escape(m.team_membership.user.email)
|
||||
role = html_escape(m.role)
|
||||
|
||||
"""
|
||||
<a href="/crm/auth/user/#{id}">#{email} (guest #{role})</a>
|
||||
"""
|
||||
end)
|
||||
|> Phoenix.HTML.raw()
|
||||
end
|
||||
|
||||
def get_struct_fields(module) do
|
||||
module.__struct__()
|
||||
|> Map.drop([:__meta__, :__struct__])
|
||||
|> Map.keys()
|
||||
|> Enum.map(&Atom.to_string/1)
|
||||
|> Enum.sort()
|
||||
end
|
||||
|
||||
def create_changeset(schema, attrs), do: Plausible.Site.crm_changeset(schema, attrs)
|
||||
def update_changeset(schema, attrs), do: Plausible.Site.crm_changeset(schema, attrs)
|
||||
|
||||
def html_escape(string) do
|
||||
string
|
||||
|> Phoenix.HTML.html_escape()
|
||||
|> Phoenix.HTML.safe_to_string()
|
||||
end
|
||||
end
|
||||
|
|
@ -37,17 +37,6 @@ defmodule Plausible.Teams.Invitations.InviteToSite do
|
|||
end)
|
||||
end
|
||||
|
||||
# XXX: remove with kaffy
|
||||
@spec bulk_invite([Site.t()], User.t(), String.t(), atom(), Keyword.t()) ::
|
||||
{:ok, [invitation]} | {:error, invite_error()}
|
||||
def bulk_invite(sites, inviter, invitee_email, role, opts \\ []) do
|
||||
Repo.transaction(fn ->
|
||||
for site <- sites do
|
||||
do_invite(site, inviter, invitee_email, role, opts)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_invite(site, inviter, invitee_email, role, opts \\ []) do
|
||||
with site <- Repo.preload(site, [:owners, :team]),
|
||||
:ok <- Teams.Invitations.check_invitation_permissions(site, inviter, role, opts),
|
||||
|
|
|
|||
|
|
@ -17,23 +17,6 @@ defmodule Plausible.Teams.Sites.Transfer do
|
|||
| :multiple_teams
|
||||
| :permission_denied
|
||||
|
||||
# XXX: remove with kaffy
|
||||
@spec bulk_transfer([Site.t()], Auth.User.t(), Teams.Team.t() | nil) ::
|
||||
{:ok, [Teams.Membership.t()]} | {:error, transfer_error()}
|
||||
def bulk_transfer(sites, new_owner, team \\ nil) do
|
||||
Repo.transaction(fn ->
|
||||
for site <- sites do
|
||||
case transfer_ownership(site, new_owner, team) do
|
||||
{:ok, membership} ->
|
||||
membership
|
||||
|
||||
{:error, error} ->
|
||||
Repo.rollback(error)
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@spec change_team(Site.t(), Auth.User.t(), Teams.Team.t()) ::
|
||||
:ok | {:error, transfer_error()}
|
||||
def change_team(site, user, new_team) do
|
||||
|
|
|
|||
|
|
@ -1,251 +0,0 @@
|
|||
defmodule Plausible.Teams.TeamAdmin do
|
||||
@moduledoc """
|
||||
Kaffy CRM definition for Team.
|
||||
"""
|
||||
|
||||
use Plausible
|
||||
use Plausible.Repo
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Plausible.Billing.Subscription
|
||||
alias Plausible.Teams
|
||||
|
||||
require Plausible.Billing.Subscription.Status
|
||||
|
||||
def widgets(_schema, _conn) do
|
||||
setup_teams_count =
|
||||
Repo.aggregate(from(t in Teams.Team, where: t.setup_complete == true), :count)
|
||||
|
||||
[
|
||||
%{
|
||||
type: "tidbit",
|
||||
title: "Setup Teams",
|
||||
content: to_string(setup_teams_count),
|
||||
icon: nil,
|
||||
order: 1,
|
||||
width: 6
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def custom_index_query(conn, _schema, query) do
|
||||
search =
|
||||
(conn.params["custom_search"] || "")
|
||||
|> String.trim()
|
||||
|> String.replace("%", "\%")
|
||||
|> String.replace("_", "\_")
|
||||
|
||||
search_term = "%#{search}%"
|
||||
|
||||
member_query =
|
||||
from t in Plausible.Teams.Team,
|
||||
left_join: tm in assoc(t, :team_memberships),
|
||||
left_join: u in assoc(tm, :user),
|
||||
where: t.id == parent_as(:team).id,
|
||||
where: ilike(u.email, ^search_term) or ilike(u.name, ^search_term),
|
||||
select: 1
|
||||
|
||||
from(t in query,
|
||||
as: :team,
|
||||
left_lateral_join: s in subquery(Teams.last_subscription_join_query()),
|
||||
on: true,
|
||||
preload: [:owners, team_memberships: :user, subscription: s],
|
||||
or_where: ilike(t.name, ^search_term),
|
||||
or_where: exists(member_query)
|
||||
)
|
||||
end
|
||||
|
||||
def update_changeset(entry, attrs) do
|
||||
Teams.Team.crm_changeset(entry, attrs)
|
||||
end
|
||||
|
||||
def index(_) do
|
||||
[
|
||||
name: %{value: &team_name/1},
|
||||
inserted_at: %{name: "Created at", value: &format_date(&1.inserted_at)},
|
||||
setup: %{value: &if(&1.setup_complete, do: "Yes", else: "No")},
|
||||
owners: %{value: &get_owners/1},
|
||||
other_members: %{value: &get_other_members/1},
|
||||
trial_expiry_date: %{name: "Trial expiry", value: &format_date(&1.trial_expiry_date)},
|
||||
subscription_plan: %{value: &Phoenix.HTML.raw(subscription_plan(&1))},
|
||||
subscription_status: %{value: &Phoenix.HTML.raw(subscription_status(&1))},
|
||||
grace_period: %{value: &Phoenix.HTML.raw(grace_period_status(&1))},
|
||||
accept_traffic_until: %{
|
||||
name: "Accept traffic until",
|
||||
value: &format_date(&1.accept_traffic_until)
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def form_fields(_) do
|
||||
[
|
||||
identifier: %{create: :hidden, update: :readonly},
|
||||
name: nil,
|
||||
trial_expiry_date: %{
|
||||
help_text: "Change will also update Accept Traffic Until date"
|
||||
},
|
||||
allow_next_upgrade_override: nil,
|
||||
accept_traffic_until: %{
|
||||
help_text: "Change will take up to 15 minutes to propagate"
|
||||
},
|
||||
notes: %{type: :textarea, rows: 6}
|
||||
]
|
||||
end
|
||||
|
||||
def resource_actions(_) do
|
||||
[
|
||||
unlock: %{
|
||||
name: "Unlock",
|
||||
action: fn _, team -> unlock(team) end
|
||||
},
|
||||
lock: %{
|
||||
name: "Lock",
|
||||
action: fn _, team -> lock(team) end
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def delete(_conn, %{data: team}) do
|
||||
case Teams.delete(team) do
|
||||
{:ok, :deleted} ->
|
||||
{:ok, team}
|
||||
|
||||
{:error, :active_subscription} ->
|
||||
{team, "The team has an active subscription which must be canceled first."}
|
||||
end
|
||||
end
|
||||
|
||||
def grace_period_status(team) do
|
||||
grace_period = team.grace_period
|
||||
|
||||
case grace_period do
|
||||
nil ->
|
||||
"--"
|
||||
|
||||
%{manual_lock: true, is_over: true} ->
|
||||
"Manually locked"
|
||||
|
||||
%{manual_lock: true, is_over: false} ->
|
||||
"Waiting for manual lock"
|
||||
|
||||
%{is_over: true} ->
|
||||
"ended"
|
||||
|
||||
%{end_date: %Date{} = end_date} ->
|
||||
days_left = Date.diff(end_date, Date.utc_today())
|
||||
"#{days_left} days left"
|
||||
end
|
||||
end
|
||||
|
||||
def subscription_plan(team) do
|
||||
subscription = team.subscription
|
||||
|
||||
if Subscription.Status.active?(subscription) && subscription.paddle_subscription_id do
|
||||
quota = PlausibleWeb.AuthView.subscription_quota(subscription)
|
||||
interval = PlausibleWeb.AuthView.subscription_interval(subscription)
|
||||
|
||||
~s(<a href="#{manage_url(subscription)}">#{quota} \(#{interval}\)</a>)
|
||||
else
|
||||
"--"
|
||||
end
|
||||
end
|
||||
|
||||
def subscription_status(team) do
|
||||
cond do
|
||||
team && team.subscription ->
|
||||
status_str =
|
||||
PlausibleWeb.SettingsView.present_subscription_status(team.subscription.status)
|
||||
|
||||
if team.subscription.paddle_subscription_id do
|
||||
~s(<a href="#{manage_url(team.subscription)}">#{status_str}</a>)
|
||||
else
|
||||
status_str
|
||||
end
|
||||
|
||||
Plausible.Teams.on_trial?(team) ->
|
||||
"On trial"
|
||||
|
||||
true ->
|
||||
"Trial expired"
|
||||
end
|
||||
end
|
||||
|
||||
defp lock(team) do
|
||||
if team.grace_period do
|
||||
Plausible.Billing.SiteLocker.set_lock_status_for(team, true)
|
||||
Plausible.Teams.end_grace_period(team)
|
||||
{:ok, team}
|
||||
else
|
||||
{:error, team, "No active grace period on this team"}
|
||||
end
|
||||
end
|
||||
|
||||
defp unlock(team) do
|
||||
if team.grace_period do
|
||||
Plausible.Teams.remove_grace_period(team)
|
||||
Plausible.Billing.SiteLocker.set_lock_status_for(team, false)
|
||||
{:ok, team}
|
||||
else
|
||||
{:error, team, "No active grace period on this team"}
|
||||
end
|
||||
end
|
||||
|
||||
defp team_name(team) do
|
||||
case team.owners do
|
||||
[owner] ->
|
||||
if team.setup_complete do
|
||||
team.name
|
||||
else
|
||||
owner.name
|
||||
end
|
||||
|
||||
[_ | _] ->
|
||||
team.name
|
||||
end
|
||||
end
|
||||
|
||||
defp manage_url(%{paddle_subscription_id: paddle_id} = _subscription) do
|
||||
Plausible.Billing.PaddleApi.vendors_domain() <>
|
||||
"/subscriptions/customers/manage/" <> paddle_id
|
||||
end
|
||||
|
||||
defp get_owners(team) do
|
||||
team.owners
|
||||
|> Enum.map_join("<br><br>\n", fn owner ->
|
||||
name = html_escape(owner.name)
|
||||
email = html_escape(owner.email)
|
||||
|
||||
"""
|
||||
<a href="/crm/auth/user/#{owner.id}">#{name}</a><br>#{email}
|
||||
"""
|
||||
end)
|
||||
|> Phoenix.HTML.raw()
|
||||
end
|
||||
|
||||
defp get_other_members(team) do
|
||||
team.team_memberships
|
||||
|> Enum.reject(&(&1.role == :owner))
|
||||
|> Enum.map_join("<br>\n", fn tm ->
|
||||
email = html_escape(tm.user.email)
|
||||
role = html_escape(tm.role)
|
||||
|
||||
"""
|
||||
<a href="/crm/auth/user/#{tm.user.id}">#{email <> " (#{role})"}</a>
|
||||
"""
|
||||
end)
|
||||
|> Phoenix.HTML.raw()
|
||||
end
|
||||
|
||||
defp format_date(nil), do: "--"
|
||||
|
||||
defp format_date(date) do
|
||||
Calendar.strftime(date, "%b %-d, %Y")
|
||||
end
|
||||
|
||||
def html_escape(string) do
|
||||
string
|
||||
|> Phoenix.HTML.html_escape()
|
||||
|> Phoenix.HTML.safe_to_string()
|
||||
end
|
||||
end
|
||||
|
|
@ -192,7 +192,13 @@ defmodule Plausible.Release do
|
|||
|
||||
monthly_cost = plan.monthly_cost && Money.to_decimal(plan.monthly_cost)
|
||||
yearly_cost = plan.yearly_cost && Money.to_decimal(plan.yearly_cost)
|
||||
{:ok, features} = Plausible.Billing.Ecto.FeatureList.dump(plan.features)
|
||||
|
||||
features =
|
||||
Enum.map(plan.features, fn f ->
|
||||
{:ok, feat} = Plausible.Billing.Ecto.Feature.dump(f)
|
||||
feat
|
||||
end)
|
||||
|
||||
{:ok, team_member_limit} = Plausible.Billing.Ecto.Limit.dump(plan.team_member_limit)
|
||||
|
||||
plan
|
||||
|
|
|
|||
|
|
@ -1,307 +0,0 @@
|
|||
defmodule PlausibleWeb.AdminController do
|
||||
use PlausibleWeb, :controller
|
||||
use Plausible
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Plausible.Repo
|
||||
alias Plausible.Teams
|
||||
|
||||
def usage(conn, params) do
|
||||
team_id = String.to_integer(params["team_id"])
|
||||
|
||||
team =
|
||||
team_id
|
||||
|> Teams.get()
|
||||
|> Repo.preload([:owners, team_memberships: :user])
|
||||
|> Teams.with_subscription()
|
||||
|
||||
usage = Teams.Billing.quota_usage(team, with_features: true)
|
||||
|
||||
limits = %{
|
||||
monthly_pageviews: Teams.Billing.monthly_pageview_limit(team),
|
||||
sites: Teams.Billing.site_limit(team),
|
||||
team_members: Teams.Billing.team_member_limit(team)
|
||||
}
|
||||
|
||||
html_response = usage_and_limits_html(team, usage, limits, params["embed"] == "true")
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("text/html")
|
||||
|> send_resp(200, html_response)
|
||||
end
|
||||
|
||||
def user_info(conn, params) do
|
||||
user_id = String.to_integer(params["user_id"])
|
||||
|
||||
user =
|
||||
Plausible.Auth.User
|
||||
|> Repo.get!(user_id)
|
||||
|> Repo.preload(:owned_teams)
|
||||
|
||||
teams_list = Plausible.Auth.UserAdmin.teams(user.owned_teams)
|
||||
|
||||
html_response = """
|
||||
<a style="margin-bottom: 2em; float: right;" target="_blank" href="/cs/users/user/#{user_id}">Open in CS</a>
|
||||
<div style="margin-bottom: 1.1em;">
|
||||
<p><b>Owned teams:</b></p>
|
||||
#{teams_list}
|
||||
</div>
|
||||
"""
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("text/html")
|
||||
|> send_resp(200, html_response)
|
||||
end
|
||||
|
||||
def current_plan(conn, params) do
|
||||
team_id = String.to_integer(params["team_id"])
|
||||
|
||||
team =
|
||||
team_id
|
||||
|> Teams.get()
|
||||
|> Teams.with_subscription()
|
||||
|
||||
plan =
|
||||
case team && team.subscription &&
|
||||
Plausible.Billing.Plans.get_subscription_plan(team.subscription) do
|
||||
%{} = plan ->
|
||||
plan
|
||||
|> Map.take([
|
||||
:billing_interval,
|
||||
:monthly_pageview_limit,
|
||||
:site_limit,
|
||||
:team_member_limit,
|
||||
:hourly_api_request_limit,
|
||||
:features
|
||||
])
|
||||
|> Map.update(:features, [], fn features -> Enum.map(features, & &1.name()) end)
|
||||
|
||||
_ ->
|
||||
%{features: []}
|
||||
end
|
||||
|
||||
json_response = Jason.encode!(plan)
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("application/json")
|
||||
|> send_resp(200, json_response)
|
||||
end
|
||||
|
||||
def team_by_id(conn, params) do
|
||||
id = params["team_id"]
|
||||
|
||||
entry =
|
||||
Repo.one(
|
||||
from t in Plausible.Teams.Team,
|
||||
inner_join: o in assoc(t, :owners),
|
||||
where: t.id == ^id,
|
||||
group_by: t.id,
|
||||
select:
|
||||
fragment(
|
||||
"""
|
||||
case when ? = false then
|
||||
string_agg(concat(?, ' (', ?, ')'), ',')
|
||||
else
|
||||
concat(?, ' [', string_agg(concat(?, ' (', ?, ')'), ','), ']')
|
||||
end
|
||||
""",
|
||||
t.setup_complete,
|
||||
o.name,
|
||||
o.email,
|
||||
t.name,
|
||||
o.name,
|
||||
o.email
|
||||
)
|
||||
) || ""
|
||||
|
||||
conn
|
||||
|> send_resp(200, entry)
|
||||
end
|
||||
|
||||
def team_search(conn, params) do
|
||||
search =
|
||||
(params["search"] || "")
|
||||
|> String.trim()
|
||||
|
||||
choices =
|
||||
if search != "" do
|
||||
term =
|
||||
search
|
||||
|> String.replace("%", "\%")
|
||||
|> String.replace("_", "\_")
|
||||
|
||||
term = "%#{term}%"
|
||||
|
||||
team_id =
|
||||
case Integer.parse(search) do
|
||||
{id, ""} -> id
|
||||
_ -> 0
|
||||
end
|
||||
|
||||
if team_id != 0 do
|
||||
[]
|
||||
else
|
||||
Repo.all(
|
||||
from t in Teams.Team,
|
||||
inner_join: o in assoc(t, :owners),
|
||||
where:
|
||||
t.id == ^team_id or
|
||||
type(t.identifier, :string) == ^search or
|
||||
ilike(t.name, ^term) or
|
||||
ilike(o.email, ^term) or
|
||||
ilike(o.name, ^term),
|
||||
order_by: [t.name, t.id],
|
||||
group_by: t.id,
|
||||
select: [
|
||||
fragment(
|
||||
"""
|
||||
case when ? = false then
|
||||
concat(string_agg(concat(?, ' (', ?, ')'), ','), ' - ', ?)
|
||||
else
|
||||
concat(concat(?, ' [', string_agg(concat(?, ' (', ?, ')'), ','), ']'), ' - ', ?)
|
||||
end
|
||||
""",
|
||||
t.setup_complete,
|
||||
o.name,
|
||||
o.email,
|
||||
t.identifier,
|
||||
t.name,
|
||||
o.name,
|
||||
o.email,
|
||||
t.identifier
|
||||
),
|
||||
t.id
|
||||
],
|
||||
limit: 20
|
||||
)
|
||||
end
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("application/json")
|
||||
|> send_resp(200, Jason.encode!(choices))
|
||||
end
|
||||
|
||||
defp usage_and_limits_html(team, usage, limits, embed?) do
|
||||
content = """
|
||||
<a style="margin-bottom: 2em; float: right;" target="_blank" href="/cs/teams/team/#{team.id}">Open in CS</a>
|
||||
<ul>
|
||||
<li>Team: <b>#{html_escape(Teams.name(team))}</b></li>
|
||||
<li>Setup: <b>#{if(team.setup_complete, do: "Yes", else: "No")}</b></li>
|
||||
<li>Subscription plan: #{Teams.TeamAdmin.subscription_plan(team)}</li>
|
||||
<li>Subscription status: #{Teams.TeamAdmin.subscription_status(team)}</li>
|
||||
<li>Grace period: #{Teams.TeamAdmin.grace_period_status(team)}</li>
|
||||
<li>Sites: <b>#{usage.sites}</b> / #{limits.sites}</li>
|
||||
<li>Team members: <b>#{usage.team_members}</b> / #{limits.team_members}</li>
|
||||
<li>Features: #{features_usage(usage.features)}</li>
|
||||
<li>Monthly pageviews: #{monthly_pageviews_usage(usage.monthly_pageviews, limits.monthly_pageviews)}</li>
|
||||
#{sites_count_row(team)}
|
||||
<li>Owners: #{get_owners(team)}</li>
|
||||
<li>Team members: #{get_other_members(team)}</li>
|
||||
</ul>
|
||||
"""
|
||||
|
||||
if embed? do
|
||||
content
|
||||
else
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Usage - team:#{team && team.id}</title>
|
||||
<style>
|
||||
ul, li {margin-top: 10px;}
|
||||
body {padding-top: 10px;}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
#{content}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
alias PlausibleWeb.Router.Helpers, as: Routes
|
||||
|
||||
defp sites_count_row(%Plausible.Teams.Team{} = team) do
|
||||
sites_count =
|
||||
team
|
||||
|> Ecto.assoc(:sites)
|
||||
|> Plausible.Repo.aggregate(:count)
|
||||
|
||||
sites_link =
|
||||
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site,
|
||||
custom_search: team.identifier
|
||||
)
|
||||
|
||||
"""
|
||||
<li>Owner of <a href="#{sites_link}">#{sites_count} site#{if sites_count != 1, do: "s", else: ""}</a></li>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
defp sites_count_row(_) do
|
||||
"""
|
||||
<li>Owner of 0 sites</li>
|
||||
"""
|
||||
end
|
||||
|
||||
defp features_usage(features_module_list) do
|
||||
list_items =
|
||||
features_module_list
|
||||
|> Enum.map_join(fn f_mod -> "<li>#{f_mod.display_name()}</li>" end)
|
||||
|
||||
"<ul>#{list_items}</ul>"
|
||||
end
|
||||
|
||||
defp monthly_pageviews_usage(usage, limit) do
|
||||
list_items =
|
||||
usage
|
||||
|> Enum.sort_by(fn {_cycle, usage} -> usage.date_range.first end, :desc)
|
||||
|> Enum.map(fn {cycle, usage} ->
|
||||
"<li>#{cycle} (#{PlausibleWeb.TextHelpers.format_date_range(usage.date_range)}): <b>#{usage.total}</b> / #{limit}</li>"
|
||||
end)
|
||||
|
||||
"<ul>#{Enum.join(list_items)}</ul>"
|
||||
end
|
||||
|
||||
defp get_owners(team) do
|
||||
team.owners
|
||||
|> Enum.map_join(", ", fn owner ->
|
||||
email = html_escape(owner.email)
|
||||
|
||||
"""
|
||||
<a href="/crm/auth/user/#{owner.id}">#{email}</a>
|
||||
"""
|
||||
end)
|
||||
end
|
||||
|
||||
defp get_other_members(team) do
|
||||
team.team_memberships
|
||||
|> Enum.reject(&(&1.role == :owner))
|
||||
|> Enum.map_join(", ", fn tm ->
|
||||
email = html_escape(tm.user.email)
|
||||
role = html_escape(tm.role)
|
||||
|
||||
"""
|
||||
<a href="/crm/auth/user/#{tm.user.id}">#{email <> " (#{role})"}</a>
|
||||
"""
|
||||
end)
|
||||
end
|
||||
|
||||
def html_escape(string) do
|
||||
string
|
||||
|> Phoenix.HTML.html_escape()
|
||||
|> Phoenix.HTML.safe_to_string()
|
||||
end
|
||||
end
|
||||
|
|
@ -60,15 +60,6 @@ defmodule PlausibleWeb.Endpoint do
|
|||
[at: "/", from: :plausible, only: static_paths] ++ static_compression
|
||||
)
|
||||
|
||||
on_ee do
|
||||
plug(Plug.Static,
|
||||
at: "/kaffy",
|
||||
from: :kaffy,
|
||||
gzip: false,
|
||||
only: ~w(assets)
|
||||
)
|
||||
end
|
||||
|
||||
# Code reloading can be explicitly enabled under the
|
||||
# :code_reloader configuration of your endpoint.
|
||||
if code_reloading? do
|
||||
|
|
|
|||
|
|
@ -87,27 +87,6 @@ defmodule PlausibleWeb.Router do
|
|||
live_storybook("/storybook", backend_module: PlausibleWeb.Storybook)
|
||||
end
|
||||
|
||||
on_ee do
|
||||
use Kaffy.Routes,
|
||||
scope: "/crm",
|
||||
pipe_through: [
|
||||
PlausibleWeb.Plugs.NoRobots,
|
||||
PlausibleWeb.AuthPlug,
|
||||
PlausibleWeb.SuperAdminOnlyPlug
|
||||
]
|
||||
end
|
||||
|
||||
on_ee do
|
||||
scope "/crm", PlausibleWeb do
|
||||
pipe_through :flags
|
||||
get "/teams/team/:team_id/usage", AdminController, :usage
|
||||
get "/auth/user/:user_id/info", AdminController, :user_info
|
||||
get "/billing/team/:team_id/current_plan", AdminController, :current_plan
|
||||
get "/billing/search/team-by-id/:team_id", AdminController, :team_by_id
|
||||
post "/billing/search/team", AdminController, :team_search
|
||||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
scope alias: PlausibleWeb.Live,
|
||||
assigns: %{connect_live_socket: true, skip_plausible_tracking: true} do
|
||||
|
|
|
|||
|
|
@ -35,14 +35,6 @@
|
|||
ee?() && @conn.assigns[:site] &&
|
||||
Plausible.Auth.is_super_admin?(@conn.assigns[:current_user])
|
||||
}>
|
||||
<.styled_link
|
||||
class="text-sm mr-6"
|
||||
href={PlausibleWeb.Endpoint.url() <> "/crm/sites/site/#{@conn.assigns.site.id}"}
|
||||
new_tab={true}
|
||||
>
|
||||
CRM
|
||||
</.styled_link>
|
||||
|
||||
<.styled_link
|
||||
class="text-sm mr-6"
|
||||
href={"/cs/sites/site/#{@conn.assigns.site.id}"}
|
||||
|
|
|
|||
|
|
@ -48,13 +48,6 @@
|
|||
<span class="text-gray-500 dark:text-gray-400">
|
||||
{membership.user.email}
|
||||
</span>
|
||||
<.styled_link
|
||||
:if={ee?() and Plausible.Auth.is_super_admin?(@current_user)}
|
||||
new_tab={true}
|
||||
href={PlausibleWeb.Endpoint.url() <> "/crm/auth/user/#{membership.user.id}"}
|
||||
>
|
||||
CRM
|
||||
</.styled_link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
2
mix.exs
2
mix.exs
|
|
@ -91,8 +91,6 @@ defmodule Plausible.MixProject do
|
|||
{:gen_cycle, "~> 1.0.4"},
|
||||
{:hackney, "~> 1.8"},
|
||||
{:jason, "~> 1.3"},
|
||||
{:kaffy,
|
||||
git: "https://github.com/aesmail/kaffy.git", only: [:dev, :test, :staging, :prod, :load]},
|
||||
{:location, git: "https://github.com/plausible/location.git"},
|
||||
{:mox, "~> 1.0", only: [:test, :ce_test]},
|
||||
{:nanoid, "~> 2.1.0"},
|
||||
|
|
|
|||
1
mix.lock
1
mix.lock
|
|
@ -73,7 +73,6 @@
|
|||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||
"joken": {:hex, :joken, "2.6.0", "b9dd9b6d52e3e6fcb6c65e151ad38bf4bc286382b5b6f97079c47ade6b1bcc6a", [:mix], [{:jose, "~> 1.11.5", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5a95b05a71cd0b54abd35378aeb1d487a23a52c324fa7efdffc512b655b5aaa7"},
|
||||
"jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"},
|
||||
"kaffy": {:git, "https://github.com/aesmail/kaffy.git", "87e2d5ec95e1628fe23dc02f4acf3a1d572d77e4", []},
|
||||
"location": {:git, "https://github.com/plausible/location.git", "a89bf79985c3c3d0830477ae587001156a646ce8", []},
|
||||
"locus": {:hex, :locus, "2.3.11", "ddfab230e3fb8b45f47416ed0fb8776c6d6d00f38687f6d37647ed7502c33d8e", [:rebar3], [{:tls_certificate_check, "~> 1.9", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "ad855e9b998adc6ec5c57b9d0e5130b0e40a927be7b50d8e104df245c60ede1a"},
|
||||
"mail": {:hex, :mail, "0.3.1", "cb0a14e4ed8904e4e5a08214e686ccf6f9099346885db17d8c309381f865cc5c", [:mix], [], "hexpm", "1db701e89865c1d5fa296b2b57b1cd587587cca8d8a1a22892b35ef5a8e352a6"},
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
defmodule Plausible.Billing.EnterprisePlanAdminTest do
|
||||
use Plausible.DataCase, async: true
|
||||
use Plausible.Teams.Test
|
||||
|
||||
alias Plausible.Billing.EnterprisePlan
|
||||
alias Plausible.Billing.EnterprisePlanAdmin
|
||||
|
||||
@moduletag :ee_only
|
||||
|
||||
test "sanitizes number inputs and whitespace" do
|
||||
user = new_user()
|
||||
_site = new_site(owner: user)
|
||||
team = team_of(user)
|
||||
|
||||
changeset =
|
||||
EnterprisePlanAdmin.create_changeset(%EnterprisePlan{}, %{
|
||||
"team_id" => to_string(team.id),
|
||||
"paddle_plan_id" => " . 123456 ",
|
||||
"billing_interval" => "monthly",
|
||||
"monthly_pageview_limit" => "100,000,000",
|
||||
"site_limit" => " 10 ",
|
||||
"team_member_limit" => "-1 ",
|
||||
"hourly_api_request_limit" => " 1,000",
|
||||
"features" => ["goals"]
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
assert changeset.changes.team_id == team_of(user).id
|
||||
assert changeset.changes.paddle_plan_id == "123456"
|
||||
assert changeset.changes.billing_interval == :monthly
|
||||
assert changeset.changes.monthly_pageview_limit == 100_000_000
|
||||
assert changeset.changes.site_limit == 10
|
||||
assert changeset.changes.hourly_api_request_limit == 1000
|
||||
assert changeset.changes.features == [Plausible.Billing.Feature.Goals]
|
||||
end
|
||||
|
||||
test "scrubs empty attrs" do
|
||||
user = new_user()
|
||||
_site = new_site(owner: user)
|
||||
team = team_of(user)
|
||||
|
||||
changeset =
|
||||
EnterprisePlanAdmin.create_changeset(%EnterprisePlan{}, %{
|
||||
"team_id" => to_string(team.id),
|
||||
"paddle_plan_id" => " ,. ",
|
||||
"billing_interval" => "monthly",
|
||||
"monthly_pageview_limit" => "100,000,000",
|
||||
"site_limit" => " 10 ",
|
||||
"team_member_limit" => "-1 ",
|
||||
"hourly_api_request_limit" => " 1,000",
|
||||
"features" => ["goals"]
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert {_, validation: :required} = changeset.errors[:paddle_plan_id]
|
||||
end
|
||||
end
|
||||
|
|
@ -32,7 +32,7 @@ defmodule Plausible.Billing.EnterprisePlanTest do
|
|||
|> EnterprisePlan.changeset(attrs)
|
||||
|> Plausible.Repo.insert()
|
||||
|
||||
assert {"is invalid", [type: Plausible.Billing.Ecto.FeatureList, validation: :cast]} ==
|
||||
assert {"is invalid", [type: {:array, Plausible.Billing.Ecto.Feature}, validation: :cast]} ==
|
||||
changeset.errors[:features]
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,233 +0,0 @@
|
|||
defmodule Plausible.Site.AdminTest do
|
||||
use Plausible
|
||||
use Plausible.DataCase, async: true
|
||||
use Plausible.Teams.Test
|
||||
use Bamboo.Test
|
||||
|
||||
@subject_prefix if ee?(), do: "[Plausible Analytics] ", else: "[Plausible CE] "
|
||||
|
||||
setup do
|
||||
admin_user = insert(:user)
|
||||
|
||||
conn =
|
||||
%Plug.Conn{assigns: %{current_user: admin_user}}
|
||||
|> Plug.Conn.fetch_query_params()
|
||||
|
||||
transfer_action = Plausible.SiteAdmin.list_actions(conn)[:transfer_ownership][:action]
|
||||
|
||||
transfer_direct_action =
|
||||
Plausible.SiteAdmin.list_actions(conn)[:transfer_ownership_direct][:action]
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
transfer_action: transfer_action,
|
||||
transfer_direct_action: transfer_direct_action,
|
||||
conn: conn
|
||||
}}
|
||||
end
|
||||
|
||||
describe "bulk transferring site ownership" do
|
||||
test "user has to select at least one site", %{conn: conn, transfer_action: action} do
|
||||
assert action.(conn, [], %{}) == {:error, "Please select at least one site from the list"}
|
||||
end
|
||||
|
||||
test "new owner must be an existing user", %{conn: conn, transfer_action: action} do
|
||||
site = insert(:site)
|
||||
|
||||
assert action.(conn, [site], %{"email" => "random@email.com"}) ==
|
||||
{:error, "User could not be found"}
|
||||
end
|
||||
|
||||
test "initiates ownership transfer for multiple sites in one action", %{
|
||||
conn: conn,
|
||||
transfer_action: action
|
||||
} do
|
||||
current_owner = new_user()
|
||||
new_owner = new_user()
|
||||
site1 = new_site(owner: current_owner)
|
||||
site2 = new_site(owner: current_owner)
|
||||
|
||||
assert :ok = action.(conn, [site1, site2], %{"email" => new_owner.email})
|
||||
|
||||
assert_email_delivered_with(
|
||||
to: [nil: new_owner.email],
|
||||
subject: @subject_prefix <> "Request to transfer ownership of #{site1.domain}"
|
||||
)
|
||||
|
||||
assert_email_delivered_with(
|
||||
to: [nil: new_owner.email],
|
||||
subject: @subject_prefix <> "Request to transfer ownership of #{site2.domain}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "bulk transferring site ownership directly" do
|
||||
test "user has to select at least one site", %{conn: conn, transfer_direct_action: action} do
|
||||
assert action.(conn, [], %{}) == {:error, "Please select at least one site from the list"}
|
||||
end
|
||||
|
||||
test "new owner must be an existing user", %{conn: conn, transfer_direct_action: action} do
|
||||
site = new_site()
|
||||
|
||||
assert action.(conn, [site], %{"email" => "random@email.com"}) ==
|
||||
{:error, "User could not be found"}
|
||||
end
|
||||
|
||||
test "new team (via owner) can't be the same as old team", %{
|
||||
conn: conn,
|
||||
transfer_direct_action: action
|
||||
} do
|
||||
current_owner = new_user()
|
||||
site = new_site(owner: current_owner)
|
||||
|
||||
assert {:error, "The site is already in the picked team"} =
|
||||
action.(conn, [site], %{"email" => current_owner.email})
|
||||
end
|
||||
|
||||
test "the provided team identifier must be valid UUID format", %{
|
||||
conn: conn,
|
||||
transfer_direct_action: action
|
||||
} do
|
||||
today = Date.utc_today()
|
||||
current_owner = new_user()
|
||||
site = new_site(owner: current_owner)
|
||||
|
||||
new_owner =
|
||||
new_user()
|
||||
|> subscribe_to_growth_plan(last_bill_date: Date.shift(today, day: -5))
|
||||
|
||||
assert {:error, "The provided team identifier is invalid"} =
|
||||
action.(conn, [site], %{"email" => new_owner.email, "team_id" => "invalid"})
|
||||
end
|
||||
|
||||
test "new owner must be owner on a single team if no team identifier provided", %{
|
||||
conn: conn,
|
||||
transfer_direct_action: action
|
||||
} do
|
||||
today = Date.utc_today()
|
||||
current_owner = new_user()
|
||||
site = new_site(owner: current_owner)
|
||||
|
||||
new_owner =
|
||||
new_user()
|
||||
|> subscribe_to_growth_plan(last_bill_date: Date.shift(today, day: -5))
|
||||
|
||||
another_site = new_site()
|
||||
add_member(another_site.team, user: new_owner, role: :owner)
|
||||
|
||||
assert {:error, "The new owner owns more than one team"} =
|
||||
action.(conn, [site], %{"email" => new_owner.email})
|
||||
end
|
||||
|
||||
test "new owner must be permitted to add sites in the selected team", %{
|
||||
conn: conn,
|
||||
transfer_direct_action: action
|
||||
} do
|
||||
today = Date.utc_today()
|
||||
current_owner = new_user()
|
||||
site = new_site(owner: current_owner)
|
||||
|
||||
new_owner =
|
||||
new_user()
|
||||
|> subscribe_to_growth_plan(last_bill_date: Date.shift(today, day: -5))
|
||||
|
||||
another_site = new_site()
|
||||
new_team = another_site.team
|
||||
add_member(new_team, user: new_owner, role: :viewer)
|
||||
|
||||
assert {:error, "The new owner can't add sites in the selected team"} =
|
||||
action.(conn, [site], %{
|
||||
"email" => new_owner.email,
|
||||
"team_id" => new_team.identifier
|
||||
})
|
||||
end
|
||||
|
||||
test "the new owner can be the same as old owner given selected team is different", %{
|
||||
conn: conn,
|
||||
transfer_direct_action: action
|
||||
} do
|
||||
today = Date.utc_today()
|
||||
current_owner = new_user()
|
||||
site = new_site(owner: current_owner)
|
||||
|
||||
another_owner =
|
||||
new_user()
|
||||
|> subscribe_to_growth_plan(last_bill_date: Date.shift(today, day: -5))
|
||||
|
||||
another_site = new_site(owner: another_owner)
|
||||
new_team = another_site.team
|
||||
add_member(new_team, user: current_owner, role: :owner)
|
||||
|
||||
assert :ok =
|
||||
action.(conn, [site], %{
|
||||
"email" => current_owner.email,
|
||||
"team_id" => new_team.identifier
|
||||
})
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "new owner's plan must accommodate the transferred site", %{
|
||||
conn: conn,
|
||||
transfer_direct_action: action
|
||||
} do
|
||||
today = Date.utc_today()
|
||||
current_owner = new_user()
|
||||
|
||||
new_owner =
|
||||
new_user()
|
||||
|> subscribe_to_growth_plan(last_bill_date: Date.shift(today, day: -5))
|
||||
|
||||
# fills the site limit quota
|
||||
for _ <- 1..10, do: new_site(owner: new_owner)
|
||||
|
||||
site = new_site(owner: current_owner)
|
||||
|
||||
assert {:error, "Plan limits exceeded" <> _} =
|
||||
action.(conn, [site], %{"email" => new_owner.email})
|
||||
end
|
||||
|
||||
test "executes ownership transfer for multiple sites in one action", %{
|
||||
conn: conn,
|
||||
transfer_direct_action: action
|
||||
} do
|
||||
today = Date.utc_today()
|
||||
current_owner = new_user()
|
||||
|
||||
new_owner =
|
||||
new_user()
|
||||
|> subscribe_to_growth_plan(last_bill_date: Date.shift(today, day: -5))
|
||||
|
||||
site1 = new_site(owner: current_owner)
|
||||
site2 = new_site(owner: current_owner)
|
||||
|
||||
assert :ok = action.(conn, [site1, site2], %{"email" => new_owner.email})
|
||||
end
|
||||
|
||||
test "executes ownership transfer for multiple sites in one action for provided team", %{
|
||||
conn: conn,
|
||||
transfer_direct_action: action
|
||||
} do
|
||||
today = Date.utc_today()
|
||||
current_owner = new_user()
|
||||
|
||||
new_owner = new_user()
|
||||
|
||||
another_owner =
|
||||
new_user()
|
||||
|> subscribe_to_growth_plan(last_bill_date: Date.shift(today, day: -5))
|
||||
|
||||
another_site = new_site(owner: another_owner)
|
||||
another_team = another_site.team
|
||||
add_member(another_team, user: new_owner, role: :admin)
|
||||
|
||||
site1 = new_site(owner: current_owner)
|
||||
site2 = new_site(owner: current_owner)
|
||||
|
||||
assert :ok =
|
||||
action.(conn, [site1, site2], %{
|
||||
"email" => new_owner.email,
|
||||
"team_id" => another_team.identifier
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -198,67 +198,4 @@ defmodule Plausible.Teams.Invitations.InviteToSiteTest do
|
|||
InviteToSite.invite(site, inviter, "vini@plausible.test", :editor)
|
||||
end
|
||||
end
|
||||
|
||||
describe "bulk_invite/5" do
|
||||
test "initiates ownership transfer for multiple sites in one action" do
|
||||
admin_user = new_user()
|
||||
new_owner = new_user()
|
||||
|
||||
site1 = new_site(owner: admin_user)
|
||||
site2 = new_site(owner: admin_user)
|
||||
|
||||
assert {:ok, _} =
|
||||
InviteToSite.bulk_invite(
|
||||
[site1, site2],
|
||||
admin_user,
|
||||
new_owner.email,
|
||||
:owner
|
||||
)
|
||||
|
||||
assert_email_delivered_with(
|
||||
to: [nil: new_owner.email],
|
||||
subject: @subject_prefix <> "Request to transfer ownership of #{site1.domain}"
|
||||
)
|
||||
|
||||
assert_site_transfer(site1, new_owner)
|
||||
|
||||
assert_email_delivered_with(
|
||||
to: [nil: new_owner.email],
|
||||
subject: @subject_prefix <> "Request to transfer ownership of #{site2.domain}"
|
||||
)
|
||||
|
||||
assert_site_transfer(site2, new_owner)
|
||||
end
|
||||
|
||||
test "initiates ownership transfer for multiple sites in one action skipping permission checks" do
|
||||
superadmin_user = new_user()
|
||||
new_owner = new_user()
|
||||
|
||||
site1 = new_site()
|
||||
site2 = new_site()
|
||||
|
||||
assert {:ok, _} =
|
||||
InviteToSite.bulk_invite(
|
||||
[site1, site2],
|
||||
superadmin_user,
|
||||
new_owner.email,
|
||||
:owner,
|
||||
check_permissions: false
|
||||
)
|
||||
|
||||
assert_email_delivered_with(
|
||||
to: [nil: new_owner.email],
|
||||
subject: @subject_prefix <> "Request to transfer ownership of #{site1.domain}"
|
||||
)
|
||||
|
||||
assert_site_transfer(site1, new_owner)
|
||||
|
||||
assert_email_delivered_with(
|
||||
to: [nil: new_owner.email],
|
||||
subject: @subject_prefix <> "Request to transfer ownership of #{site2.domain}"
|
||||
)
|
||||
|
||||
assert_site_transfer(site2, new_owner)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -102,176 +102,4 @@ defmodule Plausible.Teams.Sites.TransferTest do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "bulk_transfer_ownership_direct/2" do
|
||||
test "transfers ownership for multiple sites in one action" do
|
||||
current_owner = new_user()
|
||||
new_owner = new_user() |> subscribe_to_growth_plan()
|
||||
site1 = new_site(owner: current_owner)
|
||||
site2 = new_site(owner: current_owner)
|
||||
|
||||
assert {:ok, _} =
|
||||
Transfer.bulk_transfer(
|
||||
[site1, site2],
|
||||
new_owner
|
||||
)
|
||||
|
||||
team = assert_team_exists(Repo.reload!(new_owner))
|
||||
assert_team_membership(new_owner, team, :owner)
|
||||
assert_team_membership(new_owner, team, :owner)
|
||||
assert_guest_membership(team, site1, current_owner, :editor)
|
||||
assert_guest_membership(team, site2, current_owner, :editor)
|
||||
end
|
||||
|
||||
test "returns error when user is already an owner for one of the sites" do
|
||||
current_owner = new_user()
|
||||
new_owner = new_user() |> subscribe_to_growth_plan()
|
||||
|
||||
site1 = new_site(owner: current_owner)
|
||||
site2 = new_site(owner: new_owner)
|
||||
|
||||
assert {:error, :transfer_to_self} =
|
||||
Transfer.bulk_transfer(
|
||||
[site1, site2],
|
||||
new_owner
|
||||
)
|
||||
|
||||
assert_team_membership(current_owner, site1.team, :owner)
|
||||
assert_team_membership(new_owner, site2.team, :owner)
|
||||
end
|
||||
|
||||
test "does not allow transferring ownership without selecting team for owner of more than one team" do
|
||||
new_owner = new_user() |> subscribe_to_growth_plan()
|
||||
|
||||
other_site1 = new_site()
|
||||
add_member(other_site1.team, user: new_owner, role: :owner)
|
||||
other_site2 = new_site()
|
||||
add_member(other_site2.team, user: new_owner, role: :owner)
|
||||
|
||||
current_owner = new_user()
|
||||
site1 = new_site(owner: current_owner)
|
||||
site2 = new_site(owner: current_owner)
|
||||
|
||||
assert {:error, :multiple_teams} =
|
||||
Transfer.bulk_transfer(
|
||||
[site1, site2],
|
||||
new_owner
|
||||
)
|
||||
end
|
||||
|
||||
test "allows transferring between teams of the same owner" do
|
||||
current_owner = new_user() |> subscribe_to_growth_plan()
|
||||
another_owner = new_user() |> subscribe_to_growth_plan()
|
||||
|
||||
site1 = new_site(owner: current_owner)
|
||||
site2 = new_site(owner: current_owner)
|
||||
|
||||
new_team = team_of(another_owner)
|
||||
add_member(new_team, user: current_owner, role: :owner)
|
||||
|
||||
assert {:ok, _} =
|
||||
Transfer.bulk_transfer(
|
||||
[site1, site2],
|
||||
current_owner,
|
||||
new_team
|
||||
)
|
||||
end
|
||||
|
||||
test "does not allow transferring ownership to a team where user has no permission" do
|
||||
other_owner = new_user() |> subscribe_to_growth_plan()
|
||||
other_team = team_of(other_owner)
|
||||
new_owner = new_user()
|
||||
add_member(other_team, user: new_owner, role: :viewer)
|
||||
|
||||
current_owner = new_user()
|
||||
site1 = new_site(owner: current_owner)
|
||||
site2 = new_site(owner: current_owner)
|
||||
|
||||
assert {:error, :permission_denied} =
|
||||
Transfer.bulk_transfer(
|
||||
[site1, site2],
|
||||
new_owner,
|
||||
other_team
|
||||
)
|
||||
end
|
||||
|
||||
test "allows transferring ownership to a team where user has permission" do
|
||||
other_owner = new_user() |> subscribe_to_growth_plan()
|
||||
other_team = team_of(other_owner)
|
||||
new_owner = new_user()
|
||||
add_member(other_team, user: new_owner, role: :admin)
|
||||
|
||||
current_owner = new_user()
|
||||
site1 = new_site(owner: current_owner)
|
||||
site2 = new_site(owner: current_owner)
|
||||
|
||||
assert {:ok, _} =
|
||||
Transfer.bulk_transfer(
|
||||
[site1, site2],
|
||||
new_owner,
|
||||
other_team
|
||||
)
|
||||
|
||||
assert Repo.reload(site1).team_id == other_team.id
|
||||
assert_guest_membership(other_team, site1, current_owner, :editor)
|
||||
assert Repo.reload(site2).team_id == other_team.id
|
||||
assert_guest_membership(other_team, site2, current_owner, :editor)
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "does not allow transferring ownership to a non-member user when at team members limit" do
|
||||
old_owner = new_user() |> subscribe_to_business_plan()
|
||||
new_owner = new_user() |> subscribe_to_growth_plan()
|
||||
site = new_site(owner: old_owner)
|
||||
for _ <- 1..3, do: add_guest(site, role: :editor)
|
||||
|
||||
assert {:error, {:over_plan_limits, [:team_member_limit]}} =
|
||||
Transfer.bulk_transfer([site], new_owner)
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "allows transferring ownership to existing site member when at team members limit" do
|
||||
old_owner = new_user() |> subscribe_to_business_plan()
|
||||
new_owner = new_user() |> subscribe_to_growth_plan()
|
||||
site = new_site(owner: old_owner)
|
||||
add_guest(site, user: new_owner, role: :editor)
|
||||
for _ <- 1..2, do: add_guest(site, role: :editor)
|
||||
|
||||
assert {:ok, _} =
|
||||
Transfer.bulk_transfer([site], new_owner)
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "does not allow transferring ownership when sites limit exceeded" do
|
||||
old_owner = new_user() |> subscribe_to_business_plan()
|
||||
new_owner = new_user() |> subscribe_to_growth_plan()
|
||||
|
||||
for _ <- 1..10, do: new_site(owner: new_owner)
|
||||
|
||||
site = new_site(owner: old_owner)
|
||||
|
||||
assert {:error, {:over_plan_limits, [:site_limit]}} =
|
||||
Transfer.bulk_transfer([site], new_owner)
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "exceeding limits error takes precedence over missing features" do
|
||||
old_owner = new_user() |> subscribe_to_business_plan()
|
||||
new_owner = new_user() |> subscribe_to_growth_plan()
|
||||
|
||||
for _ <- 1..10, do: new_site(owner: new_owner)
|
||||
|
||||
site =
|
||||
new_site(
|
||||
owner: old_owner,
|
||||
props_enabled: true,
|
||||
allowed_event_props: ["author"]
|
||||
)
|
||||
|
||||
for _ <- 1..3, do: add_guest(site, role: :editor)
|
||||
|
||||
assert {:error, {:over_plan_limits, [:team_member_limit, :site_limit]}} =
|
||||
Transfer.bulk_transfer([site], new_owner)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,427 +0,0 @@
|
|||
defmodule PlausibleWeb.AdminControllerTest do
|
||||
use PlausibleWeb.ConnCase, async: false
|
||||
use Plausible.Teams.Test
|
||||
|
||||
alias Plausible.Repo
|
||||
alias Plausible.Teams
|
||||
|
||||
describe "GET /crm/teams/team/:team_id/usage" do
|
||||
setup [:create_user, :log_in, :create_team]
|
||||
|
||||
@tag :ee_only
|
||||
test "returns 403 if the logged in user is not a super admin", %{conn: conn} do
|
||||
conn = get(conn, "/crm/teams/team/1/usage")
|
||||
assert response(conn, 403) == "Not allowed"
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "returns usage data as a standalone page", %{conn: conn, user: user, team: team} do
|
||||
patch_env(:super_admin_user_ids, [user.id])
|
||||
conn = get(conn, "/crm/teams/team/#{team.id}/usage")
|
||||
assert response(conn, 200) =~ "<html"
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "returns usage data in embeddable form when requested", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
team: team
|
||||
} do
|
||||
patch_env(:super_admin_user_ids, [user.id])
|
||||
conn = get(conn, "/crm/teams/team/#{team.id}/usage?embed=true")
|
||||
refute response(conn, 200) =~ "<html"
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /crm/sites/site" do
|
||||
setup [:create_user, :log_in]
|
||||
|
||||
@tag :ee_only
|
||||
test "pagination works correctly when multiple memberships per site present", %{
|
||||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
patch_env(:super_admin_user_ids, [user.id])
|
||||
|
||||
s1 = new_site(inserted_at: ~N[2024-01-01 00:00:00])
|
||||
for _ <- 1..3, do: add_guest(s1, role: :viewer)
|
||||
s2 = new_site(inserted_at: ~N[2024-01-02 00:00:00])
|
||||
for _ <- 1..3, do: add_guest(s2, role: :viewer)
|
||||
s3 = new_site(inserted_at: ~N[2024-01-03 00:00:00])
|
||||
for _ <- 1..3, do: add_guest(s3, role: :viewer)
|
||||
|
||||
conn1 = get(conn, "/crm/sites/site", %{"limit" => "2"})
|
||||
page1_html = html_response(conn1, 200)
|
||||
|
||||
assert page1_html =~ s3.domain
|
||||
assert page1_html =~ s2.domain
|
||||
refute page1_html =~ s1.domain
|
||||
|
||||
conn2 = get(conn, "/crm/sites/site", %{"page" => "2", "limit" => "2"})
|
||||
page2_html = html_response(conn2, 200)
|
||||
|
||||
refute page2_html =~ s3.domain
|
||||
refute page2_html =~ s2.domain
|
||||
assert page2_html =~ s1.domain
|
||||
end
|
||||
end
|
||||
|
||||
describe "DELETE /crm/auth/user/:user_id" do
|
||||
setup [:create_user, :log_in]
|
||||
|
||||
setup %{user: user} do
|
||||
patch_env(:super_admin_user_ids, [user.id])
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "deletes a user without a team", %{conn: conn} do
|
||||
another_user = new_user()
|
||||
|
||||
conn = delete(conn, "/crm/auth/user/#{another_user.id}")
|
||||
assert redirected_to(conn, 302) == "/crm/auth/user"
|
||||
|
||||
refute Repo.reload(another_user)
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "deletes a user with a personal team without subscription", %{conn: conn} do
|
||||
another_user = new_user()
|
||||
site = new_site(owner: another_user)
|
||||
team = team_of(another_user)
|
||||
|
||||
conn = delete(conn, "/crm/auth/user/#{another_user.id}")
|
||||
assert redirected_to(conn, 302) == "/crm/auth/user"
|
||||
|
||||
refute Repo.reload(another_user)
|
||||
refute Repo.reload(site)
|
||||
refute Repo.reload(team)
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "fails to delete a user with a personal team with active subscription", %{conn: conn} do
|
||||
another_user = new_user() |> subscribe_to_growth_plan()
|
||||
site = new_site(owner: another_user)
|
||||
team = team_of(another_user)
|
||||
|
||||
conn = delete(conn, "/crm/auth/user/#{another_user.id}")
|
||||
assert redirected_to(conn, 302) == "/crm/auth/user/#{another_user.id}"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
|
||||
"User's personal team has an active subscription"
|
||||
|
||||
assert Repo.reload(another_user)
|
||||
assert Repo.reload(site)
|
||||
assert Repo.reload(team)
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "fails to delete a user who is the only owner on a public team", %{conn: conn} do
|
||||
another_user = new_user()
|
||||
site = new_site(owner: another_user)
|
||||
team = another_user |> team_of() |> Plausible.Teams.complete_setup()
|
||||
|
||||
conn = delete(conn, "/crm/auth/user/#{another_user.id}")
|
||||
assert redirected_to(conn, 302) == "/crm/auth/user/#{another_user.id}"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
|
||||
"The user is the only public team owner"
|
||||
|
||||
assert Repo.reload(another_user)
|
||||
assert Repo.reload(site)
|
||||
assert Repo.reload(team)
|
||||
end
|
||||
end
|
||||
|
||||
describe "DELETE /crm/teams/team/:team_id" do
|
||||
setup [:create_user, :log_in]
|
||||
|
||||
setup %{user: user} do
|
||||
patch_env(:super_admin_user_ids, [user.id])
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "deletes a team", %{conn: conn} do
|
||||
another_user = new_user()
|
||||
site = new_site(owner: another_user)
|
||||
team = team_of(another_user)
|
||||
|
||||
conn = delete(conn, "/crm/teams/team/#{team.id}")
|
||||
assert redirected_to(conn, 302) == "/crm/teams/team"
|
||||
|
||||
refute Repo.reload(team)
|
||||
refute Repo.reload(site)
|
||||
assert Repo.reload(another_user)
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "fails to delete a team with an active subscription", %{conn: conn} do
|
||||
another_user = new_user() |> subscribe_to_growth_plan()
|
||||
site = new_site(owner: another_user)
|
||||
team = team_of(another_user)
|
||||
|
||||
conn = delete(conn, "/crm/teams/team/#{team.id}")
|
||||
assert redirected_to(conn, 302) == "/crm/teams/team/#{team.id}"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
|
||||
"The team has an active subscription"
|
||||
|
||||
assert Repo.reload(team)
|
||||
assert Repo.reload(site)
|
||||
assert Repo.reload(another_user)
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /crm/sites/site/:site_id" do
|
||||
setup [:create_user, :log_in]
|
||||
|
||||
@tag :ee_only
|
||||
test "resets stats start date on native stats start time change", %{conn: conn, user: user} do
|
||||
patch_env(:super_admin_user_ids, [user.id])
|
||||
|
||||
site =
|
||||
new_site(
|
||||
public: false,
|
||||
stats_start_date: ~D[2022-03-14],
|
||||
native_stats_start_at: ~N[2024-01-22 14:28:00]
|
||||
)
|
||||
|
||||
params = %{
|
||||
"site" => %{
|
||||
"domain" => site.domain,
|
||||
"timezone" => site.timezone,
|
||||
"public" => "false",
|
||||
"native_stats_start_at" => "2024-02-12 12:00:00",
|
||||
"ingest_rate_limit_scale_seconds" => site.ingest_rate_limit_scale_seconds,
|
||||
"ingest_rate_limit_threshold" => site.ingest_rate_limit_threshold
|
||||
}
|
||||
}
|
||||
|
||||
conn = put(conn, "/crm/sites/site/#{site.id}", params)
|
||||
assert redirected_to(conn, 302) == "/crm/sites/site"
|
||||
|
||||
site = Repo.reload!(site)
|
||||
|
||||
refute site.public
|
||||
assert site.native_stats_start_at == ~N[2024-02-12 12:00:00]
|
||||
assert site.stats_start_date == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /crm/billing/user/:user_id/current_plan" do
|
||||
setup [:create_user, :log_in]
|
||||
|
||||
@tag :ee_only
|
||||
test "returns 403 if the logged in user is not a super admin", %{conn: conn} do
|
||||
conn = get(conn, "/crm/billing/team/0/current_plan")
|
||||
assert response(conn, 403) == "Not allowed"
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "returns empty state for non-existent team", %{conn: conn, user: user} do
|
||||
patch_env(:super_admin_user_ids, [user.id])
|
||||
|
||||
conn = get(conn, "/crm/billing/team/0/current_plan")
|
||||
assert json_response(conn, 200) == %{"features" => []}
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "returns empty state for user without subscription", %{conn: conn, user: user} do
|
||||
patch_env(:super_admin_user_ids, [user.id])
|
||||
_site = new_site(owner: user)
|
||||
team = team_of(user)
|
||||
|
||||
conn = get(conn, "/crm/billing/team/#{team.id}/current_plan")
|
||||
assert json_response(conn, 200) == %{"features" => []}
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "returns empty state for user with subscription with non-existent paddle plan ID", %{
|
||||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
patch_env(:super_admin_user_ids, [user.id])
|
||||
|
||||
subscribe_to_plan(user, "does-not-exist")
|
||||
|
||||
team = team_of(user)
|
||||
|
||||
conn = get(conn, "/crm/billing/team/#{team.id}/current_plan")
|
||||
assert json_response(conn, 200) == %{"features" => []}
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "returns plan data for user with subscription", %{conn: conn, user: user} do
|
||||
patch_env(:super_admin_user_ids, [user.id])
|
||||
|
||||
subscribe_to_plan(user, "857104")
|
||||
team = team_of(user)
|
||||
|
||||
conn = get(conn, "/crm/billing/team/#{team.id}/current_plan")
|
||||
|
||||
assert json_response(conn, 200) == %{
|
||||
"features" => ["goals", "shared_links"],
|
||||
"monthly_pageview_limit" => 10_000_000,
|
||||
"site_limit" => 10,
|
||||
"team_member_limit" => 3
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /crm/auth/api_key" do
|
||||
setup [:create_user, :log_in]
|
||||
|
||||
@tag :kaffy_quirks
|
||||
test "creates a team-bound API key", %{conn: conn, user: user} do
|
||||
patch_env(:super_admin_user_ids, [user.id])
|
||||
|
||||
another_user = new_user()
|
||||
team = another_user |> subscribe_to_business_plan() |> team_of()
|
||||
|
||||
params = %{
|
||||
"api_key" => %{
|
||||
"name" => "Some key",
|
||||
"key" => Ecto.UUID.generate(),
|
||||
"scope" => "stats:read:*",
|
||||
"user_id" => "#{another_user.id}"
|
||||
}
|
||||
}
|
||||
|
||||
conn = post(conn, "/crm/auth/api_key", params)
|
||||
|
||||
assert api_key = Repo.get_by(Plausible.Auth.ApiKey, user_id: another_user.id)
|
||||
|
||||
assert redirected_to(conn, 302) == "/crm/auth/api_key"
|
||||
|
||||
assert api_key.team_id == team.id
|
||||
assert api_key.user_id == another_user.id
|
||||
end
|
||||
|
||||
@tag :kaffy_quirks
|
||||
test "Creates personal team when creating the api key if there's none", %{
|
||||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
patch_env(:super_admin_user_ids, [user.id])
|
||||
|
||||
another_user = new_user()
|
||||
|
||||
another_team = new_site().team |> Teams.complete_setup()
|
||||
add_member(another_team, user: another_user, role: :owner)
|
||||
|
||||
params = %{
|
||||
"api_key" => %{
|
||||
"name" => "Some key",
|
||||
"key" => Ecto.UUID.generate(),
|
||||
"scopes" => Jason.encode!(["stats:read:*"]),
|
||||
"user_id" => "#{another_user.id}"
|
||||
}
|
||||
}
|
||||
|
||||
conn = post(conn, "/crm/auth/api_key", params)
|
||||
|
||||
assert api_key = Repo.get_by(Plausible.Auth.ApiKey, user_id: another_user.id)
|
||||
|
||||
assert {:ok, personal_team} = Teams.get_owned_team(another_user, only_not_setup?: true)
|
||||
|
||||
assert redirected_to(conn, 302) == "/crm/auth/api_key"
|
||||
|
||||
assert api_key.team_id == personal_team.id
|
||||
end
|
||||
|
||||
@tag :kaffy_quirks
|
||||
test "Creates team for a particular team if provided", %{conn: conn, user: user} do
|
||||
patch_env(:super_admin_user_ids, [user.id])
|
||||
|
||||
another_user = new_user() |> subscribe_to_business_plan()
|
||||
|
||||
another_team = new_site().team |> Teams.complete_setup()
|
||||
add_member(another_team, user: another_user, role: :owner)
|
||||
|
||||
params = %{
|
||||
"api_key" => %{
|
||||
"team_identifier" => another_team.identifier,
|
||||
"name" => "Some key",
|
||||
"key" => Ecto.UUID.generate(),
|
||||
"scopes" => Jason.encode!(["stats:read:*"]),
|
||||
"user_id" => "#{another_user.id}"
|
||||
}
|
||||
}
|
||||
|
||||
conn = post(conn, "/crm/auth/api_key", params)
|
||||
|
||||
assert api_key = Repo.get_by(Plausible.Auth.ApiKey, user_id: another_user.id)
|
||||
|
||||
assert redirected_to(conn, 302) == "/crm/auth/api_key"
|
||||
|
||||
assert api_key.team_id == another_team.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "PUT /crm/auth/api_key/:id" do
|
||||
setup [:create_user, :log_in]
|
||||
|
||||
@tag :ee_only
|
||||
test "updates an API key", %{conn: conn, user: user} do
|
||||
patch_env(:super_admin_user_ids, [user.id])
|
||||
|
||||
another_user = new_user()
|
||||
team = another_user |> subscribe_to_business_plan() |> team_of()
|
||||
|
||||
api_key = insert(:api_key, user: user, team: team)
|
||||
|
||||
assert api_key.scopes == ["stats:read:*"]
|
||||
|
||||
params = %{
|
||||
"api_key" => %{
|
||||
"name" => "Some key",
|
||||
"key" => Ecto.UUID.generate(),
|
||||
"scopes" => Jason.encode!(["sites:provision:*"]),
|
||||
"user_id" => "#{another_user.id}"
|
||||
}
|
||||
}
|
||||
|
||||
conn = put(conn, "/crm/auth/api_key/#{api_key.id}", params)
|
||||
|
||||
assert api_key = Repo.get_by(Plausible.Auth.ApiKey, user_id: another_user.id)
|
||||
|
||||
assert redirected_to(conn, 302) == "/crm/auth/api_key"
|
||||
|
||||
assert api_key.team_id == team.id
|
||||
assert api_key.scopes == ["sites:provision:*"]
|
||||
end
|
||||
|
||||
@tag :ee_only
|
||||
test "leaves legacy API key without a team on update", %{conn: conn, user: user} do
|
||||
patch_env(:super_admin_user_ids, [user.id])
|
||||
|
||||
another_user = new_user()
|
||||
_team = another_user |> subscribe_to_business_plan() |> team_of()
|
||||
|
||||
api_key = insert(:api_key, user: user)
|
||||
|
||||
assert api_key.scopes == ["stats:read:*"]
|
||||
|
||||
params = %{
|
||||
"api_key" => %{
|
||||
"name" => "Some key",
|
||||
"key" => Ecto.UUID.generate(),
|
||||
"scopes" => Jason.encode!(["sites:provision:*"]),
|
||||
"user_id" => "#{another_user.id}"
|
||||
}
|
||||
}
|
||||
|
||||
conn = put(conn, "/crm/auth/api_key/#{api_key.id}", params)
|
||||
|
||||
assert api_key = Repo.get_by(Plausible.Auth.ApiKey, user_id: another_user.id)
|
||||
|
||||
assert redirected_to(conn, 302) == "/crm/auth/api_key"
|
||||
|
||||
refute api_key.team_id
|
||||
assert api_key.scopes == ["sites:provision:*"]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -586,28 +586,6 @@ defmodule PlausibleWeb.SiteControllerTest do
|
|||
describe "GET /:domain/settings/people" do
|
||||
setup [:create_user, :log_in, :create_site]
|
||||
|
||||
@tag :ee_only
|
||||
test "shows members page with links to CRM for super admin", %{
|
||||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
site = new_site(owner: user)
|
||||
patch_env(:super_admin_user_ids, [user.id])
|
||||
|
||||
conn = get(conn, "/#{site.domain}/settings/people")
|
||||
resp = html_response(conn, 200)
|
||||
|
||||
assert resp =~ "/crm/auth/user/#{user.id}"
|
||||
end
|
||||
|
||||
test "does not show CRM links to non-super admin user", %{conn: conn, user: user} do
|
||||
site = new_site(owner: user)
|
||||
conn = get(conn, "/#{site.domain}/settings/people")
|
||||
resp = html_response(conn, 200)
|
||||
|
||||
refute resp =~ "/crm/auth/user/#{user.id}"
|
||||
end
|
||||
|
||||
test "lists current members", %{conn: conn, user: user} do
|
||||
site = new_site(owner: user)
|
||||
editor = add_guest(site, role: :editor)
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ defmodule PlausibleWeb.StatsControllerTest do
|
|||
|
||||
test "does not show CRM link to the site", %{conn: conn, site: site} do
|
||||
conn = get(conn, conn |> get("/" <> site.domain) |> redirected_to())
|
||||
refute html_response(conn, 200) =~ "/crm/sites/site/#{site.id}"
|
||||
refute html_response(conn, 200) =~ "/cs/sites/site/#{site.id}"
|
||||
end
|
||||
|
||||
test "all segments (personal or site) are stuffed into dataset, with their associated owner_id and owner_name",
|
||||
|
|
@ -314,7 +314,7 @@ defmodule PlausibleWeb.StatsControllerTest do
|
|||
test "shows CRM link to the site", %{conn: conn} do
|
||||
site = new_site()
|
||||
conn = get(conn, conn |> get("/" <> site.domain) |> redirected_to())
|
||||
assert html_response(conn, 200) =~ "/crm/sites/site/#{site.id}"
|
||||
assert html_response(conn, 200) =~ "/cs/sites/site/#{site.id}"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ if :minio in Keyword.fetch!(ExUnit.configuration(), :include) do
|
|||
Plausible.TestUtils.ensure_minio()
|
||||
end
|
||||
|
||||
default_exclude = [:slow, :minio, :migrations, :kaffy_quirks]
|
||||
default_exclude = [:slow, :minio, :migrations]
|
||||
|
||||
# avoid slowdowns contacting the code server https://github.com/sasa1977/con_cache/pull/79
|
||||
:code.ensure_loaded(ConCache.Lock.Resource)
|
||||
|
|
|
|||
Loading…
Reference in New Issue