Account settings w sidebar (#4654)

* Outline /settings/v2 fundamentals

* Add setting tiles stubs

* Bootstrap name change

* Bootstrap theme change

* Bootstrap security settings

* Use table component for listing sessions

* Disable current e-mail field

* Implement Danger Zone

* Deal with compilation warnings

* Implement "Subscription" section

* Implement invoices list

* Fix invoices empty state & add API keys

* Fix headings in Subscription section

* Fix API keys mobile view

* Fix subscription boxes width

* Fix formatting

* Move tests for settings WIP

* Adjust remaining tests and router placement

Include docs links in tiles, where applicable.

* Fix remaining routes and remove dead code

* Fix route in a live view where no @conn is available

* Update mobile view settings picker

* Format

* Fix subscription section headings

* Fix account e-mail on dark mode

* Delete unused template

* Fix mobile setting section picker

* Optimize Login Management tile for mobile

* Update invoices section with docs link

* Update copy

* Remove trailing dots from (sub)titles

* Fix CSV export padding for "exporting" state

* Align subscription status to the right

* Fix failing test

* Fix subscription status alignment once again

* Improve subscription mobile view a little

* Fixup test compilation 🙈

* Add extra margin to subscription status box

* Make cancel button in 2FA modals expand in mobile view

* Stats API only

* Capitalize "Current session" indicator

* Show "Show More" invoices button only when there's >12

* tiny change

* Update changelog

---------

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
Co-authored-by: Marko Saric <34340819+metmarkosaric@users.noreply.github.com>
This commit is contained in:
hq1 2024-10-08 10:30:01 +02:00 committed by GitHub
parent 4224f9d187
commit 2359cb920c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 2188 additions and 2052 deletions

View File

@ -12,7 +12,10 @@ All notable changes to this project will be documented in this file.
### Changed
- Revised User Settings UI
### Fixed
- Revenue metrics are displayed correctly after goal has been renamed
## v2.1.3 - 2024-09-26

View File

@ -75,6 +75,18 @@ defmodule Plausible.Auth.User do
|> unique_constraint(:email)
end
def name_changeset(user, attrs \\ %{}) do
user
|> cast(attrs, [:name])
|> validate_required([:name])
end
def theme_changeset(user, attrs \\ %{}) do
user
|> cast(attrs, [:theme])
|> validate_required([:theme])
end
def settings_changeset(user, attrs \\ %{}) do
user
|> cast(attrs, [:email, :name, :theme])

View File

@ -119,7 +119,8 @@ defmodule Plausible.Auth.UserAdmin do
defp subscription_status(user) do
cond do
user.subscription ->
status_str = PlausibleWeb.AuthView.present_subscription_status(user.subscription.status)
status_str =
PlausibleWeb.SettingsView.present_subscription_status(user.subscription.status)
if user.subscription.paddle_subscription_id do
{:safe, ~s(<a href="#{manage_url(user.subscription)}">#{status_str}</a>)}

View File

@ -44,6 +44,7 @@ defmodule PlausibleWeb do
import PlausibleWeb.ErrorHelpers
import PlausibleWeb.FormHelpers
import PlausibleWeb.Components.Generic
import PlausibleWeb.Live.Components.Form
alias PlausibleWeb.Router.Helpers, as: Routes
end
end

View File

@ -17,8 +17,8 @@ defmodule PlausibleWeb.Components.Billing do
def render_monthly_pageview_usage(assigns) do
~H"""
<article id="monthly_pageview_usage_container" x-data="{ tab: 'last_cycle' }" class="mt-8">
<h1 class="text-xl mb-6 font-bold dark:text-gray-100">Monthly pageviews usage</h1>
<div class="mb-3">
<.title>Monthly pageviews usage</.title>
<div class="mt-4 mb-4">
<ol class="divide-y divide-gray-300 dark:divide-gray-600 rounded-md border dark:border-gray-600 md:flex md:flex-row-reverse md:divide-y-0 md:overflow-hidden">
<.billing_cycle_tab
name="Upcoming cycle"
@ -182,7 +182,7 @@ defmodule PlausibleWeb.Components.Billing do
~H"""
<div
id="monthly-quota-box"
class="w-1/3 h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900 w-max-md"
class="w-full md:w-1/3 h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900 w-max-md"
>
<h4 class="font-black dark:text-gray-100">Monthly quota</h4>
<div class="py-2 text-xl font-medium dark:text-gray-100">

View File

@ -520,8 +520,10 @@ defmodule PlausibleWeb.Components.Generic do
attr :rest, :global
attr :width, :string, default: "min-w-full"
attr :rows, :list, default: []
attr :row_attrs, :any, default: nil
slot :thead, required: false
slot :tbody, required: true
slot :inner_block, required: false
def table(assigns) do
~H"""
@ -532,9 +534,10 @@ defmodule PlausibleWeb.Components.Generic do
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr :for={item <- @rows}>
<tr :for={item <- @rows} {if @row_attrs, do: @row_attrs.(item), else: %{}}>
<%= render_slot(@tbody, item) %>
</tr>
<%= render_slot(@inner_block) %>
</tbody>
</table>
"""

View File

@ -46,9 +46,13 @@ defmodule PlausibleWeb.Components.TwoFactor do
autocomplete: "off",
class: @input_class,
oninput:
"this.value=this.value.replace(/[^0-9]/g, ''); if (this.value.length >= 6) document.getElementById('verify-button').focus()",
if @show_button? do
"this.value=this.value.replace(/[^0-9]/g, ''); if (this.value.length >= 6) document.getElementById('#{@id}').focus()"
else
"this.value=this.value.replace(/[^0-9]/g, '');"
end,
onclick: "this.select();",
oninvalid: "document.getElementById('verify-button').disabled = false",
oninvalid: @show_button? && "document.getElementById('#{@id}').disabled = false",
maxlength: "6",
placeholder: "••••••",
value: "",
@ -156,7 +160,7 @@ defmodule PlausibleWeb.Components.TwoFactor do
<.button
type="button"
x-on:click={"#{@state_param} = false"}
class="mr-2"
class="w-full sm:w-auto mr-2"
theme="bright"
>
Cancel

View File

@ -3,7 +3,6 @@ defmodule PlausibleWeb.AuthController do
use Plausible.Repo
alias Plausible.Auth
alias Plausible.Billing.Quota
alias PlausibleWeb.TwoFactor
alias PlausibleWeb.UserAuth
@ -26,15 +25,6 @@ defmodule PlausibleWeb.AuthController do
plug(
PlausibleWeb.RequireAccountPlug
when action in [
:user_settings,
:save_settings,
:update_email,
:update_password,
:cancel_update_email,
:new_api_key,
:create_api_key,
:delete_api_key,
:delete_session,
:delete_me,
:activate_form,
:activate,
@ -261,10 +251,6 @@ defmodule PlausibleWeb.AuthController do
end
end
def user_settings(conn, _params) do
render_settings(conn, [])
end
def initiate_2fa_setup(conn, _params) do
case Auth.TOTP.initiate(conn.assigns.current_user) do
{:ok, user, %{totp_uri: totp_uri, secret: secret}} ->
@ -273,7 +259,7 @@ defmodule PlausibleWeb.AuthController do
{:error, :already_setup} ->
conn
|> put_flash(:error, "Two-Factor Authentication is already setup for this account.")
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#setup-2fa")
|> redirect(to: Routes.settings_path(conn, :security) <> "#update-2fa")
end
end
@ -281,7 +267,7 @@ defmodule PlausibleWeb.AuthController do
if Auth.TOTP.initiated?(conn.assigns.current_user) do
render(conn, "verify_2fa_setup.html")
else
redirect(conn, to: Routes.auth_path(conn, :user_settings) <> "#setup-2fa")
redirect(conn, to: Routes.settings_path(conn, :security) <> "#update-2fa")
end
end
@ -300,7 +286,7 @@ defmodule PlausibleWeb.AuthController do
{:error, :not_initiated} ->
conn
|> put_flash(:error, "Please enable Two-Factor Authentication for this account first.")
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#setup-2fa")
|> redirect(to: Routes.settings_path(conn, :security) <> "#update-2fa")
end
end
@ -310,12 +296,12 @@ defmodule PlausibleWeb.AuthController do
conn
|> TwoFactor.Session.clear_remember_2fa()
|> put_flash(:success, "Two-Factor Authentication is disabled")
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#setup-2fa")
|> redirect(to: Routes.settings_path(conn, :security) <> "#update-2fa")
{:error, :invalid_password} ->
conn
|> put_flash(:error, "Incorrect password provided")
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#setup-2fa")
|> redirect(to: Routes.settings_path(conn, :security) <> "#update-2fa")
end
end
@ -329,12 +315,12 @@ defmodule PlausibleWeb.AuthController do
{:error, :invalid_password} ->
conn
|> put_flash(:error, "Incorrect password provided")
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#setup-2fa")
|> redirect(to: Routes.settings_path(conn, :security) <> "#update-2fa")
{:error, :not_enabled} ->
conn
|> put_flash(:error, "Please enable Two-Factor Authentication for this account first.")
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#setup-2fa")
|> redirect(to: Routes.settings_path(conn, :security) <> "#update-2fa")
end
end
@ -436,193 +422,10 @@ defmodule PlausibleWeb.AuthController do
end
end
def save_settings(conn, %{"user" => user_params}) do
user = conn.assigns.current_user
changes = Auth.User.settings_changeset(user, user_params)
case Repo.update(changes) do
{:ok, _user} ->
conn
|> put_flash(:success, "Account settings saved successfully")
|> redirect(to: Routes.auth_path(conn, :user_settings))
{:error, changeset} ->
render_settings(conn, settings_changeset: changeset)
end
end
def update_email(conn, %{"user" => user_params}) do
user = conn.assigns.current_user
with :ok <- Auth.rate_limit(:email_change_user, user),
changes = Auth.User.email_changeset(user, 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_settings(conn, email_changeset: changeset)
{:error, {:rate_limit, _}} ->
changeset =
user
|> Auth.User.email_changeset(user_params)
|> Ecto.Changeset.add_error(:email, "too many requests, try again in an hour")
|> Map.put(:action, :validate)
render_settings(conn, email_changeset: changeset)
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
UserAuth.revoke_all_user_sessions(user, except: user_session)
conn
|> put_flash(:success, "Your password is now changed")
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#change-password")
else
{:error, %Ecto.Changeset{} = changeset} ->
render_settings(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_settings(conn, password_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.auth_path(conn, :user_settings) <> "#change-email-address")
{: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
defp handle_email_updated(conn) do
conn
|> put_flash(:success, "Email updated successfully")
|> redirect(to: Routes.auth_path(conn, :user_settings) <> "#change-email-address")
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 render_settings(conn, opts) do
current_user = conn.assigns.current_user
api_keys = Repo.preload(current_user, :api_keys).api_keys
user_sessions = Auth.UserSessions.list_for_user(current_user)
settings_changeset =
Keyword.get(opts, :settings_changeset, Auth.User.settings_changeset(current_user))
email_changeset = Keyword.get(opts, :email_changeset, Auth.User.email_changeset(current_user))
password_changeset =
Keyword.get(opts, :password_changeset, Auth.User.password_changeset(current_user))
render(conn, "user_settings.html",
api_keys: api_keys,
user_sessions: user_sessions,
settings_changeset: settings_changeset,
email_changeset: email_changeset,
password_changeset: password_changeset,
subscription: current_user.subscription,
invoices: Plausible.Billing.paddle_api().get_invoices(current_user.subscription),
theme: current_user.theme || "system",
team_member_limit: Quota.Limits.team_member_limit(current_user),
team_member_usage: Quota.Usage.team_member_usage(current_user),
site_limit: Quota.Limits.site_limit(current_user),
site_usage: Quota.Usage.site_usage(current_user),
pageview_limit: Quota.Limits.monthly_pageview_limit(current_user),
pageview_usage: Quota.Usage.monthly_pageview_usage(current_user),
totp_enabled?: Auth.TOTP.enabled?(current_user)
)
end
def new_api_key(conn, _params) do
changeset = Auth.ApiKey.changeset(%Auth.ApiKey{})
render(conn, "new_api_key.html", changeset: changeset)
end
def create_api_key(conn, %{"api_key" => %{"name" => name, "key" => key}}) do
case Auth.create_api_key(conn.assigns.current_user, name, key) do
{:ok, _api_key} ->
conn
|> put_flash(:success, "API key created successfully")
|> redirect(to: "/settings#api-keys")
{:error, changeset} ->
render(conn, "new_api_key.html", changeset: changeset)
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: "/settings#api-keys")
{:error, :not_found} ->
conn
|> put_flash(:error, "Could not find API Key to delete")
|> redirect(to: "/settings#api-keys")
end
|> redirect(to: Routes.settings_path(conn, :security) <> "#update-email")
end
def delete_me(conn, params) do
@ -631,16 +434,6 @@ defmodule PlausibleWeb.AuthController do
logout(conn, params)
end
def delete_session(conn, %{"id" => session_id}) do
current_user = conn.assigns.current_user
:ok = UserAuth.revoke_user_session(current_user, session_id)
conn
|> put_flash(:success, "Session logged out successfully")
|> redirect(to: "/settings#user-sessions")
end
def logout(conn, params) do
redirect_to = Map.get(params, "redirect", "/")

View File

@ -44,7 +44,7 @@ defmodule PlausibleWeb.BillingController do
Subscription.Status.past_due(),
Subscription.Status.paused()
]) ->
redirect(conn, to: Routes.auth_path(conn, :user_settings))
redirect(conn, to: Routes.settings_path(conn, :subscription))
subscribed_to_latest? ->
render(conn, "change_enterprise_plan_contact_us.html", skip_plausible_tracking: true)
@ -101,7 +101,7 @@ defmodule PlausibleWeb.BillingController do
{:ok, _subscription} ->
conn
|> put_flash(:success, "Plan changed successfully")
|> redirect(to: "/settings")
|> redirect(to: Routes.settings_path(conn, :subscription))
{:error, e} ->
msg =
@ -131,7 +131,7 @@ defmodule PlausibleWeb.BillingController do
conn
|> put_flash(:error, msg)
|> redirect(to: "/settings")
|> redirect(to: Routes.settings_path(conn, :subscription))
end
end

View File

@ -0,0 +1,281 @@
defmodule PlausibleWeb.SettingsController do
use PlausibleWeb, :controller
use Plausible.Repo
alias Plausible.Auth
alias PlausibleWeb.UserAuth
alias Plausible.Billing.Quota
require Logger
def index(conn, _params) do
redirect(conn, to: Routes.settings_path(conn, :preferences))
end
def preferences(conn, _params) do
render_preferences(conn)
end
def security(conn, _params) do
render_security(conn)
end
def subscription(conn, _params) do
current_user = conn.assigns.current_user
render(conn, :subscription,
layout: {PlausibleWeb.LayoutView, :settings},
subscription: current_user.subscription,
pageview_limit: Quota.Limits.monthly_pageview_limit(current_user),
pageview_usage: Quota.Usage.monthly_pageview_usage(current_user),
site_usage: Quota.Usage.site_usage(current_user),
site_limit: Quota.Limits.site_limit(current_user),
team_member_limit: Quota.Limits.team_member_limit(current_user),
team_member_usage: Quota.Usage.team_member_usage(current_user)
)
end
def invoices(conn, _params) do
current_user = conn.assigns.current_user
invoices = Plausible.Billing.paddle_api().get_invoices(current_user.subscription)
render(conn, :invoices, layout: {PlausibleWeb.LayoutView, :settings}, invoices: invoices)
end
def api_keys(conn, _params) do
current_user = conn.assigns.current_user
api_keys =
Repo.preload(current_user, :api_keys).api_keys
render(conn, :api_keys, layout: {PlausibleWeb.LayoutView, :settings}, api_keys: api_keys)
end
def new_api_key(conn, _params) do
changeset = Auth.ApiKey.changeset(%Auth.ApiKey{})
render(conn, "new_api_key.html", changeset: changeset)
end
def create_api_key(conn, %{"api_key" => %{"name" => name, "key" => key}}) do
case Auth.create_api_key(conn.assigns.current_user, 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, changeset} ->
render(conn, "new_api_key.html", changeset: changeset)
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
render(conn, :danger_zone, layout: {PlausibleWeb.LayoutView, :settings})
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
UserAuth.revoke_all_user_sessions(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 = UserAuth.revoke_user_session(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

View File

@ -50,6 +50,7 @@ defmodule PlausibleWeb.Live.Components.Form do
attr(:class, :any, default: @default_input_class)
attr(:mt?, :boolean, default: true)
attr(:max_one_error, :boolean, default: false)
slot(:inner_block)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
@ -82,6 +83,15 @@ defmodule PlausibleWeb.Live.Components.Form do
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
errors =
if assigns.max_one_error do
Enum.take(assigns.errors, 1)
else
assigns.errors
end
assigns = assign(assigns, :errors, errors)
~H"""
<div phx-feedback-for={@name} class={@mt? && "mt-2"}>
<.label :if={@label != nil and @label != ""} for={@id} class="mb-2">

View File

@ -132,13 +132,13 @@ defmodule PlausibleWeb.Live.CSVExport do
</div>
<button
phx-click="cancel"
class="text-red-500 font-semibold"
class="text-red-500 font-semibold text-sm"
data-confirm="Are you sure you want to cancel this export?"
>
Cancel
</button>
</div>
<p class="text-sm">
<p class="text-sm mt-4">
The preparation of your stats might take a while. Depending on the volume of your data, it might take up to 20 minutes. Feel free to leave the page and return later.
</p>
"""

View File

@ -143,7 +143,7 @@ defmodule PlausibleWeb.Live.Sites do
<p>
To access the sites you own, you need to subscribe to a monthly or yearly payment plan. <%= link(
"Upgrade now →",
to: "/settings",
to: Routes.settings_path(PlausibleWeb.Endpoint, :subscription),
class: "text-sm font-medium text-yellow-800"
) %>
</p>
@ -450,7 +450,7 @@ defmodule PlausibleWeb.Live.Sites do
You can review your usage in the
<.styled_link
class="inline-block"
href={Routes.auth_path(PlausibleWeb.Endpoint, :user_settings)}
href={Routes.settings_path(PlausibleWeb.Endpoint, :subscription)}
>
account settings
</.styled_link>.

View File

@ -2,7 +2,7 @@ defmodule PlausibleWeb.RequireAccountPlug do
import Plug.Conn
@unverified_email_exceptions [
["settings", "email", "cancel"],
["settings", "security", "email", "cancel"],
["activate"],
["activate", "request-code"],
["me"]

View File

@ -322,20 +322,38 @@ defmodule PlausibleWeb.Router do
post "/share/:slug/authenticate", StatsController, :authenticate_shared_link
end
scope "/settings", PlausibleWeb do
pipe_through [:browser, :csrf, PlausibleWeb.RequireAccountPlug]
get "/", SettingsController, :index
get "/preferences", SettingsController, :preferences
post "/preferences/name", SettingsController, :update_name
post "/preferences/theme", SettingsController, :update_theme
get "/security", SettingsController, :security
delete "/security/user-sessions/:id", SettingsController, :delete_session
post "/security/email/cancel", SettingsController, :cancel_update_email
post "/security/email", SettingsController, :update_email
post "/security/password", SettingsController, :update_password
get "/billing/subscription", SettingsController, :subscription
get "/billing/invoices", SettingsController, :invoices
get "/api-keys", SettingsController, :api_keys
get "/api-keys/new", SettingsController, :new_api_key
post "/api-keys", SettingsController, :create_api_key
delete "/api-keys/:id", SettingsController, :delete_api_key
get "/danger-zone", SettingsController, :danger_zone
end
scope "/", PlausibleWeb do
pipe_through [:browser, :csrf]
get "/logout", AuthController, :logout
get "/settings", AuthController, :user_settings
put "/settings", AuthController, :save_settings
put "/settings/email", AuthController, :update_email
put "/settings/password", AuthController, :update_password
post "/settings/email/cancel", AuthController, :cancel_update_email
delete "/me", AuthController, :delete_me
get "/settings/api-keys/new", AuthController, :new_api_key
post "/settings/api-keys", AuthController, :create_api_key
delete "/settings/api-keys/:id", AuthController, :delete_api_key
delete "/settings/user-sessions/:id", AuthController, :delete_session
get "/auth/google/callback", AuthController, :google_auth_callback

View File

@ -88,7 +88,7 @@
<.focus_list>
<:item :if={@has_any_memberships?}>
<.styled_link method="post" href="/settings/email/cancel">
<.styled_link method="post" href={Routes.settings_path(@conn, :cancel_update_email)}>
Change email back to
</.styled_link>
<%= @conn.assigns[:current_user].previous_email %>

View File

@ -40,7 +40,7 @@
<button
id="finish"
class="button w-full mt-4"
onclick={"location.replace('#{Routes.auth_path(@conn, :user_settings) <> "#setup-2fa"}')"}
onclick={"location.replace('#{Routes.settings_path(@conn, :security) <> "#update-2fa"}')"}
>
Finish
</button>

View File

@ -11,7 +11,7 @@
<.focus_list>
<:item>
Changed your mind?
<.styled_link href={Routes.auth_path(@conn, :user_settings) <> "#setup-2fa"}>
<.styled_link href={Routes.settings_path(@conn, :security) <> "#update-2fa"}>
Go back to Settings
</.styled_link>
</:item>

View File

@ -1,24 +0,0 @@
<PlausibleWeb.Components.Generic.focus_box>
<%= form_for @changeset, "/settings/api-keys", [class: "w-full max-w-md mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 py-6 mt-8"], fn f -> %>
<h1 class="text-xl font-black dark:text-gray-100">Create new API key</h1>
<div class="my-4">
<%= label f, :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-1">
<%= text_input f, :name, class: "dark:bg-gray-900 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 dark:text-gray-300 dark:focus:bg-gray-800 focus:border-gray-300 dark:focus:border-gray-500 rounded-md", placeholder: "Development" %>
</div>
<%= error_tag f, :name %>
</div>
<div class="my-4">
<%= label f, :key, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="relative mt-1">
<%= text_input f, :key, id: "key-input", class: "dark:text-gray-300 shadow-sm bg-gray-50 dark:bg-gray-850 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md pr-16", readonly: "readonly" %>
<a onclick="var textarea = document.getElementById('key-input'); textarea.focus(); textarea.select(); document.execCommand('copy');" href="javascript:void(0)" class="absolute flex items-center text-xs font-medium text-indigo-600 no-underline hover:underline" style="top: 12px; right: 12px;">
<svg class="pr-1 text-indigo-600 dark:text-indigo-500" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>COPY
</a>
<%= error_tag f, :key %>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-200">Make sure to store the key in a secure place. Once created, we will not be able to show it again.</p>
</div>
</div>
<%= submit "Continue", class: "button mt-4 w-full" %>
<% end %>
</PlausibleWeb.Components.Generic.focus_box>

View File

@ -1,650 +0,0 @@
<div x-data="{disable2FAOpen: false, regenerate2FAOpen: false}">
<%= if ee?() do %>
<div class="max-w-2xl px-8 pt-4 pb-8 mx-auto mt-24 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-orange-200 ">
<div class="flex flex-wrap justify-between">
<h2 class="text-xl font-black dark:text-gray-100 w-max mr-4 mt-2">Subscription Plan</h2>
<div class="gap-x-2 mt-2 inline-flex">
<span
:if={@subscription && Plausible.Billing.Plans.business_tier?(@subscription)}
class={[
"w-max px-2.5 py-0.5 rounded-md text-sm font-bold leading-5 text-indigo-600 bg-blue-100 dark:text-yellow-200 dark:border dark:bg-inherit dark:border-yellow-200"
]}
>
Business
</span>
<span
:if={@subscription}
class={[
"w-max px-2.5 py-0.5 rounded-md text-sm font-bold leading-5",
subscription_colors(@subscription.status)
]}
>
<%= present_subscription_status(@subscription.status) %>
</span>
</div>
</div>
<div class="my-4 border-b border-gray-400"></div>
<PlausibleWeb.Components.Billing.Notice.subscription_cancelled
user={@current_user}
dismissable={false}
/>
<div class="flex flex-col items-center justify-between mt-8 sm:flex-row sm:items-start">
<PlausibleWeb.Components.Billing.monthly_quota_box
user={@current_user}
subscription={@subscription}
/>
<div
class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900"
style="width: 11.75rem;"
>
<h4 class="font-black dark:text-gray-100">Next bill amount</h4>
<%= if Plausible.Billing.Subscription.Status.in?(@subscription, [Plausible.Billing.Subscription.Status.active(), Plausible.Billing.Subscription.Status.past_due()]) do %>
<div class="py-2 text-xl font-medium dark:text-gray-100">
<%= PlausibleWeb.BillingView.present_currency(@subscription.currency_code) %><%= @subscription.next_bill_amount %>
</div>
<.styled_link :if={@subscription.update_url} href={@subscription.update_url}>
Update billing info
</.styled_link>
<% else %>
<div class="py-2 text-xl font-medium dark:text-gray-100">---</div>
<% end %>
</div>
<div
class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900"
style="width: 11.75rem;"
>
<h4 class="font-black dark:text-gray-100">Next bill date</h4>
<%= if @subscription && @subscription.next_bill_date && Plausible.Billing.Subscription.Status.in?(@subscription, [Plausible.Billing.Subscription.Status.active(), Plausible.Billing.Subscription.Status.past_due()]) do %>
<div class="py-2 text-xl font-medium dark:text-gray-100">
<%= Calendar.strftime(@subscription.next_bill_date, "%b %-d, %Y") %>
</div>
<span class="text-gray-600 dark:text-gray-400">
(<%= subscription_interval(@subscription) %> billing)
</span>
<% else %>
<div class="py-2 text-xl font-medium dark:text-gray-100">---</div>
<% end %>
</div>
</div>
<PlausibleWeb.Components.Billing.render_monthly_pageview_usage
usage={@pageview_usage}
limit={@pageview_limit}
/>
<article class="mt-8">
<h1 class="text-xl mb-3 font-bold dark:text-gray-100">Sites & team members usage</h1>
<PlausibleWeb.Components.Billing.usage_and_limits_table>
<PlausibleWeb.Components.Billing.usage_and_limits_row
id="site-usage-row"
title="Owned sites"
usage={@site_usage}
limit={@site_limit}
/>
<PlausibleWeb.Components.Billing.usage_and_limits_row
id="team-member-usage-row"
title="Team members"
usage={@team_member_usage}
limit={@team_member_limit}
/>
</PlausibleWeb.Components.Billing.usage_and_limits_table>
</article>
<%= cond do %>
<% Plausible.Billing.Subscriptions.resumable?(@subscription) && @subscription.cancel_url -> %>
<div class="mt-8">
<%= link("Cancel my subscription",
to: @subscription.cancel_url,
class:
"inline-block mt-4 px-4 py-2 border border-gray-300 dark:border-gray-500 text-sm leading-5 font-medium rounded-md text-red-700 bg-white dark:bg-gray-800 hover:text-red-500 focus:outline-none focus:border-blue-300 focus:ring active:text-red-800 active:bg-gray-50 transition ease-in-out duration-150"
) %>
</div>
<% true -> %>
<div class="mt-8">
<PlausibleWeb.Components.Billing.upgrade_link />
</div>
<% end %>
</div>
<%= case @invoices do %>
<% {:error, :no_invoices} -> %>
<% {:error, :request_failed} -> %>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800">
<h2 class="text-xl font-black dark:text-gray-100">Invoices</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<p class="text-center text-black dark:text-gray-100 m-2">
Something went wrong
</p>
</div>
<% {:ok, invoice_list} when is_list(invoice_list) -> %>
<div
class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800"
x-data="{showAll: false}"
>
<h2 class="text-xl font-black dark:text-gray-100">Invoices</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th
scope="col"
class="py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
Date
</th>
<th
scope="col"
class="py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
Amount
</th>
<th
scope="col"
class="py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
Invoice
</th>
</tr>
</thead>
<%= for {invoice, idx} <- Enum.with_index(format_invoices(invoice_list)) do %>
<tbody class={["divide-y divide-gray-200"]}>
<tr x-show={"showAll || #{idx} < 12"}>
<td class="py-4 text-sm text-gray-800 dark:text-gray-200 font-medium">
<%= invoice.date %>
</td>
<td class="py-4 text-sm text-gray-800 dark:text-gray-200">
<%= invoice.currency <> invoice.amount %>
</td>
<td class="py-4 text-sm text-indigo-500">
<%= link("Link", to: invoice.url, target: "_blank") %>
</td>
</tr>
<tr :if={idx == 12 && length(invoice_list) > 12} x-show="!showAll">
<td colspan="3" class="text-center">
<button
x-on:click="showAll = true"
x-show="!showAll"
class="mt-4 text-indigo-500 hover:text-indigo-600"
>
Show More
</button>
</td>
</tr>
</tbody>
<% end %>
</table>
</div>
<% end %>
<% end %>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-green-500 rounded rounded-t-none shadow-md dark:bg-gray-800">
<h2 class="text-xl font-black dark:text-gray-100">Dashboard Appearance</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<%= form_for @settings_changeset, "/settings", [class: "max-w-sm"], fn f -> %>
<div class="col-span-4 sm:col-span-2">
<%= label(f, :theme, "Theme Selection",
class: "block text-sm font-medium leading-5 text-gray-700 dark:text-gray-300"
) %>
<%= select(f, :theme, Plausible.Themes.options(),
class:
"dark:bg-gray-900 mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md dark:text-gray-100 cursor-pointer"
) %>
</div>
<PlausibleWeb.Components.Generic.button type="submit" class="mt-4">
Save
</PlausibleWeb.Components.Generic.button>
<% end %>
</div>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-red-600 rounded rounded-t-none shadow-md dark:bg-gray-800">
<h2 id="change-password" class="text-xl font-black dark:text-gray-100">
Change password
</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<%= form_for @password_changeset, "/settings/password#change-password", [class: "max-w-sm"], fn f -> %>
<div class="my-4">
<%= label(f, :old_password, "Current password",
class: "block text-sm font-medium text-gray-700 dark:text-gray-300"
) %>
<div class="mt-1">
<%= password_input(f, :old_password,
class:
"shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
) %>
<%= error_tag(f, :old_password, only_first?: true) %>
</div>
</div>
<div class="my-4">
<%= label(f, :password, "New password",
class: "block text-sm font-medium text-gray-700 dark:text-gray-300"
) %>
<div class="mt-1">
<%= password_input(f, :password,
autocomplete: "new-password",
class:
"shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
) %>
<%= error_tag(f, :password, only_first?: true) %>
</div>
</div>
<div class="my-4">
<%= label(f, :password_confirmation, "Confirm new password",
class: "block text-sm font-medium text-gray-700 dark:text-gray-300"
) %>
<div class="mt-1">
<%= password_input(f, :password_confirmation,
autocomplete: "new-password",
class:
"shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
) %>
<%= error_tag(f, :password_confirmation, only_first?: true) %>
</div>
</div>
<div :if={Plausible.Auth.TOTP.enabled?(@current_user)} class="my-4">
<%= label(f, :two_factor_code, "Verify with 2FA",
class: "block text-sm font-medium text-gray-700 dark:text-gray-300"
) %>
<div class="mt-1">
<PlausibleWeb.Components.TwoFactor.verify_2fa_input
form={f}
show_button?={false}
field={:two_factor_code}
/>
</div>
</div>
<%= submit("Change my password",
class:
"inline-block mt-4 px-4 py-2 border border-gray-300 dark:border-gray-500 text-sm leading-5 font-medium rounded-md text-red-600 dark:text-red-500 bg-white dark:bg-gray-800 hover:text-red-400 focus:outline-none focus:border-blue-300 focus:ring active:text-red-800 active:bg-gray-50 transition ease-in-out duration-150"
) %>
<% end %>
</div>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-green-500 rounded rounded-t-none shadow-md dark:bg-gray-800">
<h2 id="setup-2fa" class="text-xl font-black dark:text-gray-100">
Two-Factor Authentication (2FA)
</h2>
<p class="text-sm mt-2 text-gray-600 dark:text-gray-400">
Two-Factor Authentication protects your account by adding an extra security step when you log in.
</p>
<%= if @totp_enabled? do %>
<button
x-on:click="disable2FAOpen = true; $refs.disable2FAPassword.value = ''"
type="button"
class="inline-block mt-4 px-4 py-2 border border-gray-300 dark:border-gray-500 text-sm leading-5 font-medium rounded-md text-red-700 bg-white dark:bg-gray-800 hover:text-red-500 dark:hover:text-red-400 focus:outline-none focus:border-blue-300 focus:ring active:text-red-800 active:bg-gray-50 transition ease-in-out duration-150"
>
Disable 2FA
</button>
<p class="mt-2 text-gray-600 text-sm dark:text-gray-400">
Lost your recovery codes?
<a
href="#setup-2fa"
x-on:click="regenerate2FAOpen = true; $refs.regenerate2FAPassword.value = ''"
class="underline text-indigo-600"
>
Generate new
</a>
</p>
<% else %>
<%= form_for @conn.params, Routes.auth_path(@conn, :initiate_2fa_setup), fn _ -> %>
<PlausibleWeb.Components.Generic.button type="submit" class="mt-4">
Enable 2FA
</PlausibleWeb.Components.Generic.button>
<% end %>
<% end %>
</div>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-indigo-100 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-indigo-500">
<h2 id="change-account-name" class="text-xl font-black dark:text-gray-100">
Change account name
</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<%= form_for @settings_changeset, "/settings#change-account-name", [class: "max-w-sm"], fn f -> %>
<div class="my-4">
<%= label(f, :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300") %>
<div class="mt-1">
<%= text_input(f, :name,
class:
"shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
) %>
<%= error_tag(f, :name) %>
</div>
</div>
<PlausibleWeb.Components.Generic.button type="submit" class="mt-4">
Save
</PlausibleWeb.Components.Generic.button>
<% end %>
</div>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-red-600 rounded rounded-t-none shadow-md dark:bg-gray-800">
<h2 id="change-email-address" class="text-xl font-black dark:text-gray-100">
Change email address
</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<%= form_for @email_changeset, "/settings/email#change-email-address", [class: "max-w-sm"], fn f -> %>
<div class="my-4">
<%= label(f, :password, "Account password",
class: "block text-sm font-medium text-gray-700 dark:text-gray-300"
) %>
<div class="mt-1">
<%= password_input(f, :password,
class:
"shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
) %>
<%= error_tag(f, :password) %>
</div>
</div>
<div class="my-4">
<%= label(f, :current_email, "Current email",
class: "block text-sm font-medium text-gray-700 dark:text-gray-300"
) %>
<div class="mt-1">
<%= email_input(f, :current_email,
readonly: true,
value: f.data.email,
class:
"shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800 bg-gray-100"
) %>
</div>
</div>
<div class="my-4">
<%= label(f, :email, "New email",
class: "block text-sm font-medium text-gray-700 dark:text-gray-300"
) %>
<div class="mt-1">
<%= email_input(f, :email,
value: f.params["email"],
class:
"shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md"
) %>
<%= error_tag(f, :email) %>
</div>
</div>
<%= submit("Change my email",
class:
"inline-block mt-4 px-4 py-2 border border-gray-300 dark:border-gray-500 text-sm leading-5 font-medium rounded-md text-red-600 dark:text-red-500 bg-white dark:bg-gray-800 hover:text-red-400 focus:outline-none focus:border-blue-300 focus:ring active:text-red-800 active:bg-gray-50 transition ease-in-out duration-150"
) %>
<% end %>
</div>
<div
id="user-sessions"
class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-indigo-100 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-indigo-500"
>
<h2 class="text-xl font-black dark:text-gray-100">Login Management</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<p class="text-sm mt-2 text-gray-600 dark:text-gray-400">
Log out of your account on other devices. Note that logged-in sessions automatically expire after 14 days of inactivity.
</p>
<div class="flex flex-col mt-6">
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<%= if Enum.any?(@user_sessions) do %>
<div class="overflow-hidden border-b border-gray-200 shadow dark:border-gray-900 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-900">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th
scope="col"
class="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-gray-100"
>
Device
</th>
<th
scope="col"
class="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-gray-100"
>
Last seen
</th>
<th scope="col" class="relative px-6 py-3">
<span class="sr-only">Log Out</span>
</th>
</tr>
</thead>
<tbody>
<%= for session <- @user_sessions do %>
<tr class="bg-white dark:bg-gray-800">
<td class="px-6 py-4 text-sm font-medium text-gray-900 dark:text-gray-100 whitespace-nowrap">
<%= session.device %>
</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-100 whitespace-nowrap">
<%= Plausible.Auth.UserSessions.last_used_humanize(session) %>
</td>
<td class="px-6 py-4 text-sm font-medium text-right whitespace-nowrap">
<%= if @current_user_session.id == session.id do %>
<span class="text-gray-500 dark:text-gray-100">
Current Session
</span>
<% else %>
<%= button("Log Out",
to: "/settings/user-sessions/#{session.id}",
class: "text-red-600 hover:text-red-400 dark:text-red-500",
method: :delete,
"data-confirm": "Are you sure you want to log out this session?"
) %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
</div>
</div>
</div>
<div
id="api-keys"
class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-indigo-100 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-indigo-500"
>
<h2 class="text-xl font-black dark:text-gray-100">API Keys</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<PlausibleWeb.Components.Billing.Notice.premium_feature
billable_user={@current_user}
current_user={@current_user}
feature_mod={Plausible.Billing.Feature.StatsAPI}
/>
<div class="flex flex-col mt-6">
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<%= if Enum.any?(@api_keys) do %>
<div class="overflow-hidden border-b border-gray-200 shadow dark:border-gray-900 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-900">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th
scope="col"
class="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-gray-100"
>
Name
</th>
<th
scope="col"
class="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-gray-100"
>
Key
</th>
<th scope="col" class="relative px-6 py-3">
<span class="sr-only">Revoke</span>
</th>
</tr>
</thead>
<tbody>
<%= for api_key <- @api_keys do %>
<tr class="bg-white dark:bg-gray-800">
<td class="px-6 py-4 text-sm font-medium text-gray-900 dark:text-gray-100 whitespace-nowrap">
<%= api_key.name %>
</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-100 whitespace-nowrap">
<%= api_key.key_prefix %><%= String.duplicate("*", 32 - 6) %>
</td>
<td class="px-6 py-4 text-sm font-medium text-right whitespace-nowrap">
<%= button("Revoke",
to: "/settings/api-keys/#{api_key.id}",
class: "text-red-600 hover:text-red-400 dark:text-red-500",
method: :delete,
"data-confirm":
"Are you sure you want to revoke this key? This action cannot be reversed."
) %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<PlausibleWeb.Components.Generic.button_link
:if={Plausible.Billing.Feature.StatsAPI.check_availability(@current_user) == :ok}
href={Routes.auth_path(@conn, :new_api_key)}
class="mt-4"
>
+ New API Key
</PlausibleWeb.Components.Generic.button_link>
</div>
</div>
</div>
</div>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 mb-24 bg-white border-t-2 border-red-600 rounded rounded-t-none shadow-md dark:bg-gray-800">
<div class="flex items-center justify-between">
<h2 class="text-xl font-black dark:text-gray-100">Delete account</h2>
</div>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<p class="dark:text-gray-100">
Deleting your account removes all sites and stats you've collected
</p>
<%= if Plausible.Billing.Subscription.Status.active?(@subscription) do %>
<span class="mt-6 bg-gray-300 button dark:bg-gray-600 hover:shadow-none hover:bg-gray-300 cursor-not-allowed">
Delete my account
</span>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Your account cannot be deleted because you have an active subscription. If you want to delete your account, please cancel your subscription first.
</p>
<% else %>
<%= link("Delete my account",
to: "/me",
class:
"inline-block mt-4 px-4 py-2 border border-gray-300 dark:border-gray-500 text-sm leading-5 font-medium rounded-md text-red-700 dark:text-red-500 bg-white dark:bg-gray-800 hover:text-red-500 dark:hover:text-red-400 focus:outline-none focus:border-blue-300 focus:ring active:text-red-800 active:bg-gray-50 transition ease-in-out duration-150",
method: "delete",
data: [
confirm:
"Deleting your account will also delete all the sites and data that you own. This action cannot be reversed. Are you sure?"
]
) %>
<% end %>
</div>
<PlausibleWeb.Components.TwoFactor.modal
:let={f}
id="disable-2fa-modal"
state_param="disable2FAOpen"
form_data={@conn.params}
form_target={Routes.auth_path(@conn, :disable_2fa)}
title="Disable Two-Factor Authentication?"
>
<:icon>
<Heroicons.shield_exclamation class="h-6 w-6" />
</:icon>
<:buttons>
<.button type="submit" class="w-full sm:w-auto">
Disable 2FA
</.button>
</:buttons>
<div class="text-sm mt-2">
Once disabled, verification codes from the authenticator application and current recovery codes will become invalid. 2FA will have to be setup from the start.
</div>
<div class="text-sm mt-2">
Enter your password to disable 2FA.
</div>
<div class="mt-3">
<%= password_input(f, :password,
id: "disable_2fa_password",
placeholder: "Enter password",
"x-ref": "disable2FAPassword",
class:
"shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
) %>
</div>
</PlausibleWeb.Components.TwoFactor.modal>
<PlausibleWeb.Components.TwoFactor.modal
:let={f}
id="regenerate-2fa-modal"
state_param="regenerate2FAOpen"
form_data={@conn.params}
form_target={Routes.auth_path(@conn, :generate_2fa_recovery_codes)}
onsubmit="document.getElementById('generate-2fa-recovery-button').disabled = true"
title="Generate New Recovery Codes?"
>
<:icon>
<Heroicons.key class="h-6 w-6" />
</:icon>
<:buttons>
<.button
id="generate-2fa-recovery-button"
type="submit"
class="w-full sm:w-auto [&>span.label-enabled]:block [&>span.label-disabled]:hidden [&[disabled]>span.label-enabled]:hidden [&[disabled]>span.label-disabled]:block"
>
<span class="label-enabled pointer-events-none">
Generate New Codes
</span>
<span class="label-disabled">
<.spinner class="inline-block h-5 w-5 mr-2 text-white dark:text-gray-400" />
Generating Codes
</span>
</.button>
</:buttons>
<div class="text-sm mt-2">
If you generate new codes, the old ones will become invalid.
</div>
<div class="text-sm mt-2">
Enter your password to continue.
</div>
<div class="mt-3 w-full">
<%= password_input(f, :password,
id: "regenerate_2fa_password",
placeholder: "Enter password",
"x-ref": "regenerate2FAPassword",
class:
"w-full shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
) %>
</div>
</PlausibleWeb.Components.TwoFactor.modal>
</div>

View File

@ -11,7 +11,7 @@
<.focus_list>
<:item>
Changed your mind?
<.styled_link href={Routes.auth_path(@conn, :user_settings) <> "#setup-2fa"}>
<.styled_link href={Routes.settings_path(@conn, :security) <> "#update-2fa"}>
Go back to Settings
</.styled_link>
</:item>

View File

@ -20,7 +20,7 @@
<script>
const PING_SUBSCRIPTION_API = "<%= Routes.billing_path(@conn, :ping_subscription) %>"
const REDIRECT_TO = "<%= Routes.auth_path(@conn, :user_settings) %>"
const REDIRECT_TO = "<%= Routes.settings_path(@conn, :subscription) %>"
const PING_EVERY_MS = 2000
const TIMEOUT_AFTER_MS = 15000

View File

@ -2,7 +2,7 @@ Last week we sent a reminder that your site traffic has exceeded the limits of y
<br /><br />
Your subscription is still active, we're still counting your stats and haven't deleted any of your data but as you have outgrown your subscription tier, we kindly ask you to upgrade to match your new traffic levels. Upon upgrading to a suitable tier, your dashboard access will be immediately restored.
<br /><br />
During the last billing cycle (<%= PlausibleWeb.TextHelpers.format_date_range(@usage.last_cycle.date_range) %>), your account recorded <%= PlausibleWeb.AuthView.delimit_integer(@usage.last_cycle.total) %> billable pageviews. In the billing cycle before that (<%= PlausibleWeb.TextHelpers.format_date_range(@usage.penultimate_cycle.date_range) %>), the usage was <%= PlausibleWeb.AuthView.delimit_integer(@usage.penultimate_cycle.total) %> billable pageviews. Note that billable pageviews include both standard pageviews and custom events. In your <%= link("account settings", to: plausible_url() <> PlausibleWeb.Router.Helpers.auth_path(PlausibleWeb.Endpoint, :user_settings)) %>, you'll find an overview of your usage and limits.
During the last billing cycle (<%= PlausibleWeb.TextHelpers.format_date_range(@usage.last_cycle.date_range) %>), your account recorded <%= PlausibleWeb.AuthView.delimit_integer(@usage.last_cycle.total) %> billable pageviews. In the billing cycle before that (<%= PlausibleWeb.TextHelpers.format_date_range(@usage.penultimate_cycle.date_range) %>), the usage was <%= PlausibleWeb.AuthView.delimit_integer(@usage.penultimate_cycle.total) %> billable pageviews. Note that billable pageviews include both standard pageviews and custom events. In your <%= link("account settings", to: plausible_url() <> PlausibleWeb.Router.Helpers.settings_path(PlausibleWeb.Endpoint, :subscription)) %>, you'll find an overview of your usage and limits.
<br /><br />
<%= if @suggested_plan == :enterprise do %>
Your usage exceeds our standard plans, so please reply back to this email for a tailored quote.

View File

@ -4,7 +4,7 @@ This is a friendly reminder that your traffic has exceeded your subscription tie
<br /><br />
To maintain uninterrupted access to your stats, we kindly ask you to upgrade your account to match your new traffic levels. Please note, if your account isn't upgraded within the next 7 days, access to your stats will be temporarily locked.
<br /><br />
During the last billing cycle (<%= PlausibleWeb.TextHelpers.format_date_range(@usage.last_cycle.date_range) %>), your account recorded <%= PlausibleWeb.AuthView.delimit_integer(@usage.last_cycle.total) %> billable pageviews. In the billing cycle before that (<%= PlausibleWeb.TextHelpers.format_date_range(@usage.penultimate_cycle.date_range) %>), your account used <%= PlausibleWeb.AuthView.delimit_integer(@usage.penultimate_cycle.total) %> billable pageviews. Note that billable pageviews include both standard pageviews and custom events. In your <%= link("account settings", to: plausible_url() <> PlausibleWeb.Router.Helpers.auth_path(PlausibleWeb.Endpoint, :user_settings)) %>, you'll find an overview of your usage and limits.
During the last billing cycle (<%= PlausibleWeb.TextHelpers.format_date_range(@usage.last_cycle.date_range) %>), your account recorded <%= PlausibleWeb.AuthView.delimit_integer(@usage.last_cycle.total) %> billable pageviews. In the billing cycle before that (<%= PlausibleWeb.TextHelpers.format_date_range(@usage.penultimate_cycle.date_range) %>), your account used <%= PlausibleWeb.AuthView.delimit_integer(@usage.penultimate_cycle.total) %> billable pageviews. Note that billable pageviews include both standard pageviews and custom events. In your <%= link("account settings", to: plausible_url() <> PlausibleWeb.Router.Helpers.settings_path(PlausibleWeb.Endpoint, :subscription)) %>, you'll find an overview of your usage and limits.
<br /><br />
<%= if @suggested_plan == :enterprise do %>
Your usage exceeds our standard plans, so please reply back to this email for a tailored quote.

View File

@ -44,7 +44,7 @@
class="hidden mr-6 sm:block"
>
<%= link(trial_notificaton(@conn.assigns[:current_user]),
to: "/settings",
to: Routes.settings_path(@conn, :subscription),
class:
"text-sm text-yellow-900 dark:text-yellow-900 rounded px-3 py-2 rounded-md bg-yellow-100 dark:bg-yellow-100"
) %>
@ -69,7 +69,9 @@
</p>
</div>
<div class="py-1.5" role="none">
<.dropdown_link href="/settings">Account Settings</.dropdown_link>
<.dropdown_link href={Routes.settings_path(@conn, :index)}>
Account Settings
</.dropdown_link>
<.dropdown_link new_tab href="https://plausible.io/docs">
Help Center
</.dropdown_link>

View File

@ -1,5 +1,5 @@
<a
href={@this_tab && "/" <> URI.encode_www_form(@site.domain) <> "/settings/" <> @this_tab}
href={@this_tab && "/settings/" <> @this_tab}
class={[
"text-sm flex items-center px-2 py-2 leading-5 font-medium rounded-md outline-none focus:outline-none transition ease-in-out duration-150",
is_current_tab(@conn, @this_tab) &&

View File

@ -0,0 +1,22 @@
<a
href={@this_tab && "/" <> URI.encode_www_form(@site.domain) <> "/settings/" <> @this_tab}
class={[
"text-sm flex items-center px-2 py-2 leading-5 font-medium rounded-md outline-none focus:outline-none transition ease-in-out duration-150",
is_current_tab(@conn, @this_tab) &&
"text-gray-900 dark:text-gray-100 bg-gray-100 font-semibold dark:bg-gray-900 hover:text-gray-900 focus:bg-gray-200 dark:focus:bg-gray-800",
@this_tab && not is_current_tab(@conn, @this_tab) &&
"text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 focus:text-gray-900 focus:bg-gray-50 dark:focus:text-gray-100 dark:focus:bg-gray-800",
!@this_tab && "text-gray-600 dark:text-gray-400"
]}
>
<PlausibleWeb.Components.Generic.dynamic_icon
:if={not @submenu? && @icon}
name={@icon}
class={["h-4 w-4 mr-2", is_current_tab(@conn, @this_tab) && "stroke-2"]}
/>
<%= @text %>
<Heroicons.chevron_down
:if={is_nil(@this_tab)}
class="h-3 w-3 ml-2 text-gray-400 dark:text-gray-500"
/>
</a>

View File

@ -0,0 +1,57 @@
<%= render_layout "app.html", assigns do %>
<% options = [
%{key: "Preferences", value: "preferences", icon: :cog_6_tooth},
%{key: "Security", value: "security", icon: :lock_closed},
%{key: "Subscription", value: "billing/subscription", icon: :circle_stack},
%{key: "Invoices", value: "billing/invoices", icon: :banknotes},
%{key: "API Keys", value: "api-keys", icon: :key},
%{key: "Danger Zone", value: "danger-zone", icon: :exclamation_triangle}
] %>
<div class="container pt-6">
<%= link("← Back to Sites",
to: "/sites",
class: "text-indigo-600 font-bold text-sm"
) %>
<div class="pb-5 border-b border-gray-200 dark:border-gray-500">
<h2 class="text-2xl font-bold leading-7 text-gray-900 dark:text-gray-100 sm:text-3xl sm:leading-9 sm:truncate">
Settings
</h2>
</div>
<div class="lg:grid lg:grid-cols-12 lg:gap-x-5 lg:mt-4">
<div class="py-4 g:py-0 lg:col-span-3">
<div class="mb-4">
<h3 class="uppercase text-sm text-indigo-600 font-semibold">Account Settings</h3>
<p class="text-xs dark:text-gray-400"><%= @current_user.email %></p>
</div>
<.form for={@conn} class="lg:hidden">
<.input
value={Enum.find_value(options, &(is_current_tab(@conn, &1.value) && &1.value))}
name="setting"
type="select"
options={Enum.map(options, fn opt -> {opt.key, opt.value} end)}
onchange="location.href = '/settings/' + event.target.value"
class="dark:bg-gray-800 mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-500 outline-none focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 rounded-md dark:text-gray-100"
/>
</.form>
<div class="hidden lg:block">
<%= for %{key: key, value: value, icon: icon} <- options do %>
<%= render("_settings_tab.html",
icon: icon,
this_tab: value,
text: key,
conn: @conn,
submenu?: false
) %>
<% end %>
</div>
</div>
<div class="space-y-6 lg:col-span-9 lg:mt-4">
<%= @inner_content %>
</div>
</div>
</div>
<% end %>

View File

@ -11,7 +11,7 @@
</div>
<div class="lg:grid lg:grid-cols-12 lg:gap-x-5 lg:mt-4">
<div class="py-4 g:py-0 lg:col-span-3">
<%= form_for @conn, "/sites/#{URI.encode_www_form(@site.domain)}/monthly-report/recipients", [class: "lg:hidden"], fn f -> %>
<%= form_for @conn, "/sites/#{URI.encode_www_form(@site.domain)}", [class: "lg:hidden"], fn f -> %>
<% options = flat_settings_options(@conn) %>
<%= select(f, :tab, options,
class:
@ -26,7 +26,7 @@
<div class="hidden lg:block">
<%= for %{key: key, value: value, icon: icon} <- settings_tabs(@conn) do %>
<%= if is_binary(value) do %>
<%= render("_settings_tab.html",
<%= render("_site_settings_tab.html",
icon: icon,
this_tab: value,
text: key,
@ -35,7 +35,7 @@
submenu?: false
) %>
<% else %>
<%= render("_settings_tab.html",
<%= render("_site_settings_tab.html",
icon: icon,
this_tab: nil,
text: key,
@ -45,7 +45,7 @@
) %>
<div class="ml-6">
<%= for %{key: key, value: val} <- value do %>
<%= render("_settings_tab.html",
<%= render("_site_settings_tab.html",
icon: nil,
this_tab: val,
text: key,

View File

@ -0,0 +1,56 @@
<.settings_tiles>
<.tile docs="stats-api">
<:title>
<a id="api-keys">API Keys</a>
</:title>
<:subtitle>
Manage your Stats API keys
</:subtitle>
<PlausibleWeb.Components.Billing.Notice.premium_feature
billable_user={@current_user}
current_user={@current_user}
feature_mod={Plausible.Billing.Feature.StatsAPI}
/>
<.filter_bar filtering_enabled?={false}>
<.button_link
:if={Plausible.Billing.Feature.StatsAPI.check_availability(@current_user) == :ok}
href={Routes.settings_path(@conn, :new_api_key)}
>
New API Key
</.button_link>
</.filter_bar>
<.table rows={@api_keys}>
<:thead>
<.th>
Name
</.th>
<.th hide_on_mobile>
Key
</.th>
<.th invisible>
Actions
</.th>
</:thead>
<:tbody :let={api_key}>
<.td truncate max_width="max-w-40">
<%= api_key.name %>
</.td>
<.td hide_on_mobile>
<%= api_key.key_prefix %>
<%= String.duplicate("*", 32 - 6) %>
</.td>
<.td actions>
<.delete_button
method="delete"
href={Routes.settings_path(@conn, :delete_api_key, api_key.id)}
data-confirm="Are you sure you want to revoke this key? This action cannot be reversed."
/>
</.td>
</:tbody>
</.table>
</.tile>
</.settings_tiles>

View File

@ -0,0 +1,25 @@
<.notice title="Danger Zone" theme={:red}>
Destructive actions below can result in irrecoverable data loss. Be careful.
</.notice>
<.settings_tiles>
<.tile docs="delete-account">
<:title>Delete Account</:title>
<:subtitle>Deleting your account removes all sites and stats you've collected</:subtitle>
<%= if Plausible.Billing.Subscription.Status.active?(@current_user.subscription) do %>
<.notice theme={:gray} title="Cannot delete account at this time">
Your account cannot be deleted because you have an active subscription. If you want to delete your account, please cancel your subscription first.
</.notice>
<% else %>
<.button_link
data-confirm="Deleting your account will also delete all the sites and data that you own. This action cannot be reversed. Are you sure?"
href="/me"
method="delete"
theme="danger"
>
Delete my account
</.button_link>
<% end %>
</.tile>
</.settings_tiles>

View File

@ -0,0 +1,55 @@
<.settings_tiles>
<.tile docs="download-invoices">
<:title>
<a id="invoices">Invoices</a>
</:title>
<:subtitle>
Download your invoices
</:subtitle>
<%= case @invoices do %>
<% {:error, :no_invoices} -> %>
<p class="mt-12 mb-8 text-center text-sm">
<span>
No invoices issued yet
</span>
</p>
<% {:error, :request_failed} -> %>
<.notice theme={:gray} title="We couldn't retrieve your invoices">
Please refresh the page or try again later
</.notice>
<% {:ok, invoice_list} when is_list(invoice_list) -> %>
<div x-data="{showAll: false}" x-cloak>
<.table
rows={Enum.with_index(format_invoices(invoice_list))}
row_attrs={
fn {_invoice, idx} ->
%{
"x-show" => "showAll || #{idx} < 12"
}
end
}
>
<:thead>
<.th>Date</.th>
<.th>Amount</.th>
<.th>Invoice</.th>
</:thead>
<:tbody :let={{invoice, _idx}}>
<.td><%= invoice.date %></.td>
<.td><%= invoice.currency <> invoice.amount %></.td>
<.td>
<.styled_link href={invoice.url} new_tab={true}>View</.styled_link>
</.td>
</:tbody>
<tr :if={length(invoice_list) > 12}>
<td colspan="3" class="text-center pt-8 pb-4">
<.button_link href={} theme="bright" x-on:click="showAll = true" x-show="!showAll">
Show More
</.button_link>
</td>
</tr>
</.table>
</div>
<% end %>
</.tile>
</.settings_tiles>

View File

@ -1,7 +1,7 @@
<PlausibleWeb.Components.Generic.focus_box>
<:title>Create new API key</:title>
<%= form_for @changeset, "/settings/api-keys", fn f -> %>
<%= form_for @changeset, Routes.settings_path(@conn, :api_keys), fn f -> %>
<div class="my-4">
<%= label(f, :name, class: "block font-medium text-gray-700 dark:text-gray-300") %>
<div class="mt-1">

View File

@ -0,0 +1,49 @@
<.settings_tiles>
<.tile>
<:title>
<a id="update-name">Your Name</a>
</:title>
<:subtitle>
Change the name associated with your account
</:subtitle>
<.form
:let={f}
action={Routes.settings_path(@conn, :update_name)}
for={@name_changeset}
method="post"
>
<.input type="text" field={f[:name]} label="Name" width="w-1/2" />
<.button type="submit">
Change Name
</.button>
</.form>
</.tile>
<.tile docs="dashboard-appearance">
<:title>
<a id="update-theme">Dashboard Appearance</a>
</:title>
<:subtitle>
Set your visual preferences
</:subtitle>
<.form
:let={f}
action={Routes.settings_path(@conn, :update_theme)}
for={@theme_changeset}
method="post"
>
<.input
type="select"
field={f[:theme]}
options={Plausible.Themes.options()}
label="Theme"
width="w-1/2"
/>
<.button type="submit">
Change Theme
</.button>
</.form>
</.tile>
</.settings_tiles>

View File

@ -0,0 +1,246 @@
<.settings_tiles>
<.tile docs="change-email">
<:title>
<a id="update-email">Email Address</a>
</:title>
<:subtitle>
Change the address associated with your account
</:subtitle>
<.form
:let={f}
action={Routes.settings_path(@conn, :update_email)}
for={@email_changeset}
method="post"
>
<.input
type="text"
name="user[current_email]"
value={f.data.email}
label="Current Email"
width="w-1/2"
disabled
/>
<.input type="text" field={f[:email]} label="New E-mail" width="w-1/2" />
<.input type="password" field={f[:password]} label="Account Password" width="w-1/2" />
<.button type="submit">
Change Email
</.button>
</.form>
</.tile>
<.tile docs="reset-password">
<:title>
<a id="update-password">Password</a>
</:title>
<:subtitle>
Change your password
</:subtitle>
<.form
:let={f}
action={Routes.settings_path(@conn, :update_password)}
for={@password_changeset}
method="post"
>
<.input
type="password"
max_one_error
field={f[:old_password]}
label="Old Password"
width="w-1/2"
/>
<.input
type="password"
max_one_error
field={f[:password]}
label="New Password"
width="w-1/2"
/>
<.input
type="password"
max_one_error
autocomplete="new-password"
field={f[:password_confirmation]}
label="Confirm New Password"
width="w-1/2"
/>
<div :if={@totp_enabled?} class="mt-2">
<PlausibleWeb.Live.Components.Form.label for={f[:two_factor_code].id} class="mb-2">
Verify with 2FA Code
</PlausibleWeb.Live.Components.Form.label>
<PlausibleWeb.Components.TwoFactor.verify_2fa_input
form={f}
show_button?={false}
field={:two_factor_code}
/>
</div>
<.button type="submit">
Change Password
</.button>
</.form>
</.tile>
<.tile docs="2fa">
<:title>
<a id="update-2fa">Two-Factor Authentication (2FA)</a>
</:title>
<:subtitle>
Two-Factor Authentication protects your account by adding an extra security step when you log in
</:subtitle>
<div x-data="{disable2FAOpen: false, regenerate2FAOpen: false}">
<div :if={@totp_enabled?}>
<.button
x-on:click="disable2FAOpen = true; $refs.disable2FAPassword.value = ''"
theme="danger"
mt?={false}
>
Disable 2FA
</.button>
<p class="mt-2 text-gray-600 text-sm dark:text-gray-400">
Lost your recovery codes?
<a
href="#update-2fa"
x-on:click="regenerate2FAOpen = true; $refs.regenerate2FAPassword.value = ''"
class="underline text-indigo-600"
>
Generate new
</a>
</p>
</div>
<div :if={not @totp_enabled?}>
<.form
action={Routes.auth_path(@conn, :initiate_2fa_setup)}
for={@conn.params}
method="post"
>
<.button type="submit" mt?={false}>
Enable 2FA
</.button>
</.form>
</div>
<PlausibleWeb.Components.TwoFactor.modal
:let={f}
id="disable-2fa-modal"
state_param="disable2FAOpen"
form_data={@conn.params}
form_target={Routes.auth_path(@conn, :disable_2fa)}
title="Disable Two-Factor Authentication?"
>
<:icon>
<Heroicons.shield_exclamation class="h-6 w-6" />
</:icon>
<:buttons>
<.button type="submit" class="w-full sm:w-auto">
Disable 2FA
</.button>
</:buttons>
<div class="text-sm mt-2">
Once disabled, verification codes from the authenticator application and current recovery codes will become invalid. 2FA will have to be setup from the start.
</div>
<div class="text-sm mt-2">
Enter your password to disable 2FA.
</div>
<div class="mt-3">
<%= password_input(f, :password,
id: "disable_2fa_password",
placeholder: "Enter password",
"x-ref": "disable2FAPassword",
class:
"shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
) %>
</div>
</PlausibleWeb.Components.TwoFactor.modal>
<PlausibleWeb.Components.TwoFactor.modal
:let={f}
id="regenerate-2fa-modal"
state_param="regenerate2FAOpen"
form_data={@conn.params}
form_target={Routes.auth_path(@conn, :generate_2fa_recovery_codes)}
onsubmit="document.getElementById('generate-2fa-recovery-button').disabled = true"
title="Generate New Recovery Codes?"
>
<:icon>
<Heroicons.key class="h-6 w-6" />
</:icon>
<:buttons>
<.button
id="generate-2fa-recovery-button"
type="submit"
class="w-full sm:w-auto [&>span.label-enabled]:block [&>span.label-disabled]:hidden [&[disabled]>span.label-enabled]:hidden [&[disabled]>span.label-disabled]:block"
>
<span class="label-enabled pointer-events-none">
Generate New Codes
</span>
<span class="label-disabled">
<.spinner class="inline-block h-5 w-5 mr-2 text-white dark:text-gray-400" />
Generating Codes
</span>
</.button>
</:buttons>
<div class="text-sm mt-2">
If you generate new codes, the old ones will become invalid.
</div>
<div class="text-sm mt-2">
Enter your password to continue.
</div>
<div class="mt-3 w-full">
<%= password_input(f, :password,
id: "regenerate_2fa_password",
placeholder: "Enter password",
"x-ref": "regenerate2FAPassword",
class:
"w-full shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
) %>
</div>
</PlausibleWeb.Components.TwoFactor.modal>
</div>
</.tile>
<.tile docs="login-management">
<:title>
<a id="user-sessions">Login Management</a>
</:title>
<:subtitle>
Log out of your account on other devices. Note that logged-in sessions automatically expire after 14 days of inactivity
</:subtitle>
<.table rows={@user_sessions}>
<:thead>
<.th>Device</.th>
<.th hide_on_mobile>Last seen</.th>
<.th invisible>Actions</.th>
</:thead>
<:tbody :let={session}>
<.td truncate max_width="max-w-40"><%= session.device %></.td>
<.td hide_on_mobile><%= Plausible.Auth.UserSessions.last_used_humanize(session) %></.td>
<.td :if={@current_user_session.id == session.id} actions>Current session</.td>
<.td :if={@current_user_session.id != session.id} actions>
<.delete_button
href={Routes.settings_path(@conn, :delete_session, session.id)}
method="delete"
data-confirm="Are you sure you want to log out this session?"
/>
</.td>
</:tbody>
</.table>
</.tile>
</.settings_tiles>

View File

@ -0,0 +1,104 @@
<.settings_tiles>
<.tile docs="billing">
<:title>
<a id="subscription">Subscription</a>
</:title>
<:subtitle>
Manage your plan
</:subtitle>
<div class="w-full inline-flex gap-x-4 justify-end items-center mt-4 mb-4">
<span
:if={@subscription && Plausible.Billing.Plans.business_tier?(@subscription)}
class={[
"w-max px-2.5 py-0.5 rounded-md text-sm font-bold leading-5 text-indigo-600 bg-blue-100 dark:text-yellow-200 dark:border dark:bg-inherit dark:border-yellow-200"
]}
>
Business
</span>
<span
:if={@subscription}
class={[
"w-max px-2.5 py-0.5 rounded-md text-sm font-bold leading-5",
subscription_colors(@subscription.status)
]}
>
<%= present_subscription_status(@subscription.status) %>
</span>
</div>
<PlausibleWeb.Components.Billing.Notice.subscription_cancelled
user={@current_user}
dismissable={false}
/>
<div class="flex flex-col items-center justify-between sm:flex-row sm:items-start">
<PlausibleWeb.Components.Billing.monthly_quota_box
user={@current_user}
subscription={@subscription}
/>
<div class="w-full md:w-1/3 h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900">
<h4 class="font-black dark:text-gray-100">Next bill amount</h4>
<%= if Plausible.Billing.Subscription.Status.in?(@subscription, [Plausible.Billing.Subscription.Status.active(), Plausible.Billing.Subscription.Status.past_due()]) do %>
<div class="py-2 text-xl font-medium dark:text-gray-100">
<%= PlausibleWeb.BillingView.present_currency(@subscription.currency_code) %><%= @subscription.next_bill_amount %>
</div>
<.styled_link :if={@subscription.update_url} href={@subscription.update_url}>
Update billing info
</.styled_link>
<% else %>
<div class="py-2 text-xl font-medium dark:text-gray-100">---</div>
<% end %>
</div>
<div class="w-full md:w-1/3 h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900">
<h4 class="font-black dark:text-gray-100">Next bill date</h4>
<%= if @subscription && @subscription.next_bill_date && Plausible.Billing.Subscription.Status.in?(@subscription, [Plausible.Billing.Subscription.Status.active(), Plausible.Billing.Subscription.Status.past_due()]) do %>
<div class="py-2 text-xl font-medium dark:text-gray-100">
<%= Calendar.strftime(@subscription.next_bill_date, "%b %-d, %Y") %>
</div>
<span class="text-gray-600 dark:text-gray-400">
(<%= subscription_interval(@subscription) %> billing)
</span>
<% else %>
<div class="py-2 text-xl font-medium dark:text-gray-100">---</div>
<% end %>
</div>
</div>
<PlausibleWeb.Components.Billing.render_monthly_pageview_usage
usage={@pageview_usage}
limit={@pageview_limit}
/>
<article class="mt-8">
<.title>Sites & team members usage</.title>
<PlausibleWeb.Components.Billing.usage_and_limits_table>
<PlausibleWeb.Components.Billing.usage_and_limits_row
id="site-usage-row"
title="Owned sites"
usage={@site_usage}
limit={@site_limit}
/>
<PlausibleWeb.Components.Billing.usage_and_limits_row
id="team-member-usage-row"
title="Team members"
usage={@team_member_usage}
limit={@team_member_limit}
/>
</PlausibleWeb.Components.Billing.usage_and_limits_table>
</article>
<%= cond do %>
<% Plausible.Billing.Subscriptions.resumable?(@subscription) && @subscription.cancel_url -> %>
<div class="mt-8">
<.button_link theme="danger" href={@subscription.cancel_url}>
Cancel my subscription
</.button_link>
</div>
<% true -> %>
<div class="mt-8">
<PlausibleWeb.Components.Billing.upgrade_link />
</div>
<% end %>
</.tile>
</.settings_tiles>

View File

@ -36,7 +36,7 @@
</p>
</div>
<div class="mt-6 w-full text-center">
<.button_link href={Routes.auth_path(@conn, :user_settings)}>
<.button_link href={Routes.settings_path(@conn, :subscription)}>
Manage my subscription
</.button_link>
</div>

View File

@ -1,8 +1,7 @@
defmodule PlausibleWeb.AuthView do
use Plausible
use PlausibleWeb, :view
require Plausible.Billing.Subscription.Status
alias Plausible.Billing.{Plans, Subscription}
alias Plausible.Billing.Plans
def subscription_quota(subscription, options \\ [])
@ -29,17 +28,6 @@ defmodule PlausibleWeb.AuthView do
Plans.subscription_interval(subscription)
end
def format_invoices(invoice_list) do
Enum.map(invoice_list, fn invoice ->
%{
date: invoice["payout_date"] |> Date.from_iso8601!() |> Calendar.strftime("%b %-d, %Y"),
amount: (invoice["amount"] / 1) |> :erlang.float_to_binary(decimals: 2),
currency: invoice["currency"] |> PlausibleWeb.BillingView.present_currency(),
url: invoice["receipt_url"]
}
end)
end
def delimit_integer(number) do
Integer.to_charlist(number)
|> :lists.reverse()
@ -54,18 +42,4 @@ defmodule PlausibleWeb.AuthView do
defp delimit_integer(list, acc) do
:lists.reverse(list) ++ acc
end
@spec present_subscription_status(Subscription.Status.status()) :: String.t()
def present_subscription_status(Subscription.Status.active()), do: "Active"
def present_subscription_status(Subscription.Status.past_due()), do: "Past due"
def present_subscription_status(Subscription.Status.deleted()), do: "Cancelled"
def present_subscription_status(Subscription.Status.paused()), do: "Paused"
def present_subscription_status(status), do: status
@spec subscription_colors(Subscription.Status.status()) :: String.t()
def subscription_colors(Subscription.Status.active()), do: "bg-green-100 text-green-800"
def subscription_colors(Subscription.Status.past_due()), do: "bg-yellow-100 text-yellow-800"
def subscription_colors(Subscription.Status.paused()), do: "bg-red-100 text-red-800"
def subscription_colors(Subscription.Status.deleted()), do: "bg-red-100 text-red-800"
def subscription_colors(_), do: ""
end

View File

@ -0,0 +1,36 @@
defmodule PlausibleWeb.SettingsView do
use PlausibleWeb, :view
use Phoenix.Component, global_prefixes: ~w(x-)
require Plausible.Billing.Subscription.Status
alias Plausible.Billing.{Plans, Subscription}
def subscription_interval(subscription) do
Plans.subscription_interval(subscription)
end
def format_invoices(invoice_list) do
Enum.map(invoice_list, fn invoice ->
%{
date: invoice["payout_date"] |> Date.from_iso8601!() |> Calendar.strftime("%b %-d, %Y"),
amount: (invoice["amount"] / 1) |> :erlang.float_to_binary(decimals: 2),
currency: invoice["currency"] |> PlausibleWeb.BillingView.present_currency(),
url: invoice["receipt_url"]
}
end)
end
@spec present_subscription_status(Subscription.Status.status()) :: String.t()
def present_subscription_status(Subscription.Status.active()), do: "Active"
def present_subscription_status(Subscription.Status.past_due()), do: "Past due"
def present_subscription_status(Subscription.Status.deleted()), do: "Cancelled"
def present_subscription_status(Subscription.Status.paused()), do: "Paused"
def present_subscription_status(status), do: status
@spec subscription_colors(Subscription.Status.status()) :: String.t()
def subscription_colors(Subscription.Status.active()), do: "bg-green-100 text-green-800"
def subscription_colors(Subscription.Status.past_due()), do: "bg-yellow-100 text-yellow-800"
def subscription_colors(Subscription.Status.paused()), do: "bg-red-100 text-red-800"
def subscription_colors(Subscription.Status.deleted()), do: "bg-red-100 text-red-800"
def subscription_colors(_), do: ""
end

File diff suppressed because it is too large Load Diff

View File

@ -264,13 +264,13 @@ defmodule PlausibleWeb.BillingControllerTest do
test "redirects to /settings when past_due", %{conn: conn} = context do
subscribe_enterprise(context, status: Subscription.Status.past_due())
conn = get(conn, Routes.billing_path(conn, :upgrade_to_enterprise_plan))
assert redirected_to(conn) == "/settings"
assert redirected_to(conn) == Routes.settings_path(conn, :subscription)
end
test "redirects to /settings when paused", %{conn: conn} = context do
subscribe_enterprise(context, status: Subscription.Status.paused())
conn = get(conn, Routes.billing_path(conn, :upgrade_to_enterprise_plan))
assert redirected_to(conn) == "/settings"
assert redirected_to(conn) == Routes.settings_path(conn, :subscription)
end
end

View File

@ -1,4 +1,4 @@
defmodule PlausibleWeb.AuthControllerSyncTest do
defmodule PlausibleWeb.SettingsControllerSyncTest do
use PlausibleWeb.ConnCase
use Bamboo.Test
use Plausible.Repo
@ -24,12 +24,12 @@ defmodule PlausibleWeb.AuthControllerSyncTest do
assert user.email_verified
conn =
put(conn, "/settings/email", %{
post(conn, Routes.settings_path(conn, :update_email), %{
"user" => %{"email" => "new" <> user.email, "password" => password}
})
assert redirected_to(conn, 302) ==
Routes.auth_path(conn, :user_settings) <> "#change-email-address"
Routes.settings_path(conn, :security) <> "#update-email"
updated_user = Repo.reload!(user)

File diff suppressed because it is too large Load Diff

View File

@ -146,7 +146,7 @@ defmodule PlausibleWeb.EmailTest do
assert html_body =~
PlausibleWeb.Router.Helpers.billing_url(PlausibleWeb.Endpoint, :choose_plan)
assert html_body =~ "/settings\">account settings</a>"
assert html_body =~ "/settings/billing/subscription\">account settings</a>"
end
test "asks enterprise level usage to contact us" do
@ -196,7 +196,7 @@ defmodule PlausibleWeb.EmailTest do
assert html_body =~
PlausibleWeb.Router.Helpers.billing_url(PlausibleWeb.Endpoint, :choose_plan)
assert html_body =~ "/settings\">account settings</a>"
assert html_body =~ "/settings/billing/subscription\">account settings</a>"
end
test "asks enterprise level usage to contact us" do