improve first launch experience for self-hosters (#2357)

* first launch

* dynamic children, wait for repo

* remove wait_for_repo and app env manipulations

* don't mention free trial in self-hosted pages

* add changelog

* assigns[:is_selfhost] -> @is_selfhost

* better changelog wording

* rm admin_user, admin_email, admin_pwd from app env

* rm DISABLE_AUTH

* redirect / to /login when not authenticated

* remove TODO

* Update lib/plausible_web/controllers/page_controller.ex

Co-authored-by: Uku Taht <Uku.taht@gmail.com>

* format

Co-authored-by: Uku Taht <Uku.taht@gmail.com>
This commit is contained in:
ruslandoga 2022-11-10 19:42:22 +08:00 committed by GitHub
parent 0e91ae9b58
commit 0b7870dc4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 239 additions and 208 deletions

View File

@ -39,6 +39,7 @@ All notable changes to this project will be documented in this file.
- Manually lock and unlock enterprise users plausible/analytics#2197
- ARM64 support for docker images plausible/analytics#2103
- Add support for international domain names (IDNs) plausible/analytics#2034
- Allow self-hosters to register an account on first launch
### Fixed
- Plausible script does not prevent default if it's been prevented by an external script [plausible/analytics#1941](https://github.com/plausible/analytics/issues/1941)

View File

@ -6,8 +6,6 @@ CRON_ENABLED=false
LOG_LEVEL=warn
ENVIRONMENT=test
MAILER_ADAPTER=Bamboo.TestAdapter
ADMIN_USER_EMAIL=admin@email.com
ADMIN_USER_PWD=fakepassword
ENABLE_EMAIL_VERIFICATION=true
SELFHOST=false
SITE_LIMIT=3

View File

@ -58,9 +58,6 @@ db_url =
db_socket_dir = get_var_from_path_or_env(config_dir, "DATABASE_SOCKET_DIR")
admin_user = get_var_from_path_or_env(config_dir, "ADMIN_USER_NAME")
admin_email = get_var_from_path_or_env(config_dir, "ADMIN_USER_EMAIL")
super_admin_user_ids =
get_var_from_path_or_env(config_dir, "ADMIN_USER_IDS", "")
|> String.split(",")
@ -71,7 +68,6 @@ super_admin_user_ids =
end)
|> Enum.filter(& &1)
admin_pwd = get_var_from_path_or_env(config_dir, "ADMIN_USER_PWD")
env = get_var_from_path_or_env(config_dir, "ENVIRONMENT", "prod")
mailer_adapter = get_var_from_path_or_env(config_dir, "MAILER_ADAPTER", "Bamboo.SMTPAdapter")
mailer_email = get_var_from_path_or_env(config_dir, "MAILER_EMAIL", "hello@plausible.local")
@ -135,10 +131,10 @@ geolite2_country_db =
ip_geolocation_db = get_var_from_path_or_env(config_dir, "IP_GEOLOCATION_DB", geolite2_country_db)
geonames_source_file = get_var_from_path_or_env(config_dir, "GEONAMES_SOURCE_FILE")
disable_auth =
config_dir
|> get_var_from_path_or_env("DISABLE_AUTH", "false")
|> String.to_existing_atom()
if System.get_env("DISABLE_AUTH") do
require Logger
Logger.warn("DISABLE_AUTH env var is no longer supported")
end
enable_email_verification =
config_dir
@ -193,9 +189,6 @@ disable_cron =
|> String.to_existing_atom()
config :plausible,
admin_user: admin_user,
admin_email: admin_email,
admin_pwd: admin_pwd,
environment: env,
mailer_email: mailer_email,
super_admin_user_ids: super_admin_user_ids,
@ -206,9 +199,8 @@ config :plausible,
domain_blacklist: domain_blacklist
config :plausible, :selfhost,
disable_authentication: disable_auth,
enable_email_verification: enable_email_verification,
disable_registration: if(!disable_auth, do: disable_registration, else: false)
disable_registration: disable_registration
config :plausible, PlausibleWeb.Endpoint,
url: [scheme: base_url.scheme, host: base_url.host, path: base_url.path, port: base_url.port],

View File

@ -1,5 +1,7 @@
defmodule Plausible.Release do
use Plausible.Repo
require Logger
@app :plausible
@start_apps [
:postgrex,
@ -7,24 +9,13 @@ defmodule Plausible.Release do
:ecto
]
def init_admin do
prepare()
{admin_email, admin_user, admin_pwd} =
validate_admin(
{Application.get_env(:plausible, :admin_email),
Application.get_env(:plausible, :admin_user),
Application.get_env(:plausible, :admin_pwd)}
)
case Plausible.Auth.find_user_by(email: admin_email) do
nil ->
{:ok, _} = Plausible.Auth.create_user(admin_user, admin_email, admin_pwd)
IO.puts("Admin user created successful!")
_ ->
IO.puts("Admin user already exists. I won't override, bailing")
@spec selfhost? :: boolean
def selfhost? do
Application.fetch_env!(@app, :is_selfhost)
end
def should_be_first_launch? do
selfhost?() and not (_has_users? = Repo.exists?(Plausible.Auth.User))
end
def migrate do
@ -81,18 +72,6 @@ defmodule Plausible.Release do
##############################
defp validate_admin({nil, nil, nil}) do
random_user = :crypto.strong_rand_bytes(8) |> Base.encode64() |> binary_part(0, 8)
random_pwd = :crypto.strong_rand_bytes(20) |> Base.encode64() |> binary_part(0, 20)
random_email = "#{random_user}@#{System.get_env("HOST")}"
IO.puts("generated admin user/password: #{random_email} / #{random_pwd}")
{random_email, random_user, random_pwd}
end
defp validate_admin({admin_email, admin_user, admin_password}) do
{admin_email, admin_user, admin_password}
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end

View File

@ -1,7 +1,7 @@
defmodule PlausibleWeb.AuthController do
use PlausibleWeb, :controller
use Plausible.Repo
alias Plausible.Auth
alias Plausible.{Auth, Release}
require Logger
plug PlausibleWeb.RequireLoggedOutPlug
@ -24,23 +24,41 @@ defmodule PlausibleWeb.AuthController do
:activate_form
]
plug :maybe_disable_registration when action in [:register_form, :register]
plug :assign_is_selfhost
defp maybe_disable_registration(conn, _opts) do
selfhost_config = Application.get_env(:plausible, :selfhost)
disable_registration = Keyword.fetch!(selfhost_config, :disable_registration)
first_launch? = Release.should_be_first_launch?()
cond do
first_launch? ->
conn
disable_registration in [:invite_only, true] ->
conn |> redirect(to: Routes.auth_path(conn, :login_form)) |> halt()
true ->
conn
end
end
defp assign_is_selfhost(conn, _opts) do
assign(conn, :is_selfhost, Plausible.Release.selfhost?())
end
def register_form(conn, _params) do
if Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_registration) != false do
redirect(conn, to: Routes.auth_path(conn, :login_form))
else
changeset = Plausible.Auth.User.changeset(%Plausible.Auth.User{})
changeset = Auth.User.changeset(%Auth.User{})
render(conn, "register_form.html",
changeset: changeset,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
end
def register(conn, params) do
if Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_registration) != false do
redirect(conn, to: Routes.auth_path(conn, :login_form))
else
conn = put_layout(conn, {PlausibleWeb.LayoutView, "focus.html"})
user = Plausible.Auth.User.new(params["user"])
if PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do
@ -48,30 +66,23 @@ defmodule PlausibleWeb.AuthController do
{:ok, user} ->
conn = set_user_session(conn, user)
case user.email_verified do
false ->
if user.email_verified do
redirect(conn, to: Routes.site_path(conn, :new))
else
send_email_verification(user)
redirect(conn, to: Routes.auth_path(conn, :activate_form))
true ->
redirect(conn, to: Routes.site_path(conn, :new))
end
{:error, changeset} ->
render(conn, "register_form.html",
changeset: changeset,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
render(conn, "register_form.html", changeset: changeset)
end
else
render(conn, "register_form.html",
changeset: user,
captcha_error: "Please complete the captcha to register",
layout: {PlausibleWeb.LayoutView, "focus.html"}
captcha_error: "Please complete the captcha to register"
)
end
end
end
def register_from_invitation_form(conn, %{"invitation_id" => invitation_id}) do
if Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_registration) == true do

View File

@ -1,14 +1,15 @@
defmodule PlausibleWeb.PageController do
use PlausibleWeb, :controller
use Plausible.Repo
plug PlausibleWeb.AutoAuthPlug
@doc """
The root path is never accessible in Plausible.Cloud because it is handled by the upstream reverse proxy.
This controller action is only ever triggered in self-hosted Plausible. It redirects the user to /login where `PlausibleWeb.RequireLoggedOutPlug` plug kicks in. If they are already logged in, they are redirected to /sites, otherwise they'll see the /login page.
"""
def index(conn, _params) do
if conn.assigns[:current_user] do
user = conn.assigns[:current_user] |> Repo.preload(:sites)
render(conn, "sites.html", sites: user.sites)
else
render(conn, "index.html")
end
conn
|> put_session(:login_dest, conn.request_path)
|> redirect(to: "/login")
end
end

View File

@ -1,25 +0,0 @@
defmodule PlausibleWeb.AutoAuthPlug do
import Plug.Conn
alias PlausibleWeb.AuthController
def init(options) do
options
end
def call(conn, _opts) do
cond do
Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_authentication) ->
conn
|> AuthController.login(%{
"email" => Application.fetch_env!(:plausible, :admin_email),
"password" => Application.fetch_env!(:plausible, :admin_pwd)
})
|> halt
true ->
Plug.Conn.put_session(conn, :login_dest, conn.request_path)
|> Phoenix.Controller.redirect(to: "/login")
|> halt
end
end
end

View File

@ -0,0 +1,26 @@
defmodule PlausibleWeb.FirstLaunchPlug do
@moduledoc """
Redirects first-launch users to registration page.
"""
@behaviour Plug
alias Plausible.Release
@impl true
def init(opts) do
_path = Keyword.fetch!(opts, :redirect_to)
end
@impl true
def call(%Plug.Conn{request_path: path} = conn, path), do: conn
def call(conn, redirect_to) do
if Release.should_be_first_launch?() do
conn
|> Phoenix.Controller.redirect(to: redirect_to)
|> Plug.Conn.halt()
else
conn
end
end
end

View File

@ -8,6 +8,7 @@ defmodule PlausibleWeb.Router do
plug :fetch_session
plug :fetch_flash
plug :put_secure_browser_headers
plug PlausibleWeb.FirstLaunchPlug, redirect_to: "/register"
plug PlausibleWeb.SessionTimeoutPlug, timeout_after_seconds: @two_weeks_in_seconds
plug PlausibleWeb.AuthPlug
plug PlausibleWeb.LastSeenPlug

View File

@ -1,5 +1,11 @@
<div class="mx-auto mt-6 text-center dark:text-gray-300">
<h1 class="text-3xl font-black">Register your 30-day unlimited-use free trial</h1>
<h1 class="text-3xl font-black">
<%= if @is_selfhost do %>
Register your Plausible Analytics account
<% else %>
Register your 30-day unlimited-use free trial
<% end %>
</h1>
<div class="text-xl font-medium">Set up privacy-friendly analytics with just a few clicks</div>
</div>
@ -53,7 +59,15 @@
</div>
<% end %>
<%= submit "Start my free trial →", class: "button mt-4 w-full" %>
<%
submit_text =
if @is_selfhost do
"Create my account →"
else
"Start my free trial →"
end
%>
<%= submit submit_text, class: "button mt-4 w-full" %>
<p class="text-center text-gray-600 dark:text-gray-500 text-xs mt-4">
Already have an account? <%= link("Log in", to: "/login", class: "underline text-gray-800 dark:text-gray-50") %> instead.

View File

@ -46,14 +46,6 @@
<li id="changelog-notification" class="relative py-2"></li>
<% end %>
</ul>
<% Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_authentication) -> %>
<ul class="flex" x-show="!document.cookie.includes('logged_in=true')">
<li>
<div class="inline-flex ml-6 rounded shadow">
<a href="/" class="inline-flex items-center justify-center px-5 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent leading-6 rounded-md hover:bg-indigo-500 focus:outline-none focus:ring transition duration-150 ease-in-out">My Sites</a>
</div>
</li>
</ul>
<% Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_registration) != false -> %>
<ul class="flex" x-show="!document.cookie.includes('logged_in=true')">
<li>

View File

@ -1,3 +0,0 @@
<div class="relative bg-gray-50 dark:bg-gray-800 overflow-hidden text-center dark:text-gray-100">
You will be redirected... If it doesn't work, please click login.
</div>

View File

@ -2,10 +2,6 @@ defmodule PlausibleWeb.AuthView do
use PlausibleWeb, :view
alias Plausible.Billing.Plans
def admin_email do
Application.get_env(:plausible, :admin_email)
end
def base_domain do
PlausibleWeb.Endpoint.host()
end

View File

@ -1,10 +1,6 @@
defmodule PlausibleWeb.BillingView do
use PlausibleWeb, :view
def admin_email do
Application.get_env(:plausible, :admin_email)
end
def base_domain do
PlausibleWeb.Endpoint.host()
end

View File

@ -1,10 +1,6 @@
defmodule PlausibleWeb.EmailView do
use PlausibleWeb, :view
def admin_email do
Application.get_env(:plausible, :admin_email)
end
def plausible_url do
PlausibleWeb.Endpoint.url()
end

View File

@ -1,10 +1,6 @@
defmodule PlausibleWeb.LayoutView do
use PlausibleWeb, :view
def admin_email do
Application.get_env(:plausible, :admin_email)
end
def base_domain do
PlausibleWeb.Endpoint.host()
end

View File

@ -1,15 +0,0 @@
defmodule PlausibleWeb.PageView do
use PlausibleWeb, :view
def admin_email do
Application.get_env(:plausible, :admin_email)
end
def base_domain do
PlausibleWeb.Endpoint.host()
end
def plausible_url do
PlausibleWeb.Endpoint.url()
end
end

View File

@ -2,10 +2,6 @@ defmodule PlausibleWeb.SiteView do
use PlausibleWeb, :view
import Phoenix.Pagination.HTML
def admin_email do
Application.get_env(:plausible, :admin_email)
end
def plausible_url do
PlausibleWeb.Endpoint.url()
end

View File

@ -1,10 +1,6 @@
defmodule PlausibleWeb.StatsView do
use PlausibleWeb, :view
def admin_email do
Application.get_env(:plausible, :admin_email)
end
def base_domain do
PlausibleWeb.Endpoint.host()
end

View File

@ -1,6 +1,5 @@
#!/bin/sh
# Create an admin user
BIN_DIR=$(dirname "$0")
"${BIN_DIR}"/bin/plausible eval Plausible.Release.init_admin
echo "init-admin is deprecated and is no-op now"
echo "user registration on first launch happens via Web UI instead"

View File

@ -0,0 +1,30 @@
defmodule Plausible.ReleaseTest do
use Plausible.DataCase
alias Plausible.{Release, Auth}
describe "should_be_first_launch?/0" do
test "returns true when self-hosted and no users" do
patch_env(:is_selfhost, true)
refute Repo.exists?(Auth.User)
assert Release.should_be_first_launch?()
end
test "returns false when not self-hosted and has no users" do
patch_env(:is_selfhost, false)
refute Repo.exists?(Auth.User)
refute Release.should_be_first_launch?()
end
test "returns false when not self-hosted and has users" do
insert(:user)
patch_env(:is_selfhost, false)
refute Release.should_be_first_launch?()
end
test "returns false when self-hosted and has users" do
insert(:user)
patch_env(:is_selfhost, true)
refute Release.should_be_first_launch?()
end
end
end

View File

@ -1,52 +1,33 @@
defmodule PlausibleWeb.AdminAuthControllerTest do
use PlausibleWeb.ConnCase
alias Plausible.Release
setup_patch_env(:is_selfhost, true)
describe "GET /" do
test "no landing page", %{conn: conn} do
set_config(disable_authentication: false)
conn = get(conn, "/")
assert redirected_to(conn) == "/login"
end
test "logs admin user in automatically when authentication is disabled", %{conn: conn} do
set_config(disable_authentication: true)
admin_user =
insert(:user,
email: Application.get_env(:plausible, :admin_email),
password: Application.get_env(:plausible, :admin_pwd)
)
# goto landing page
conn = get(conn, "/")
assert get_session(conn, :current_user_id) == admin_user.id
assert redirected_to(conn) == "/sites"
# trying logging out
conn = get(conn, "/logout")
assert redirected_to(conn) == "/"
conn = get(conn, "/")
assert redirected_to(conn) == "/sites"
end
test "disable registration", %{conn: conn} do
set_config(disable_registration: true)
prevent_first_launch()
patch_config(disable_registration: true)
conn = get(conn, "/register")
assert redirected_to(conn) == "/login"
end
test "disable registration + first launch", %{conn: conn} do
patch_config(disable_registration: true)
assert Release.should_be_first_launch?()
# "first launch" takes precedence
conn = get(conn, "/register")
assert html_response(conn, 200) =~ "Enter your details"
end
end
def set_config(config) do
updated_config =
Keyword.merge(
[disable_authentication: false, disable_registration: false],
config
)
def patch_config(config) do
updated_config = Keyword.merge([disable_registration: false], config)
patch_env(:selfhost, updated_config)
end
Application.put_env(
:plausible,
:selfhost,
updated_config
)
defp prevent_first_launch do
insert(:user)
end
end

View File

@ -0,0 +1,73 @@
defmodule PlausibleWeb.FirstLaunchPlugTest do
use PlausibleWeb.ConnCase
import Plug.Test
alias PlausibleWeb.FirstLaunchPlug
alias Plausible.Release
describe "init/1" do
test "requires :redirect_to option" do
assert_raise KeyError, ~r"key :redirect_to not found", fn ->
FirstLaunchPlug.init(_no_opts = [])
end
path = FirstLaunchPlug.init(redirect_to: "/register")
assert path == "/register"
end
end
@opts FirstLaunchPlug.init(redirect_to: "/register")
describe "call/2" do
test "no-op for paths == :redirect_to" do
conn = conn("GET", "/register")
conn = FirstLaunchPlug.call(conn, @opts)
refute conn.halted
# even when it's the first launch
patch_env(:is_selfhost, true)
assert Release.should_be_first_launch?()
conn = conn("GET", "/register")
conn = FirstLaunchPlug.call(conn, @opts)
refute conn.halted
end
test "no-op when not first launch" do
refute Release.should_be_first_launch?()
conn = conn("GET", "/sites")
conn = FirstLaunchPlug.call(conn, @opts)
refute conn.halted
end
test "redirects to :redirect_to when first launch" do
patch_env(:is_selfhost, true)
assert Release.should_be_first_launch?()
conn = conn("GET", "/sites")
conn = FirstLaunchPlug.call(conn, @opts)
assert conn.halted
assert redirected_to(conn) == "/register"
end
end
describe "first launch plug in :browser pipeline" do
test "redirects to /register on first launch", %{conn: conn} do
patch_env(:is_selfhost, true)
assert Release.should_be_first_launch?()
conn = get(conn, "/")
assert redirected_to(conn) == "/register"
end
test "no-op when not first launch", %{conn: conn} do
patch_env(:is_selfhost, false)
refute Release.should_be_first_launch?()
# gets redirected to login by auth plugs
# "first launch" doesn't interfere
conn = get(conn, "/sites")
assert redirected_to(conn) == "/login"
end
end
end