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:
hq1 2025-06-10 08:24:47 +02:00 committed by GitHub
parent bdc44d1d33
commit efc55e323d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 13 additions and 2606 deletions

View File

@ -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 ->

View File

@ -102,9 +102,6 @@ defmodule PlausibleWeb.Live.CustomerSupport do
<.styled_link onclick="window.history.go(-1); return false;">
&larr; 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

View File

@ -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])

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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),

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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}"}

View File

@ -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>

View File

@ -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"},

View File

@ -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"},

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)