| 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] |
| when action in [ |
| :team_danger_zone, |
| :delete_team, |
| :enable_team_force_2fa, |
| :disable_team_force_2fa |
| ] |
|
|
| 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 = 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 Teams.setup?(conn.assigns.current_team) do |
| name_changeset = |
| Keyword.get( |
| opts, |
| :team_name_changeset, |
| Teams.Team.name_changeset(conn.assigns.current_team) |
| ) |
|
|
| render(conn, :team_general, |
| team_name_changeset: name_changeset, |
| force_2fa_enabled?: Teams.force_2fa_enabled?(conn.assigns.current_team), |
| layout: {PlausibleWeb.LayoutView, :settings}, |
| connect_live_socket: true |
| ) |
| else |
| conn |
| |> redirect(to: Routes.site_path(conn, :index)) |
| end |
| end |
|
|
| def enable_team_force_2fa(conn, _params) do |
| team = conn.assigns.current_team |
| user = conn.assigns.current_user |
|
|
| case Teams.enable_force_2fa(team, user) do |
| {:ok, _} -> |
| conn |
| |> put_flash(:success, "2FA is now required for all team members.") |
| |> redirect(to: Routes.settings_path(conn, :team_general)) |
|
|
| {:error, _} -> |
| conn |
| |> put_flash(:error, "Failed to enforce 2FA for all team members.") |
| |> redirect(to: Routes.settings_path(conn, :team_general)) |
| end |
| end |
|
|
| def disable_team_force_2fa(conn, %{"password" => password}) do |
| team = conn.assigns.current_team |
| user = conn.assigns.current_user |
|
|
| case Teams.disable_force_2fa(team, user, password) do |
| {:ok, _} -> |
| conn |
| |> put_flash(:success, "2FA is no longer enforced for team members.") |
| |> redirect(to: Routes.settings_path(conn, :team_general)) |
|
|
| {:error, :invalid_password} -> |
| conn |
| |> put_flash(:error, "Incorrect password provided.") |
| |> redirect(to: Routes.settings_path(conn, :team_general)) |
|
|
| {:error, _} -> |
| conn |
| |> put_flash(:error, "Failed to disable enforcing 2FA for all team members.") |
| |> redirect(to: Routes.settings_path(conn, :team_general)) |
| end |
| end |
|
|
| def leave_team(conn, _params) do |
| case Teams.Memberships.Leave.leave(conn.assigns.current_team, conn.assigns.current_user) do |
| {:ok, _} -> |
| conn |
| |> put_flash(:success, "You have left \"#{Teams.name(conn.assigns.current_team)}\"") |
| |> redirect(to: Routes.site_path(conn, :index, __team: "none")) |
|
|
| {:error, :only_one_owner} -> |
| conn |
| |> put_flash(:error, "You can't leave as you are the only Owner on the team") |
| |> redirect(to: Routes.settings_path(conn, :team_general)) |
|
|
| {:error, :membership_not_found} -> |
| redirect(conn, to: Routes.site_path(conn, :index, __team: "none")) |
| end |
| end |
|
|
| def preferences(conn, _params) do |
| render_preferences(conn) |
| end |
|
|
| def security(conn, _params) do |
| render_security(conn) |
| end |
|
|
| def redirect_invoices(conn, _params) do |
| redirect(conn, to: Routes.settings_path(conn, :subscription)) |
| 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 |
|
|
| |
|
|
| 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 |
|
|
| |
|
|
| 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 |
|
|