mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 09:33:19 +03:00
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:
parent
4224f9d187
commit
2359cb920c
@ -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
|
||||
|
@ -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])
|
||||
|
@ -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>)}
|
||||
|
@ -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
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
"""
|
||||
|
@ -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
|
||||
|
@ -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", "/")
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
281
lib/plausible_web/controllers/settings_controller.ex
Normal file
281
lib/plausible_web/controllers/settings_controller.ex
Normal 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
|
@ -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">
|
||||
|
@ -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>
|
||||
"""
|
||||
|
@ -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>.
|
||||
|
@ -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"]
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 %>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
@ -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>
|
@ -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>
|
||||
|
@ -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
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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>
|
||||
|
@ -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) &&
|
||||
|
@ -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>
|
57
lib/plausible_web/templates/layout/settings.html.heex
Normal file
57
lib/plausible_web/templates/layout/settings.html.heex
Normal 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 %>
|
@ -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,
|
||||
|
56
lib/plausible_web/templates/settings/api_keys.html.heex
Normal file
56
lib/plausible_web/templates/settings/api_keys.html.heex
Normal 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>
|
25
lib/plausible_web/templates/settings/danger_zone.html.heex
Normal file
25
lib/plausible_web/templates/settings/danger_zone.html.heex
Normal 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>
|
55
lib/plausible_web/templates/settings/invoices.html.heex
Normal file
55
lib/plausible_web/templates/settings/invoices.html.heex
Normal 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>
|
@ -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">
|
49
lib/plausible_web/templates/settings/preferences.html.heex
Normal file
49
lib/plausible_web/templates/settings/preferences.html.heex
Normal 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>
|
246
lib/plausible_web/templates/settings/security.html.heex
Normal file
246
lib/plausible_web/templates/settings/security.html.heex
Normal 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>
|
104
lib/plausible_web/templates/settings/subscription.html.heex
Normal file
104
lib/plausible_web/templates/settings/subscription.html.heex
Normal 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>
|
@ -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>
|
||||
|
@ -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
|
||||
|
36
lib/plausible_web/views/settings_view.ex
Normal file
36
lib/plausible_web/views/settings_view.ex
Normal 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
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
1135
test/plausible_web/controllers/settings_controller_test.exs
Normal file
1135
test/plausible_web/controllers/settings_controller_test.exs
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user