393 lines
12 KiB
Elixir
393 lines
12 KiB
Elixir
defmodule PlausibleWeb.SettingsController do
|
|
use PlausibleWeb, :controller
|
|
use Plausible
|
|
use Plausible.Repo
|
|
|
|
alias Plausible.Auth
|
|
alias Plausible.Teams
|
|
|
|
require Logger
|
|
|
|
plug Plausible.Plugs.AuthorizeTeamAccess,
|
|
[:owner, :admin] when action in [:update_team_name]
|
|
|
|
plug Plausible.Plugs.AuthorizeTeamAccess,
|
|
[:owner, :billing] when action in [:subscription, :invoices]
|
|
|
|
plug Plausible.Plugs.AuthorizeTeamAccess,
|
|
[:owner] when action in [:team_danger_zone, :delete_team]
|
|
|
|
plug Plausible.Plugs.RestrictUserType,
|
|
[deny: :sso] when action in [:update_name, :update_email, :update_password]
|
|
|
|
def index(conn, _params) do
|
|
redirect(conn, to: Routes.settings_path(conn, :preferences))
|
|
end
|
|
|
|
def team_general(conn, _params) do
|
|
render_team_general(conn)
|
|
end
|
|
|
|
def update_team_name(conn, %{"team" => params}) do
|
|
changeset = Plausible.Teams.Team.name_changeset(conn.assigns.current_team, params)
|
|
|
|
case Repo.update(changeset) do
|
|
{:ok, _user} ->
|
|
conn
|
|
|> put_flash(:success, "Team name changed")
|
|
|> redirect(to: Routes.settings_path(conn, :team_general) <> "#update-name")
|
|
|
|
{:error, changeset} ->
|
|
render_team_general(conn, team_name_changeset: changeset)
|
|
end
|
|
end
|
|
|
|
defp render_team_general(conn, opts \\ []) do
|
|
if Plausible.Teams.setup?(conn.assigns.current_team) do
|
|
name_changeset =
|
|
Keyword.get(
|
|
opts,
|
|
:team_name_changeset,
|
|
Plausible.Teams.Team.name_changeset(conn.assigns.current_team)
|
|
)
|
|
|
|
render(conn, :team_general,
|
|
team_name_changeset: name_changeset,
|
|
layout: {PlausibleWeb.LayoutView, :settings},
|
|
connect_live_socket: true
|
|
)
|
|
else
|
|
conn
|
|
|> redirect(to: Routes.site_path(conn, :index))
|
|
end
|
|
end
|
|
|
|
def preferences(conn, _params) do
|
|
render_preferences(conn)
|
|
end
|
|
|
|
def security(conn, _params) do
|
|
render_security(conn)
|
|
end
|
|
|
|
def subscription(conn, _params) do
|
|
team = conn.assigns.current_team
|
|
subscription = Teams.Billing.get_subscription(team)
|
|
|
|
render(conn, :subscription,
|
|
layout: {PlausibleWeb.LayoutView, :settings},
|
|
subscription: subscription,
|
|
pageview_limit: Teams.Billing.monthly_pageview_limit(subscription),
|
|
pageview_usage: Teams.Billing.monthly_pageview_usage(team),
|
|
site_usage: Teams.Billing.site_usage(team),
|
|
site_limit: Teams.Billing.site_limit(team),
|
|
team_member_limit: Teams.Billing.team_member_limit(team),
|
|
team_member_usage: Teams.Billing.team_member_usage(team)
|
|
)
|
|
end
|
|
|
|
def invoices(conn, _params) do
|
|
subscription = Teams.Billing.get_subscription(conn.assigns.current_team)
|
|
|
|
invoices = Plausible.Billing.paddle_api().get_invoices(subscription)
|
|
render(conn, :invoices, layout: {PlausibleWeb.LayoutView, :settings}, invoices: invoices)
|
|
end
|
|
|
|
def api_keys(conn, _params) do
|
|
current_user = conn.assigns.current_user
|
|
current_team = conn.assigns[:current_team]
|
|
|
|
api_keys = Auth.list_api_keys(current_user, current_team)
|
|
|
|
render(conn, :api_keys, layout: {PlausibleWeb.LayoutView, :settings}, api_keys: api_keys)
|
|
end
|
|
|
|
def new_api_key(conn, _params) do
|
|
current_team = conn.assigns[:current_team]
|
|
|
|
sites_api_enabled? =
|
|
Plausible.Billing.Feature.SitesAPI.check_availability(current_team) == :ok
|
|
|
|
changeset = Auth.ApiKey.changeset(%Auth.ApiKey{type: "stats_api"}, current_team, %{})
|
|
|
|
render(conn, "new_api_key.html", changeset: changeset, sites_api_enabled?: sites_api_enabled?)
|
|
end
|
|
|
|
def create_api_key(conn, %{"api_key" => %{"name" => name, "key" => key, "type" => type}}) do
|
|
current_user = conn.assigns.current_user
|
|
current_team = conn.assigns.current_team
|
|
|
|
sites_api_enabled? =
|
|
Plausible.Billing.Feature.SitesAPI.check_availability(current_team) == :ok
|
|
|
|
api_key_fn =
|
|
if type == "sites_api" do
|
|
&Auth.create_sites_api_key/4
|
|
else
|
|
&Auth.create_stats_api_key/4
|
|
end
|
|
|
|
case api_key_fn.(current_user, current_team, name, key) do
|
|
{:ok, _api_key} ->
|
|
conn
|
|
|> put_flash(:success, "API key created successfully")
|
|
|> redirect(to: Routes.settings_path(conn, :api_keys) <> "#api-keys")
|
|
|
|
{:error, :upgrade_required} ->
|
|
conn
|
|
|> put_flash(:error, "Your current subscription plan does not include Sites API access")
|
|
|> redirect(to: Routes.settings_path(conn, :new_api_key))
|
|
|
|
{:error, changeset} ->
|
|
render(conn, "new_api_key.html",
|
|
changeset: changeset,
|
|
sites_api_enabled?: sites_api_enabled?
|
|
)
|
|
end
|
|
end
|
|
|
|
def delete_api_key(conn, %{"id" => id}) do
|
|
case Auth.delete_api_key(conn.assigns.current_user, id) do
|
|
:ok ->
|
|
conn
|
|
|> put_flash(:success, "API key revoked successfully")
|
|
|> redirect(to: Routes.settings_path(conn, :api_keys) <> "#api-keys")
|
|
|
|
{:error, :not_found} ->
|
|
conn
|
|
|> put_flash(:error, "Could not find API Key to delete")
|
|
|> redirect(to: Routes.settings_path(conn, :api_keys) <> "#api-keys")
|
|
end
|
|
end
|
|
|
|
def danger_zone(conn, _params) do
|
|
solely_owned_teams =
|
|
conn.assigns.current_user
|
|
|> Teams.Users.owned_teams()
|
|
|> Enum.filter(& &1.setup_complete)
|
|
|> Enum.reject(fn team ->
|
|
Teams.Memberships.owners_count(team) > 1
|
|
end)
|
|
|
|
render(conn, :danger_zone,
|
|
solely_owned_teams: solely_owned_teams,
|
|
layout: {PlausibleWeb.LayoutView, :settings}
|
|
)
|
|
end
|
|
|
|
def team_danger_zone(conn, _params) do
|
|
render(conn, :team_danger_zone, layout: {PlausibleWeb.LayoutView, :settings})
|
|
end
|
|
|
|
def delete_team(conn, _params) do
|
|
team = conn.assigns.current_team
|
|
|
|
case Plausible.Teams.delete(team) do
|
|
{:ok, :deleted} ->
|
|
conn
|
|
|> put_flash(:success, ~s|Team "#{Plausible.Teams.name(team)}" deleted|)
|
|
|> redirect(to: Routes.site_path(conn, :index, __team: "none"))
|
|
|
|
{:error, :active_subscription} ->
|
|
conn
|
|
|> put_flash(
|
|
:error,
|
|
"Team has an active subscription. You must cancel it first."
|
|
)
|
|
|> redirect(to: Routes.settings_path(conn, :team_danger_zone))
|
|
end
|
|
end
|
|
|
|
# Preferences actions
|
|
|
|
def update_name(conn, %{"user" => params}) do
|
|
changeset = Auth.User.name_changeset(conn.assigns.current_user, params)
|
|
|
|
case Repo.update(changeset) do
|
|
{:ok, _user} ->
|
|
conn
|
|
|> put_flash(:success, "Name changed")
|
|
|> redirect(to: Routes.settings_path(conn, :preferences) <> "#update-name")
|
|
|
|
{:error, changeset} ->
|
|
render_preferences(conn, name_changeset: changeset)
|
|
end
|
|
end
|
|
|
|
def update_theme(conn, %{"user" => params}) do
|
|
changeset = Auth.User.theme_changeset(conn.assigns.current_user, params)
|
|
|
|
case Repo.update(changeset) do
|
|
{:ok, _user} ->
|
|
conn
|
|
|> put_flash(:success, "Theme changed")
|
|
|> redirect(to: Routes.settings_path(conn, :preferences) <> "#update-theme")
|
|
|
|
{:error, changeset} ->
|
|
render_preferences(conn, theme_changeset: changeset)
|
|
end
|
|
end
|
|
|
|
defp render_preferences(conn, opts \\ []) do
|
|
name_changeset =
|
|
Keyword.get(opts, :name_changeset, Auth.User.name_changeset(conn.assigns.current_user))
|
|
|
|
theme_changeset =
|
|
Keyword.get(opts, :theme_changeset, Auth.User.theme_changeset(conn.assigns.current_user))
|
|
|
|
render(conn, :preferences,
|
|
name_changeset: name_changeset,
|
|
theme_changeset: theme_changeset,
|
|
layout: {PlausibleWeb.LayoutView, :settings}
|
|
)
|
|
end
|
|
|
|
# Security actions
|
|
|
|
def update_email(conn, %{"user" => params}) do
|
|
user = conn.assigns.current_user
|
|
|
|
with :ok <- Auth.rate_limit(:email_change_user, user),
|
|
changes = Auth.User.email_changeset(user, params),
|
|
{:ok, user} <- Repo.update(changes) do
|
|
if user.email_verified do
|
|
handle_email_updated(conn)
|
|
else
|
|
Auth.EmailVerification.issue_code(user)
|
|
redirect(conn, to: Routes.auth_path(conn, :activate_form))
|
|
end
|
|
else
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
render_security(conn, email_changeset: changeset)
|
|
|
|
{:error, {:rate_limit, _}} ->
|
|
changeset =
|
|
user
|
|
|> Auth.User.email_changeset(params)
|
|
|> Ecto.Changeset.add_error(:email, "too many requests, try again in an hour")
|
|
|> Map.put(:action, :validate)
|
|
|
|
render_security(conn, email_changeset: changeset)
|
|
end
|
|
end
|
|
|
|
def cancel_update_email(conn, _params) do
|
|
changeset = Auth.User.cancel_email_changeset(conn.assigns.current_user)
|
|
|
|
case Repo.update(changeset) do
|
|
{:ok, user} ->
|
|
conn
|
|
|> put_flash(:success, "Email changed back to #{user.email}")
|
|
|> redirect(to: Routes.settings_path(conn, :security) <> "#update-email")
|
|
|
|
{:error, _} ->
|
|
conn
|
|
|> put_flash(
|
|
:error,
|
|
"Could not cancel email update because previous email has already been taken"
|
|
)
|
|
|> redirect(to: Routes.auth_path(conn, :activate_form))
|
|
end
|
|
end
|
|
|
|
def update_password(conn, %{"user" => params}) do
|
|
user = conn.assigns.current_user
|
|
user_session = conn.assigns.current_user_session
|
|
|
|
with :ok <- Auth.rate_limit(:password_change_user, user),
|
|
{:ok, user} <- do_update_password(user, params) do
|
|
Auth.UserSessions.revoke_all(user, except: user_session)
|
|
|
|
conn
|
|
|> put_flash(:success, "Your password is now changed")
|
|
|> redirect(to: Routes.settings_path(conn, :security) <> "#update-password")
|
|
else
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
render_security(conn, password_changeset: changeset)
|
|
|
|
{:error, {:rate_limit, _}} ->
|
|
changeset =
|
|
user
|
|
|> Auth.User.password_changeset(params)
|
|
|> Ecto.Changeset.add_error(:password, "too many attempts, try again in 20 minutes")
|
|
|> Map.put(:action, :validate)
|
|
|
|
render_security(conn, password_changeset: changeset)
|
|
end
|
|
end
|
|
|
|
defp render_security(conn, opts \\ []) do
|
|
user_sessions = Auth.UserSessions.list_for_user(conn.assigns.current_user)
|
|
|
|
email_changeset =
|
|
Keyword.get(
|
|
opts,
|
|
:email_changeset,
|
|
Auth.User.email_changeset(conn.assigns.current_user, %{email: ""})
|
|
)
|
|
|
|
password_changeset =
|
|
Keyword.get(
|
|
opts,
|
|
:password_changeset,
|
|
Auth.User.password_changeset(conn.assigns.current_user)
|
|
)
|
|
|
|
render(conn, :security,
|
|
totp_enabled?: Auth.TOTP.enabled?(conn.assigns.current_user),
|
|
user_sessions: user_sessions,
|
|
email_changeset: email_changeset,
|
|
password_changeset: password_changeset,
|
|
layout: {PlausibleWeb.LayoutView, :settings}
|
|
)
|
|
end
|
|
|
|
def delete_session(conn, %{"id" => session_id}) do
|
|
current_user = conn.assigns.current_user
|
|
|
|
:ok = Auth.UserSessions.revoke_by_id(current_user, session_id)
|
|
|
|
conn
|
|
|> put_flash(:success, "Session logged out successfully")
|
|
|> redirect(to: Routes.settings_path(conn, :security) <> "#user-sessions")
|
|
end
|
|
|
|
defp do_update_password(user, params) do
|
|
changes = Auth.User.password_changeset(user, params)
|
|
|
|
Repo.transaction(fn ->
|
|
with {:ok, user} <- Repo.update(changes),
|
|
{:ok, user} <- validate_2fa_code(user, params["two_factor_code"]) do
|
|
user
|
|
else
|
|
{:error, :invalid_2fa} ->
|
|
changes
|
|
|> Ecto.Changeset.add_error(:password, "invalid 2FA code")
|
|
|> Map.put(:action, :validate)
|
|
|> Repo.rollback()
|
|
|
|
{:error, changeset} ->
|
|
Repo.rollback(changeset)
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp validate_2fa_code(user, code) do
|
|
if Auth.TOTP.enabled?(user) do
|
|
case Auth.TOTP.validate_code(user, code) do
|
|
{:ok, user} -> {:ok, user}
|
|
{:error, :not_enabled} -> {:ok, user}
|
|
{:error, _} -> {:error, :invalid_2fa}
|
|
end
|
|
else
|
|
{:ok, user}
|
|
end
|
|
end
|
|
|
|
defp handle_email_updated(conn) do
|
|
conn
|
|
|> put_flash(:success, "Email updated")
|
|
|> redirect(to: Routes.settings_path(conn, :security) <> "#update-email")
|
|
end
|
|
end
|