diff --git a/assets/js/liveview/live_socket.js b/assets/js/liveview/live_socket.js
index 5b8e0a0a0d..d87a08ff05 100644
--- a/assets/js/liveview/live_socket.js
+++ b/assets/js/liveview/live_socket.js
@@ -6,6 +6,7 @@
import 'phoenix_html'
import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view'
+import topbar from 'topbar'
/* eslint-enable import/no-unresolved */
import Alpine from 'alpinejs'
@@ -66,6 +67,14 @@ if (csrfToken && websocketUrl) {
}
})
+ topbar.config({
+ barColors: { 0: '#303f9f' },
+ shadowColor: 'rgba(0, 0, 0, .3)',
+ barThickness: 4
+ })
+ window.addEventListener('phx:page-loading-start', (_info) => topbar.show())
+ window.addEventListener('phx:page-loading-stop', (_info) => topbar.hide())
+
liveSocket.connect()
window.liveSocket = liveSocket
}
diff --git a/assets/package-lock.json b/assets/package-lock.json
index 6013a9eaba..69c303df35 100644
--- a/assets/package-lock.json
+++ b/assets/package-lock.json
@@ -34,6 +34,7 @@
"react-popper": "^2.3.0",
"react-router-dom": "^6.25.1",
"react-transition-group": "^4.4.2",
+ "topbar": "^3.0.0",
"topojson-client": "^3.1.0",
"url-search-params-polyfill": "^8.2.5",
"visionscarto-world-atlas": "^1.0.0"
@@ -10263,6 +10264,12 @@
"node": ">=8.0"
}
},
+ "node_modules/topbar": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/topbar/-/topbar-3.0.0.tgz",
+ "integrity": "sha512-mhczD7KfYi1anfoMPKRdl0wPSWiYc0YOK4KyycYs3EaNT15pVVNDG5CtfgZcEBWIPJEdfR7r8K4hTXDD2ECBVQ==",
+ "license": "MIT"
+ },
"node_modules/topojson-client": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
diff --git a/assets/package.json b/assets/package.json
index ad73805031..00c2a64d6e 100644
--- a/assets/package.json
+++ b/assets/package.json
@@ -38,6 +38,7 @@
"react-popper": "^2.3.0",
"react-router-dom": "^6.25.1",
"react-transition-group": "^4.4.2",
+ "topbar": "^3.0.0",
"topojson-client": "^3.1.0",
"url-search-params-polyfill": "^8.2.5",
"visionscarto-world-atlas": "^1.0.0"
diff --git a/extra/lib/plausible/customer_support/resource.ex b/extra/lib/plausible/customer_support/resource.ex
new file mode 100644
index 0000000000..04b368cb08
--- /dev/null
+++ b/extra/lib/plausible/customer_support/resource.ex
@@ -0,0 +1,89 @@
+defmodule Plausible.CustomerSupport.Resource do
+ @moduledoc """
+ Generic behaviour for CS resources and their components
+ """
+ defstruct [:id, :type, :module, :object]
+
+ @type schema() :: map()
+
+ @type t() :: %__MODULE__{
+ id: pos_integer(),
+ module: atom(),
+ object: schema(),
+ type: String.t()
+ }
+
+ @callback search(String.t(), pos_integer()) :: list(schema())
+ @callback get(pos_integer()) :: schema()
+ @callback component() :: module()
+ @callback type() :: String.t()
+ @callback dump(schema()) :: t()
+
+ defmodule Component do
+ @moduledoc false
+ @callback render_result(assigns :: Phoenix.LiveView.Socket.assigns()) ::
+ Phoenix.LiveView.Rendered.t()
+ end
+
+ defmacro __using__(:component) do
+ quote do
+ use PlausibleWeb, :live_component
+ alias Plausible.CustomerSupport.Resource
+ import PlausibleWeb.CustomerSupport.Live.Shared
+
+ @behaviour Plausible.CustomerSupport.Resource.Component
+
+ def success(socket, msg) do
+ send(socket.root_pid, {:success, msg})
+ socket
+ end
+
+ def failure(socket, msg) do
+ send(socket.root_pid, {:failure, msg})
+ socket
+ end
+ end
+ end
+
+ defmacro __using__(component: component) do
+ quote do
+ @behaviour Plausible.CustomerSupport.Resource
+ alias Plausible.CustomerSupport.Resource
+
+ import Ecto.Query
+ alias Plausible.Repo
+
+ @impl true
+ def dump(schema) do
+ Resource.new(__MODULE__, schema)
+ end
+
+ defoverridable dump: 1
+
+ @impl true
+ def type do
+ __MODULE__
+ |> Module.split()
+ |> Enum.reverse()
+ |> hd()
+ |> String.downcase()
+ end
+
+ defoverridable type: 0
+
+ @impl true
+ def component, do: unquote(component)
+
+ defoverridable component: 0
+ end
+ end
+
+ def new(module, schema) do
+ %__MODULE__{
+ id: schema.id,
+ type: module.type(),
+ module: module,
+ object: schema
+ }
+ end
+end
diff --git a/extra/lib/plausible/customer_support/resource/site.ex b/extra/lib/plausible/customer_support/resource/site.ex
new file mode 100644
index 0000000000..f7380fd28d
--- /dev/null
+++ b/extra/lib/plausible/customer_support/resource/site.ex
@@ -0,0 +1,46 @@
+defmodule Plausible.CustomerSupport.Resource.Site do
+ @moduledoc false
+ use Plausible.CustomerSupport.Resource, component: PlausibleWeb.CustomerSupport.Live.Site
+
+ @impl true
+ def search("", limit) do
+ q =
+ from s in Plausible.Site,
+ inner_join: t in assoc(s, :team),
+ inner_join: o in assoc(t, :owners),
+ order_by: [
+ desc: :id
+ ],
+ limit: ^limit,
+ preload: [team: {t, owners: o}]
+
+ Plausible.Repo.all(q)
+ end
+
+ def search(input, limit) do
+ q =
+ from s in Plausible.Site,
+ inner_join: t in assoc(s, :team),
+ inner_join: o in assoc(t, :owners),
+ where:
+ ilike(s.domain, ^"%#{input}%") or ilike(t.name, ^"%#{input}%") or
+ ilike(o.name, ^"%#{input}%"),
+ order_by: [
+ desc: fragment("?.domain = ?", s, ^input),
+ desc: fragment("?.name = ?", t, ^input),
+ desc: fragment("?.name = ?", o, ^input),
+ asc: s.domain
+ ],
+ limit: ^limit,
+ preload: [team: {t, owners: o}]
+
+ Plausible.Repo.all(q)
+ end
+
+ @impl true
+ def get(id) do
+ Plausible.Site
+ |> Plausible.Repo.get(id)
+ |> Plausible.Repo.preload(:team)
+ end
+end
diff --git a/extra/lib/plausible/customer_support/resource/team.ex b/extra/lib/plausible/customer_support/resource/team.ex
new file mode 100644
index 0000000000..c81ef59a18
--- /dev/null
+++ b/extra/lib/plausible/customer_support/resource/team.ex
@@ -0,0 +1,41 @@
+defmodule Plausible.CustomerSupport.Resource.Team do
+ @moduledoc false
+ use Plausible.CustomerSupport.Resource, component: PlausibleWeb.CustomerSupport.Live.Team
+
+ @impl true
+ def search("", limit) do
+ q =
+ from t in Plausible.Teams.Team,
+ inner_join: o in assoc(t, :owners),
+ limit: ^limit,
+ where: not is_nil(t.trial_expiry_date),
+ order_by: [desc: :id],
+ preload: [owners: o]
+
+ Plausible.Repo.all(q)
+ end
+
+ def search(input, limit) do
+ q =
+ from t in Plausible.Teams.Team,
+ inner_join: o in assoc(t, :owners),
+ where: ilike(t.name, ^"%#{input}%") or ilike(o.name, ^"%#{input}%"),
+ limit: ^limit,
+ order_by: [
+ desc: fragment("?.name = ?", t, ^input),
+ desc: fragment("?.name = ?", o, ^input),
+ asc: t.name
+ ],
+ preload: [owners: o]
+
+ Plausible.Repo.all(q)
+ end
+
+ @impl true
+ def get(id) do
+ Plausible.Teams.Team
+ |> Repo.get(id)
+ |> Plausible.Teams.with_subscription()
+ |> Repo.preload(:owners)
+ end
+end
diff --git a/extra/lib/plausible/customer_support/resource/user.ex b/extra/lib/plausible/customer_support/resource/user.ex
new file mode 100644
index 0000000000..618c03c594
--- /dev/null
+++ b/extra/lib/plausible/customer_support/resource/user.ex
@@ -0,0 +1,38 @@
+defmodule Plausible.CustomerSupport.Resource.User do
+ @moduledoc false
+ use Plausible.CustomerSupport.Resource, component: PlausibleWeb.CustomerSupport.Live.User
+
+ @impl true
+ def get(id) do
+ Plausible.Repo.get!(Plausible.Auth.User, id)
+ |> Plausible.Repo.preload(team_memberships: :team)
+ end
+
+ @impl true
+ def search("", limit) do
+ q =
+ from u in Plausible.Auth.User,
+ order_by: [
+ desc: :id
+ ],
+ preload: [:owned_teams],
+ limit: ^limit
+
+ Plausible.Repo.all(q)
+ end
+
+ def search(input, limit) do
+ q =
+ from u in Plausible.Auth.User,
+ where: ilike(u.email, ^"%#{input}%") or ilike(u.name, ^"%#{input}%"),
+ order_by: [
+ desc: fragment("?.name = ?", u, ^input),
+ desc: fragment("?.email = ?", u, ^input),
+ asc: u.name
+ ],
+ preload: [:owned_teams],
+ limit: ^limit
+
+ Plausible.Repo.all(q)
+ end
+end
diff --git a/extra/lib/plausible/help_scout.ex b/extra/lib/plausible/help_scout.ex
index c6a1405e55..a061c39664 100644
--- a/extra/lib/plausible/help_scout.ex
+++ b/extra/lib/plausible/help_scout.ex
@@ -105,7 +105,14 @@ defmodule Plausible.HelpScout do
}
end)
- user_link = Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :auth, :user, user.id)
+ user_link =
+ Routes.customer_support_resource_url(
+ PlausibleWeb.Endpoint,
+ :details,
+ :users,
+ :user,
+ user.id
+ )
{:ok,
%{
@@ -129,19 +136,20 @@ defmodule Plausible.HelpScout do
status_link =
if team do
- Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :teams, :team, team.id)
- else
- Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :auth, :user, user.id)
- end
-
- sites_link =
- if team do
- Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site,
- custom_search: team.identifier
+ Routes.customer_support_resource_url(
+ PlausibleWeb.Endpoint,
+ :details,
+ :teams,
+ :team,
+ team.id
)
else
- Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site,
- custom_search: user.email
+ Routes.customer_support_resource_url(
+ PlausibleWeb.Endpoint,
+ :details,
+ :users,
+ :user,
+ user.id
)
end
@@ -156,8 +164,7 @@ defmodule Plausible.HelpScout do
status_link: status_link,
plan_label: plan_label(subscription, plan),
plan_link: plan_link(subscription),
- sites_count: Teams.owned_sites_count(team),
- sites_link: sites_link
+ sites_count: Teams.owned_sites_count(team)
}}
end
end
diff --git a/extra/lib/plausible_web/live/customer_support.ex b/extra/lib/plausible_web/live/customer_support.ex
new file mode 100644
index 0000000000..501aad8b15
--- /dev/null
+++ b/extra/lib/plausible_web/live/customer_support.ex
@@ -0,0 +1,208 @@
+defmodule PlausibleWeb.Live.CustomerSupport do
+ @moduledoc """
+ Customer Support UI
+ """
+ use PlausibleWeb, :live_view
+ alias Plausible.CustomerSupport.Resource
+
+ @resources [Resource.Team, Resource.User, Resource.Site]
+ @resources_by_type @resources |> Enum.into(%{}, fn mod -> {mod.type(), mod} end)
+
+ @impl true
+ def mount(params, _session, socket) do
+ uri =
+ ("/cs?" <> URI.encode_query(Map.take(params, ["filter_text"])))
+ |> URI.new!()
+
+ {:ok,
+ assign(socket,
+ resources_by_type: @resources_by_type,
+ results: [],
+ current: nil,
+ uri: uri,
+ filter_text: params["filter_text"] || ""
+ )}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+ <.flash_messages flash={@flash} />
+
+
+
+
+ 💬 Customer Support
+
+
+
+
+ <.filter_bar filter_text={@filter_text} placeholder="Search everything">
+
+
+
+
+ <.link patch={"/cs/#{r.type}s/#{r.type}/#{r.id}"}>
+
+
+ <.render_result resource={r} />
+
+
+
+
+
+
+
+
+
+ <.styled_link onclick="window.history.go(-1); return false;">
+ ← Previous
+
+ <.styled_link :if={@current} class="text-xs" href={kaffy_url(@current, @id)}>
+ open in Kaffy
+
+
+ <.live_component
+ :if={@current}
+ module={@current.component()}
+ resource_id={@id}
+ id={"#{@current.type()}-#{@id}"}
+ />
+
+
+
+ """
+ end
+
+ def render_result(assigns) do
+ apply(assigns.resource.module.component(), :render_result, [assigns])
+ end
+
+ @impl true
+ def handle_params(%{"id" => id, "resource" => type} = p, _uri, socket) do
+ mod = Map.fetch!(@resources_by_type, type)
+
+ id = String.to_integer(id)
+
+ send_update(self(), mod.component(),
+ id: "#{mod.type()}-#{id}",
+ tab: p["tab"]
+ )
+
+ {:noreply, assign(socket, type: type, current: mod, id: id)}
+ end
+
+ def handle_params(%{"filter_text" => _}, _uri, socket) do
+ socket =
+ search(assign(socket, current: nil))
+
+ {:noreply, socket}
+ end
+
+ def handle_params(_, _uri, socket) do
+ {:noreply, search(socket)}
+ end
+
+ def search(%{assigns: assigns} = socket) do
+ results = spawn_searches(assigns.filter_text)
+ assign(socket, results: results)
+ end
+
+ @impl true
+ def handle_event("filter", %{"filter-text" => input}, socket) do
+ socket = set_filter_text(socket, input)
+ {:noreply, socket}
+ end
+
+ def handle_event("reset-filter-text", _params, socket) do
+ socket = set_filter_text(socket, "")
+ {:noreply, socket}
+ end
+
+ def handle_event("close", _, socket) do
+ {:noreply, assign(socket, current: nil)}
+ end
+
+ def handle_info({:success, msg}, socket) do
+ {:noreply, put_live_flash(socket, :success, msg)}
+ end
+
+ def handle_info({:failure, msg}, socket) do
+ {:noreply, put_live_flash(socket, :error, msg)}
+ end
+
+ defp spawn_searches(input) do
+ input = String.trim(input)
+
+ {resources, input, limit} =
+ maybe_focus_search(input)
+
+ resources
+ |> Task.async_stream(fn resource ->
+ input
+ |> resource.search(limit)
+ |> Enum.map(&resource.dump/1)
+ end)
+ |> Enum.reduce([], fn {:ok, results}, acc ->
+ acc ++ results
+ end)
+ end
+
+ defp maybe_focus_search(lone_modifier) when lone_modifier in ["site:", "team:", "user:"] do
+ {[], "", 0}
+ end
+
+ defp maybe_focus_search("site:" <> rest) do
+ {[Resource.Site], rest, 90}
+ end
+
+ defp maybe_focus_search("team:" <> rest) do
+ {[Resource.Team], rest, 90}
+ end
+
+ defp maybe_focus_search("user:" <> rest) do
+ {[Resource.User], rest, 90}
+ end
+
+ defp maybe_focus_search(input) do
+ {@resources, input, 30}
+ end
+
+ defp set_filter_text(socket, filter_text) do
+ uri = socket.assigns.uri
+
+ uri_params =
+ uri.query
+ |> URI.decode_query()
+ |> Map.put("filter_text", filter_text)
+ |> URI.encode_query()
+
+ uri = %{uri | query: uri_params}
+
+ socket
+ |> assign(:filter_text, filter_text)
+ |> 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
diff --git a/extra/lib/plausible_web/live/customer_support/live/shared.ex b/extra/lib/plausible_web/live/customer_support/live/shared.ex
new file mode 100644
index 0000000000..8eaf148252
--- /dev/null
+++ b/extra/lib/plausible_web/live/customer_support/live/shared.ex
@@ -0,0 +1,30 @@
+defmodule PlausibleWeb.CustomerSupport.Live.Shared do
+ @moduledoc false
+ use Phoenix.Component
+
+ attr :to, :string, required: true
+ attr :tab, :string, required: true
+ slot :inner_block, required: true
+
+ def tab(assigns) do
+ ~H"""
+ <.link
+ replace
+ patch={"?tab=#{@to}"}
+ class="group relative min-w-0 flex-1 overflow-hidden rounded-l-lg px-4 py-4 text-center text-sm font-medium focus:z-10 cursor-pointer text-gray-800 dark:text-gray-200"
+ >
+
+ {render_slot(@inner_block)}
+
+
+
+
+ """
+ end
+end
diff --git a/extra/lib/plausible_web/live/customer_support/live/site.ex b/extra/lib/plausible_web/live/customer_support/live/site.ex
new file mode 100644
index 0000000000..573cab482d
--- /dev/null
+++ b/extra/lib/plausible_web/live/customer_support/live/site.ex
@@ -0,0 +1,200 @@
+defmodule PlausibleWeb.CustomerSupport.Live.Site do
+ @moduledoc false
+ use Plausible.CustomerSupport.Resource, :component
+
+ def update(%{resource_id: resource_id}, socket) do
+ site = Resource.Site.get(resource_id)
+ changeset = Plausible.Site.crm_changeset(site, %{})
+ form = to_form(changeset)
+ {:ok, assign(socket, site: site, form: form)}
+ end
+
+ def update(%{tab: "people"}, %{assigns: %{site: site}} = socket) do
+ people = Plausible.Sites.list_people(site)
+
+ people =
+ (people.invitations ++ people.memberships)
+ |> Enum.map(fn p ->
+ if Map.has_key?(p, :invitation_id) do
+ {:invitation, p.email, p.role}
+ else
+ {:membership, p.user, p.role}
+ end
+ end)
+
+ {:ok, assign(socket, people: people, tab: "people")}
+ end
+
+ def update(_, socket) do
+ {:ok, assign(socket, tab: "overview")}
+ end
+
+ attr :tab, :string, default: "overview"
+
+ def render(assigns) do
+ ~H"""
+
+
+
+ <.favicon class="w-8" domain={@site.domain} />
+
+
+
+
+ {@site.domain}
+
+
+ Timezone: {@site.timezone}
+
+
+ Team:
+ <.styled_link patch={"/cs/teams/team/#{@site.team.id}"}>
+ {@site.team.name}
+
+
+
+ (previously: {@site.domain_changed_from})
+
+
+
+
+
+
+
+ <.tab to="overview" tab={@tab}>Overview
+ <.tab to="people" tab={@tab}>
+ People
+
+
+
+
+
+ <.form
+ :let={f}
+ :if={@tab == "overview"}
+ for={@form}
+ phx-target={@myself}
+ phx-submit="save-site"
+ class="mt-8"
+ >
+ <.input
+ type="select"
+ field={f[:timezone]}
+ label="Timezone"
+ options={Plausible.Timezones.options()}
+ />
+ <.input type="checkbox" field={f[:public]} label="Public?" />
+ <.input type="datetime-local" field={f[:native_stats_start_at]} label="Native Stats Start At" />
+ <.input
+ type="text"
+ field={f[:ingest_rate_limit_threshold]}
+ label="Ingest Rate Limit Threshold"
+ />
+ <.input
+ type="text"
+ field={f[:ingest_rate_limit_scale_seconds]}
+ label="Ingest Rate Limit Scale Seconds"
+ />
+ <.button phx-target={@myself} type="submit">
+ Save
+
+
+
+
+ <.table rows={@people}>
+ <:thead>
+ <.th>User
+ <.th>Kind
+ <.th>Role
+
+ <:tbody :let={{kind, person, role}}>
+ <.td :if={kind == :membership}>
+ <.styled_link class="flex items-center" patch={"/cs/users/user/#{person.id}"}>
+
+ {person.name}
+
+
+
+ <.td :if={kind == :invitation}>
+
+
+ {person}
+
+
+
+ <.td :if={kind == :membership}>
+ Membership
+
+
+ <.td :if={kind == :invitation}>
+ Invitation
+
+ <.td>{role}
+
+
+
+
+ """
+ end
+
+ def render_result(assigns) do
+ ~H"""
+
+
+ <.favicon class="w-5" domain={@resource.object.domain} />
+
+ {@resource.object.domain}
+
+
+
+ Site
+
+
+
+
+
+ Part of {@resource.object.team.name}
+
+ owned by {@resource.object.team.owners
+ |> Enum.map(& &1.name)
+ |> Enum.join(", ")}
+
+
+ """
+ end
+
+ attr :domain, :string, required: true
+ attr :class, :string, required: true
+
+ def favicon(assigns) do
+ ~H"""
+
+ """
+ end
+
+ def handle_event("save-site", %{"site" => params}, socket) do
+ changeset = Plausible.Site.crm_changeset(socket.assigns.site, params)
+
+ case Plausible.Repo.update(changeset) do
+ {:ok, site} ->
+ success(socket, "Site saved")
+ {:noreply, assign(socket, site: site, form: to_form(changeset))}
+
+ {:error, changeset} ->
+ failure(socket, "Error saving site: #{inspect(changeset.errors)}")
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+end
diff --git a/extra/lib/plausible_web/live/customer_support/live/team.ex b/extra/lib/plausible_web/live/customer_support/live/team.ex
new file mode 100644
index 0000000000..0c1b453bef
--- /dev/null
+++ b/extra/lib/plausible_web/live/customer_support/live/team.ex
@@ -0,0 +1,713 @@
+defmodule PlausibleWeb.CustomerSupport.Live.Team do
+ @moduledoc false
+ use Plausible.CustomerSupport.Resource, :component
+
+ alias Plausible.Billing.{Plans, Subscription}
+ alias Plausible.Teams
+ alias Plausible.Teams.Management.Layout
+ alias Plausible.Billing.EnterprisePlan
+
+ alias PlausibleWeb.Router.Helpers, as: Routes
+
+ alias Plausible.Repo
+ import Ecto.Query
+
+ def update(%{resource_id: resource_id}, socket) do
+ team = Resource.Team.get(resource_id)
+ changeset = Plausible.Teams.Team.crm_changeset(team, %{})
+ form = to_form(changeset)
+
+ 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)
+ }
+
+ {:ok, assign(socket, team: team, form: form, usage: usage, limits: limits)}
+ end
+
+ def update(%{tab: "sites"}, %{assigns: %{team: team}} = socket) do
+ any_owner = Plausible.Repo.preload(team, [:owners]).owners |> hd()
+ sites = Teams.Sites.list(any_owner, %{}, team: team)
+ {:ok, assign(socket, sites: sites, tab: "sites")}
+ end
+
+ def update(%{tab: "billing"}, %{assigns: %{team: team}} = socket) do
+ plans = get_plans(team.id)
+ plan = Plans.get_subscription_plan(team.subscription)
+
+ attrs =
+ if is_map(plan) do
+ Map.take(plan, [
+ :billing_interval,
+ :monthly_pageview_limit,
+ :site_limit,
+ :team_member_limit,
+ :hourly_api_request_limit,
+ :features
+ ])
+ |> Map.update(:features, [], fn features ->
+ Enum.map(features, &to_string(&1.name()))
+ end)
+ else
+ %{site_limit: "10,000"}
+ end
+
+ plan_form =
+ to_form(
+ EnterprisePlan.changeset(
+ %EnterprisePlan{},
+ attrs
+ )
+ )
+
+ {:ok,
+ assign(socket,
+ plans: plans,
+ plan_form: plan_form,
+ show_plan_form?: false,
+ tab: "billing"
+ )}
+ end
+
+ def update(%{tab: "members"}, %{assigns: %{team: team}} = socket) do
+ team_layout = Layout.init(team)
+ {:ok, assign(socket, team_layout: team_layout, tab: "members")}
+ end
+
+ def update(_, socket) do
+ {:ok, assign(socket, tab: "overview")}
+ end
+
+ attr :tab, :string, default: "overview"
+
+ def render(assigns) do
+ ~H"""
+
+
+
+
+
+
+
+
+
+ {@team.name}
+
+
+ Set up at {@team.setup_at}
+ Not set up yet
+
+
+
+
+
+
+ <.styled_link phx-click="unlock" phx-target={@myself}>Unlock Team
+
+
+
+
+ <.styled_link phx-click="lock" phx-target={@myself}>Lock Team
+
+
+
+ <.input_with_clipboard
+ id="team-identifier"
+ name="team-identifier"
+ label="Team Identifier"
+ value={@team.identifier}
+ onfocus="this.value = this.value;"
+ />
+
+
+
+
+
+
+
+ <.tab to="overview" tab={@tab}>Overview
+ <.tab to="members" tab={@tab}>
+ Members ({number_format(@usage.team_members)}/{number_format(@limits.team_members)})
+
+ <.tab to="sites" tab={@tab}>
+ Sites ({number_format(@usage.sites)}/{number_format(@limits.sites)})
+
+ <.tab to="billing" tab={@tab}>
+ Billing
+
+
+
+
+
+
+
+
+ Subscription status {subscription_status(@team)}
+
+
+
+
+ Subscription plan {subscription_plan(@team)}
+
+
+
+
+ Grace Period {grace_period_status(@team)}
+
+
+
+
+
+
Usage
+ <.table rows={monthly_pageviews_usage(@usage.monthly_pageviews, @limits.monthly_pageviews)}>
+ <:thead>
+ <.th invisible>Cycle
+ <.th invisible>Dates
+ <.th>Total
+ <.th>Limit
+
+ <:tbody :let={{cycle, date, total, limit}}>
+ <.td>{cycle}
+ <.td>{date}
+ <.td>
+
limit, do: "text-red-600"}>{number_format(total)}
+
+ <.td>{number_format(limit)}
+
+
+
+
+
Features Used
+
+ {@usage.features |> Enum.map(& &1.display_name()) |> Enum.join(", ")}
+
+
+
+
+ Custom Plans
+
+ <.table :if={!@show_plan_form?} rows={@plans}>
+ <:thead>
+ <.th invisible>Interval
+ <.th>Paddle Plan ID
+ <.th>Limits
+ <.th>Features
+
+ <:tbody :let={plan}>
+ <.td class="align-top">
+ {plan.billing_interval}
+
+ <.td class="align-top">
+ {plan.paddle_plan_id}
+
+
+ CURRENT
+
+
+ <.td max_width="max-w-40">
+ <.table rows={[
+ {"Pageviews", number_format(plan.monthly_pageview_limit)},
+ {"Sites", number_format(plan.site_limit)},
+ {"Members", number_format(plan.team_member_limit)},
+ {"API Requests", number_format(plan.hourly_api_request_limit)}
+ ]}>
+ <:tbody :let={{label, value}}>
+ <.td>{label}
+ <.td>{value}
+
+
+
+ <.td class="align-top">
+
{feat.display_name()}
+
+
+
+
+ <.form
+ :let={f}
+ :if={@show_plan_form?}
+ for={@plan_form}
+ phx-submit="save-plan"
+ phx-target={@myself}
+ >
+ <.input field={f[:paddle_plan_id]} label="Paddle Plan ID" autocomplete="off" />
+ <.input
+ type="select"
+ options={["monthly", "yearly"]}
+ field={f[:billing_interval]}
+ label="Billing Interval"
+ autocomplete="off"
+ />
+
+ <.input
+ x-on:input="numberFormatCallback(event)"
+ field={f[:monthly_pageview_limit]}
+ label="Monthly Pageview Limit"
+ autocomplete="off"
+ />
+ <.input
+ x-on:input="numberFormatCallback(event)"
+ field={f[:site_limit]}
+ label="Site Limit"
+ autocomplete="off"
+ />
+ <.input
+ x-on:input="numberFormatCallback(event)"
+ field={f[:team_member_limit]}
+ label="Team Member Limit"
+ autocomplete="off"
+ />
+ <.input
+ x-on:input="numberFormatCallback(event)"
+ field={f[:hourly_api_request_limit]}
+ label="Hourly API Request Limit"
+ autocomplete="off"
+ />
+
+ <.input
+ :for={
+ mod <-
+ Plausible.Billing.Feature.list()
+ |> Enum.sort_by(fn item -> if item.name() == :stats_api, do: 0, else: 1 end)
+ }
+ :if={not mod.free?()}
+ type="checkbox"
+ name={"#{f.name}[features[]][]"}
+ value={mod.name()}
+ label={mod.display_name()}
+ checked={mod in (f.source.changes[:features] || [])}
+ />
+
+ <.button type="submit">
+ Save Custom Plan
+
+
+
+ <.button :if={!@show_plan_form?} phx-click="show-plan-form" phx-target={@myself}>
+ New Custom Plan
+
+ <.button
+ :if={@show_plan_form?}
+ theme="bright"
+ phx-click="hide-plan-form"
+ phx-target={@myself}
+ >
+ Cancel
+
+
+
+
+ <.form :let={f} for={@form} phx-submit="save-team" phx-target={@myself}>
+ <.input field={f[:trial_expiry_date]} label="Trial Expiry Date" />
+ <.input field={f[:accept_traffic_until]} label="Accept traffic Until" />
+ <.input
+ type="checkbox"
+ field={f[:allow_next_upgrade_override]}
+ label="Allow Next Upgrade Override"
+ />
+
+ <.input type="textarea" field={f[:notes]} label="Notes" />
+ <.button type="submit">
+ Save
+
+
+
+
+
+ <.table rows={@sites.entries}>
+ <:thead>
+ <.th>Domain
+ <.th>Previous Domain
+ <.th invisible>Settings
+ <.th invisible>Dashboard
+
+ <:tbody :let={site}>
+ <.td>
+
+
+ <.styled_link
+ patch={"/cs/sites/site/#{site.id}"}
+ class="cursor-pointer flex block items-center"
+ >
+ {site.domain}
+
+
+
+ <.td>{site.domain_changed_from || "--"}
+ <.td>
+ <.styled_link
+ new_tab={true}
+ href={Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain, [])}
+ >
+ Dashboard
+
+
+ <.td>
+ <.styled_link
+ new_tab={true}
+ href={Routes.site_path(PlausibleWeb.Endpoint, :settings_general, site.domain, [])}
+ >
+ Settings
+
+
+
+
+
+
+
+ <.table rows={Layout.sorted_for_display(@team_layout)}>
+ <:thead>
+ <.th>User
+ <.th>Type
+ <.th>Role
+
+ <:tbody :let={{_, member}}>
+ <.td truncate>
+
+ <.styled_link
+ patch={"/cs/users/user/#{member.id}"}
+ class="cursor-pointer flex block items-center"
+ >
+
+ {member.name} <{member.email}>
+
+
+
+
+ {member.name} <{member.email}>
+
+
+ <.td>
+ {member.type}
+
+ <.td>
+ {member.role}
+
+
+
+
+
+
+ """
+ end
+
+ def render_result(assigns) do
+ ~H"""
+
+
+
+
+
+
+ {@resource.object.name}
+
+
+
+ Team
+
+
+
+
+
+ Team identifier:
+ {@resource.object.identifier |> String.slice(0, 8)}
+
+ Owned by: {@resource.object.owners
+ |> Enum.map(& &1.name)
+ |> Enum.join(", ")}
+
+
+ """
+ end
+
+ def handle_event("show-plan-form", _, socket) do
+ {:noreply, assign(socket, show_plan_form?: true)}
+ end
+
+ def handle_event("hide-plan-form", _, socket) do
+ {:noreply, assign(socket, show_plan_form?: false)}
+ end
+
+ def handle_event("save-team", %{"team" => params}, socket) do
+ changeset = Plausible.Teams.Team.crm_changeset(socket.assigns.team, params)
+
+ case Plausible.Repo.update(changeset) do
+ {:ok, team} ->
+ success(socket, "Team saved")
+ {:noreply, assign(socket, team: team, form: to_form(changeset))}
+
+ {:error, changeset} ->
+ failure(socket, "Error saving team: #{inspect(changeset.errors)}")
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+
+ def handle_event("save-plan", %{"enterprise_plan" => params}, socket) do
+ params = Map.put(params, "features", Enum.reject(params["features[]"], &(&1 == "false")))
+ params = sanitize_params(params)
+ changeset = EnterprisePlan.changeset(%EnterprisePlan{team_id: socket.assigns.team.id}, params)
+
+ case Plausible.Repo.insert(changeset) do
+ {:ok, _plan} ->
+ success(socket, "Plan saved")
+ plans = get_plans(socket.assigns.team.id)
+
+ {:noreply,
+ assign(socket, plans: plans, plan_form: to_form(changeset), show_plan_form?: false)}
+
+ {:error, changeset} ->
+ failure(socket, "Error saving team: #{inspect(changeset.errors)}")
+ {:noreply, assign(socket, plan_form: to_form(changeset))}
+ end
+ end
+
+ def handle_event("unlock", _, socket) do
+ {:noreply, unlock_team(socket)}
+ end
+
+ def handle_event("lock", _, socket) do
+ {:noreply, lock_team(socket)}
+ end
+
+ def team_bg(term) do
+ list = [
+ "bg-blue-500",
+ "bg-blue-600",
+ "bg-blue-700",
+ "bg-blue-800",
+ "bg-cyan-500",
+ "bg-cyan-600",
+ "bg-cyan-700",
+ "bg-cyan-800",
+ "bg-red-500",
+ "bg-red-600",
+ "bg-red-700",
+ "bg-red-800",
+ "bg-green-500",
+ "bg-green-600",
+ "bg-green-700",
+ "bg-green-800",
+ "bg-yellow-500",
+ "bg-yellow-600",
+ "bg-yellow-700",
+ "bg-yellow-800",
+ "bg-orange-500",
+ "bg-orange-600",
+ "bg-orange-700",
+ "bg-orange-800",
+ "bg-purple-500",
+ "bg-purple-600",
+ "bg-purple-700",
+ "bg-purple-800",
+ "bg-gray-500",
+ "bg-gray-600",
+ "bg-gray-700",
+ "bg-gray-800",
+ "bg-emerald-500",
+ "bg-emerald-600",
+ "bg-emerald-700",
+ "bg-emerald-800"
+ ]
+
+ idx = :erlang.phash2(term, length(list))
+ Enum.at(list, idx)
+ 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
+ assigns = %{status_str: status_str, subscription: team.subscription}
+
+ ~H"""
+ <.styled_link new_tab={true} href={manage_url(@subscription)}>{@status_str}
+ """
+ else
+ status_str
+ end
+
+ Plausible.Teams.on_trial?(team) ->
+ "On trial"
+
+ true ->
+ "Trial expired"
+ end
+ end
+
+ defp manage_url(%{paddle_subscription_id: paddle_id} = _subscription) do
+ Plausible.Billing.PaddleApi.vendors_domain() <>
+ "/subscriptions/customers/manage/" <> paddle_id
+ 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)
+
+ assigns = %{quota: quota, interval: interval, subscription: subscription}
+
+ ~H"""
+ <.styled_link new_tab={true} href={manage_url(@subscription)}>
+ {@quota} ({@interval})
+
+ """
+ else
+ "--"
+ 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
+
+ defp lock_team(socket) do
+ if socket.assigns.team.grace_period do
+ team = socket.assigns.team
+ Plausible.Billing.SiteLocker.set_lock_status_for(team, true)
+ Plausible.Teams.end_grace_period(team)
+
+ success(socket, "Team locked. Grace period ended.")
+ assign(socket, team: team)
+ else
+ failure(socket, "No grace period")
+ socket
+ end
+ end
+
+ defp unlock_team(socket) do
+ if socket.assigns.team.grace_period do
+ team =
+ socket.assigns.team
+ |> Plausible.Teams.remove_grace_period()
+ |> Plausible.Billing.SiteLocker.set_lock_status_for(false)
+
+ success(socket, "Team unlocked. Grace period removed.")
+ assign(socket, team: team)
+ else
+ socket
+ end
+ end
+
+ defp monthly_pageviews_usage(usage, limit) do
+ usage
+ |> Enum.sort_by(fn {_cycle, usage} -> usage.date_range.first end, :desc)
+ |> Enum.map(fn {cycle, usage} ->
+ {cycle, PlausibleWeb.TextHelpers.format_date_range(usage.date_range), usage.total, limit}
+ end)
+ end
+
+ defp get_plans(team_id) do
+ Repo.all(
+ from ep in EnterprisePlan,
+ where: ep.team_id == ^team_id,
+ order_by: [desc: :id]
+ )
+ end
+
+ defp number_format(number) when is_integer(number) do
+ Cldr.Number.to_string!(number)
+ end
+
+ defp number_format(other), do: other
+
+ @numeric_fields [
+ "team_id",
+ "paddle_plan_id",
+ "monthly_pageview_limit",
+ "site_limit",
+ "team_member_limit",
+ "hourly_api_request_limit"
+ ]
+
+ defp sanitize_params(params) do
+ params
+ |> Enum.map(&clear_param/1)
+ |> Enum.reject(&(&1 == ""))
+ |> Map.new()
+ end
+
+ defp clear_param({key, value}) when key in @numeric_fields do
+ value =
+ value
+ |> to_string()
+ |> String.replace(~r/[^0-9-]/, "")
+ |> String.trim()
+
+ {key, value}
+ end
+
+ defp clear_param({key, value}) when is_binary(value) do
+ {key, String.trim(value)}
+ end
+
+ defp clear_param(other) do
+ other
+ end
+end
diff --git a/extra/lib/plausible_web/live/customer_support/live/user.ex b/extra/lib/plausible_web/live/customer_support/live/user.ex
new file mode 100644
index 0000000000..31afe615f0
--- /dev/null
+++ b/extra/lib/plausible_web/live/customer_support/live/user.ex
@@ -0,0 +1,105 @@
+defmodule PlausibleWeb.CustomerSupport.Live.User do
+ @moduledoc false
+ use Plausible.CustomerSupport.Resource, :component
+ use PlausibleWeb.Live.Flash
+
+ def update(assigns, socket) do
+ user = socket.assigns[:user] || Resource.User.get(assigns.resource_id)
+ form = user |> Plausible.Auth.User.changeset() |> to_form()
+ {:ok, assign(socket, user: user, form: form)}
+ end
+
+ def render(assigns) do
+ ~H"""
+
+
+
+
+
+
+
+
+
+
+ {@user.name}
+
+
+ {@user.email}
+
+ (previously: {@user.previous_email})
+
+
+
+
+
+
+ <.table rows={@user.team_memberships}>
+ <:thead>
+ <.th>Team
+ <.th>Role
+
+ <:tbody :let={membership}>
+ <.td>
+ <.styled_link patch={"/cs/teams/team/#{membership.team.id}"}>
+ {membership.team.name}
+
+
+ <.td>{membership.role}
+
+
+
+ <.form :let={f} for={@form} phx-target={@myself} phx-submit="save-user" class="mt-8">
+ <.input type="textarea" field={f[:notes]} label="Notes" />
+ <.button phx-target={@myself} type="submit">
+ Save
+
+
+
+
+ """
+ end
+
+ def render_result(assigns) do
+ ~H"""
+
+
+
+
+ {@resource.object.name}
+
+
+
+ User
+
+
+
+
+
+ {@resource.object.name} <{@resource.object.email}>
+ Owns {length(@resource.object.owned_teams)} team(s)
+
+
+ """
+ end
+
+ def handle_event("save-user", %{"user" => params}, socket) do
+ changeset = Plausible.Auth.User.changeset(socket.assigns.user, params)
+
+ case Plausible.Repo.update(changeset) do
+ {:ok, user} ->
+ success(socket, "User updated")
+ {:noreply, assign(socket, user: user, form: to_form(changeset))}
+
+ {:error, changeset} ->
+ failure(socket, inspect(changeset.errors))
+ send(socket.root_pid, {:failure, inspect(changeset.errors)})
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+end
diff --git a/extra/lib/plausible_web/views/help_scout_view.ex b/extra/lib/plausible_web/views/help_scout_view.ex
index 3215c76402..b0c578dac8 100644
--- a/extra/lib/plausible_web/views/help_scout_view.ex
+++ b/extra/lib/plausible_web/views/help_scout_view.ex
@@ -85,7 +85,7 @@ defmodule PlausibleWeb.HelpScoutView do
diff --git a/lib/plausible/auth/user.ex b/lib/plausible/auth/user.ex
index bae6c03517..e1110a5ddc 100644
--- a/lib/plausible/auth/user.ex
+++ b/lib/plausible/auth/user.ex
@@ -187,6 +187,10 @@ defmodule Plausible.Auth.User do
Path.join(PlausibleWeb.Endpoint.url(), ["avatar/", hash])
end
+ def profile_img_url(email) when is_binary(email) do
+ profile_img_url(%__MODULE__{email: email})
+ end
+
defp validate_email_changed(changeset) do
if !get_change(changeset, :email) && !changeset.errors[:email] do
add_error(changeset, :email, "can't be the same", validation: :different_email)
diff --git a/lib/plausible/billing/ecto/feature.ex b/lib/plausible/billing/ecto/feature.ex
index f9464cc617..a5994eee78 100644
--- a/lib/plausible/billing/ecto/feature.ex
+++ b/lib/plausible/billing/ecto/feature.ex
@@ -45,6 +45,7 @@ defmodule Plausible.Billing.Ecto.FeatureList do
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)
diff --git a/lib/plausible/billing/ecto/limit.ex b/lib/plausible/billing/ecto/limit.ex
index 21c5bedce4..71097cdc46 100644
--- a/lib/plausible/billing/ecto/limit.ex
+++ b/lib/plausible/billing/ecto/limit.ex
@@ -20,6 +20,7 @@ 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()
diff --git a/lib/plausible/billing/enterprise_plan.ex b/lib/plausible/billing/enterprise_plan.ex
index 54cd4efa11..b729047f21 100644
--- a/lib/plausible/billing/enterprise_plan.ex
+++ b/lib/plausible/billing/enterprise_plan.ex
@@ -29,9 +29,14 @@ defmodule Plausible.Billing.EnterprisePlan do
timestamps()
end
+ @max round(:math.pow(2, 31))
+
def changeset(model, attrs \\ %{}) do
model
|> cast(attrs, @required_fields)
+ |> validate_number(:monthly_pageview_limit, less_than: @max)
+ |> validate_number(:site_limit, less_than: @max)
+ |> validate_number(:hourly_api_request_limit, less_than: @max)
|> validate_required(@required_fields)
end
end
diff --git a/lib/plausible/billing/feature.ex b/lib/plausible/billing/feature.ex
index 813a77841c..f60ab21d3d 100644
--- a/lib/plausible/billing/feature.ex
+++ b/lib/plausible/billing/feature.ex
@@ -71,13 +71,13 @@ defmodule Plausible.Billing.Feature do
:ok | {:error, :upgrade_required} | {:error, :not_implemented}
@features [
- Plausible.Billing.Feature.Goals,
- Plausible.Billing.Feature.StatsAPI,
- Plausible.Billing.Feature.SitesAPI,
Plausible.Billing.Feature.Props,
Plausible.Billing.Feature.Funnels,
+ Plausible.Billing.Feature.Goals,
Plausible.Billing.Feature.RevenueGoals,
- Plausible.Billing.Feature.SiteSegments
+ Plausible.Billing.Feature.SiteSegments,
+ Plausible.Billing.Feature.SitesAPI,
+ Plausible.Billing.Feature.StatsAPI
]
# Generate a union type for features
diff --git a/lib/plausible/crm_extensions.ex b/lib/plausible/crm_extensions.ex
index 8af88a20d4..6c306dd767 100644
--- a/lib/plausible/crm_extensions.ex
+++ b/lib/plausible/crm_extensions.ex
@@ -47,7 +47,9 @@ defmodule Plausible.CrmExtensions do
]
end
- def javascripts(%{assigns: %{context: "sites", resource: "site", entry: %{domain: domain}}}) do
+ def javascripts(%{
+ assigns: %{context: "sites", resource: "site", entry: %{domain: domain, id: id}}
+ }) do
base_url = PlausibleWeb.Endpoint.url()
[
@@ -58,7 +60,7 @@ defmodule Plausible.CrmExtensions do
if (cardBody) {
const buttonDOM = document.createElement("div")
buttonDOM.className = "mb-3 w-full text-right"
- buttonDOM.innerHTML = ''
+ buttonDOM.innerHTML = ''
cardBody.prepend(buttonDOM)
}
})()
diff --git a/lib/plausible/teams/management/layout/entry.ex b/lib/plausible/teams/management/layout/entry.ex
index af6335ba19..c0637e2068 100644
--- a/lib/plausible/teams/management/layout/entry.ex
+++ b/lib/plausible/teams/management/layout/entry.ex
@@ -5,7 +5,7 @@ defmodule Plausible.Teams.Management.Layout.Entry do
"""
alias Plausible.Teams
- defstruct [:email, :name, :role, :type, :meta, :queued_op]
+ defstruct [:email, :name, :role, :type, :meta, :queued_op, :id]
@type t() :: %__MODULE__{}
@@ -19,6 +19,7 @@ defmodule Plausible.Teams.Management.Layout.Entry do
when is_integer(existing) do
%__MODULE__{
name: "Invited User",
+ id: 0,
email: invitation.email,
role: invitation.role,
type: :invitation_sent,
@@ -30,6 +31,7 @@ defmodule Plausible.Teams.Management.Layout.Entry do
def new(%Teams.Invitation{id: nil} = pending, attrs) do
%__MODULE__{
name: "Invited User",
+ id: 0,
email: pending.email,
role: pending.role,
type: :invitation_pending,
@@ -42,6 +44,7 @@ defmodule Plausible.Teams.Management.Layout.Entry do
%__MODULE__{
name: membership.user.name,
role: membership.role,
+ id: membership.user.id,
email: membership.user.email,
type: :membership,
meta: membership
diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex
index 815578f146..517e1c659f 100644
--- a/lib/plausible_web/components/generic.ex
+++ b/lib/plausible_web/components/generic.ex
@@ -207,7 +207,7 @@ defmodule PlausibleWeb.Components.Generic do
attr(:href, :string, default: "#")
attr(:new_tab, :boolean, default: false)
attr(:class, :string, default: "")
- attr(:rest, :global)
+ attr(:rest, :global, include: ~w(patch))
attr(:method, :string, default: "get")
slot(:inner_block)
@@ -592,6 +592,7 @@ defmodule PlausibleWeb.Components.Generic do
attr :truncate, :boolean, default: false
attr :max_width, :string, default: ""
attr :height, :string, default: ""
+ attr :class, :string, default: ""
attr :actions, :boolean, default: nil
attr :hide_on_mobile, :boolean, default: nil
attr :rest, :global
@@ -614,7 +615,8 @@ defmodule PlausibleWeb.Components.Generic do
@truncate && "truncate",
@max_width,
@actions && "flex text-right justify-end",
- @hide_on_mobile && "hidden md:table-cell"
+ @hide_on_mobile && "hidden md:table-cell",
+ @class
]}
{@rest}
>
@@ -732,10 +734,10 @@ defmodule PlausibleWeb.Components.Generic do
def filter_bar(assigns) do
~H"""
-
-
-
diff --git a/lib/plausible_web/controllers/admin_controller.ex b/lib/plausible_web/controllers/admin_controller.ex
index c3b2557916..0a66909d8c 100644
--- a/lib/plausible_web/controllers/admin_controller.ex
+++ b/lib/plausible_web/controllers/admin_controller.ex
@@ -42,6 +42,7 @@ defmodule PlausibleWeb.AdminController do
teams_list = Plausible.Auth.UserAdmin.teams(user.owned_teams)
html_response = """
+
Open in CS
Owned teams:
#{teams_list}
@@ -186,6 +187,7 @@ defmodule PlausibleWeb.AdminController do
defp usage_and_limits_html(team, usage, limits, embed?) do
content = """
+
Open in CS
Team: #{html_escape(Teams.name(team))}
Setup: #{if(team.setup_complete, do: "Yes", else: "No")}
diff --git a/lib/plausible_web/live/components/combo_box.ex b/lib/plausible_web/live/components/combo_box.ex
index 091dfaea20..d432f0f4ab 100644
--- a/lib/plausible_web/live/components/combo_box.ex
+++ b/lib/plausible_web/live/components/combo_box.ex
@@ -1,13 +1,14 @@
defmodule PlausibleWeb.Live.Components.ComboBox do
@moduledoc """
Phoenix LiveComponent for a combobox UI element with search and selection
- functionality.
+ functionality.
The component allows users to select an option from a list of options,
which can be searched by typing in the input field.
The component renders an input field with a dropdown anchor and a
- hidden input field for submitting the selected value.
+ hidden input field for submitting the selected value. In order to remain
+ functional, the component must be embedded in a ` `.
The number of options displayed in the dropdown is limited to 15
by default but can be customized. When a user types into the input
diff --git a/lib/plausible_web/live/components/form.ex b/lib/plausible_web/live/components/form.ex
index 2599ab2fd9..26d177e207 100644
--- a/lib/plausible_web/live/components/form.ex
+++ b/lib/plausible_web/live/components/form.ex
@@ -90,19 +90,27 @@ defmodule PlausibleWeb.Live.Components.Form do
end
def input(%{type: "checkbox"} = assigns) do
+ assigns =
+ assign_new(assigns, :checked, fn ->
+ Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
+ end)
+
~H"""
-
- <.label for={@id}>{@label}
+ <.label for={@id} class="gap-x-2 flex flex-inline items-center sm:justify-start justify-center ">
+
+
+ {@label}
+
"""
end
@@ -137,6 +145,22 @@ defmodule PlausibleWeb.Live.Components.Form do
"""
end
+ def input(%{type: "textarea"} = assigns) do
+ ~H"""
+
+ <.label for={@id}>{@label}
+ {Phoenix.HTML.Form.normalize_value("textarea", @value)}
+ <.error :for={msg <- @errors}>{msg}
+
+ """
+ end
+
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
errors =
diff --git a/lib/plausible_web/live/components/team.ex b/lib/plausible_web/live/components/team.ex
index 002992c671..b6dd68681c 100644
--- a/lib/plausible_web/live/components/team.ex
+++ b/lib/plausible_web/live/components/team.ex
@@ -45,7 +45,7 @@ defmodule PlausibleWeb.Live.Components.Team do
<.dropdown id={"role-dropdown-#{@user.email}"}>
<:button class="role bg-transparent text-gray-800 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 focus-visible:outline-gray-100 whitespace-nowrap truncate inline-flex items-center gap-x-2 font-medium rounded-md px-3.5 py-2.5 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700">
{@role |> to_string() |> String.capitalize()}
-
+
<:menu class="dropdown-items max-w-60">
<.role_item
diff --git a/lib/plausible_web/live/sites.ex b/lib/plausible_web/live/sites.ex
index d485aa3b3c..8a4e7ee8ad 100644
--- a/lib/plausible_web/live/sites.ex
+++ b/lib/plausible_web/live/sites.ex
@@ -51,6 +51,7 @@ defmodule PlausibleWeb.Live.Sites do
~H"""
<.flash_messages flash={@flash} />
Enum.into(%{}) |> Jason.encode!}}"}
x-on:keydown.escape.window="invitationOpen = false"
class="container pt-6"
@@ -539,43 +540,7 @@ defmodule PlausibleWeb.Live.Sites do
def search_form(assigns) do
~H"""
-
-
-
-
-
-
-
-
- <.spinner class="hidden phx-change-loading:inline ml-2" />
-
-
+ <.filter_bar filter_text={@filter_text} placeholder="Search Sites">
"""
end
@@ -635,13 +600,13 @@ defmodule PlausibleWeb.Live.Sites do
def handle_event(
"filter",
- %{"filter_text" => filter_text},
+ %{"filter-text" => filter_text},
%{assigns: %{filter_text: filter_text}} = socket
) do
{:noreply, socket}
end
- def handle_event("filter", %{"filter_text" => filter_text}, socket) do
+ def handle_event("filter", %{"filter-text" => filter_text}, socket) do
socket =
socket
|> reset_pagination()
diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex
index 91a0c0169c..c37e9796fd 100644
--- a/lib/plausible_web/router.ex
+++ b/lib/plausible_web/router.ex
@@ -108,6 +108,16 @@ defmodule PlausibleWeb.Router do
end
end
+ on_ee do
+ scope alias: PlausibleWeb.Live, assigns: %{connect_live_socket: true} do
+ pipe_through [:browser, :csrf, :app_layout, :flags]
+
+ live "/cs", CustomerSupport, :index, as: :customer_support
+
+ live "/cs/:any/:resource/:id", CustomerSupport, :details, as: :customer_support_resource
+ end
+ end
+
on_ee do
scope path: "/flags" do
pipe_through :flags
diff --git a/lib/plausible_web/templates/layout/_header.html.heex b/lib/plausible_web/templates/layout/_header.html.heex
index eee6613f64..da20b8d3ee 100644
--- a/lib/plausible_web/templates/layout/_header.html.heex
+++ b/lib/plausible_web/templates/layout/_header.html.heex
@@ -37,11 +37,19 @@
}>
<.styled_link
class="text-sm mr-6"
- href={PlausibleWeb.Endpoint.url() <> "/crm/sites/site/#{@conn.assigns[:site].id}"}
+ href={PlausibleWeb.Endpoint.url() <> "/crm/sites/site/#{@conn.assigns.site.id}"}
new_tab={true}
>
CRM
+
+ <.styled_link
+ class="text-sm mr-6"
+ href={"/cs/sites/site/#{@conn.assigns.site.id}"}
+ new_tab={true}
+ >
+ CS
+
\nwith new line"
end
@@ -158,7 +158,7 @@ defmodule PlausibleWeb.HelpScoutControllerTest do
)
assert html = html_response(conn, 200)
- assert html =~ "/crm/auth/user/#{user.id}"
+ assert html =~ "/cs/users/user/#{user.id}"
assert html =~ "Some user notes"
assert html =~ "My Personal Sites"
assert html =~ "HS Integration Test Team"
diff --git a/test/plausible_web/live/sites_test.exs b/test/plausible_web/live/sites_test.exs
index 9bd040c37c..0acbcb6283 100644
--- a/test/plausible_web/live/sites_test.exs
+++ b/test/plausible_web/live/sites_test.exs
@@ -246,7 +246,7 @@ defmodule PlausibleWeb.Live.SitesTest do
{:ok, lv, _html} = live(conn, "/sites")
- type_into_input(lv, "filter_text", "firs")
+ type_into_input(lv, "filter-text", "firs")
html = render(lv)
assert html =~ "first.example.com"
@@ -267,7 +267,7 @@ defmodule PlausibleWeb.Live.SitesTest do
assert html =~ "page=2"
refute html =~ "page=1"
- type_into_input(lv, "filter_text", "anot")
+ type_into_input(lv, "filter-text", "anot")
html = render(lv)
assert html =~ "first.another.example.com"
@@ -355,7 +355,7 @@ defmodule PlausibleWeb.Live.SitesTest do
defp get_invitation_data(html) do
html
- |> text_of_attr("div[x-data]", "x-data")
+ |> text_of_attr("div[x-ref=\"invitation_data\"][x-data]", "x-data")
|> String.trim("dropdown")
|> String.replace("selectedInvitation:", "\"selectedInvitation\":")
|> String.replace("invitationOpen:", "\"invitationOpen\":")