improve setup UX: password field, setup hook, checklist banners, theme tweaks
All checks were successful
deploy / deploy (push) Successful in 1m31s
All checks were successful
deploy / deploy (push) Successful in 1m31s
- add password field and required shop name to setup wizard - extract SetupHook for DRY redirect to /setup when no admin exists - add ?from=checklist param to checklist hrefs with contextual banner on email settings and theme pages for easy return to dashboard - remove email warning banner from admin layout (checklist covers it) - make email a required checklist item (no longer optional) - add DevReset module for wiping dev data without restart - rename "Theme Studio" to "Theme", drop subtitle - lower theme editor side-by-side breakpoint from 64em to 48em - clean up login/registration pages (remove dead registration_open code) - fix settings.put_secret to invalidate cache after write Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -109,6 +109,7 @@ defmodule Berrypod.Accounts do
|
||||
else
|
||||
%User{}
|
||||
|> User.email_changeset(attrs)
|
||||
|> User.password_changeset(attrs)
|
||||
|> Ecto.Changeset.put_change(:confirmed_at, DateTime.utc_now(:second))
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
78
lib/berrypod/dev_reset.ex
Normal file
78
lib/berrypod/dev_reset.ex
Normal file
@@ -0,0 +1,78 @@
|
||||
if Mix.env() == :dev do
|
||||
defmodule Berrypod.DevReset do
|
||||
@moduledoc """
|
||||
Dev-only helper to wipe all data and flush caches without restarting.
|
||||
|
||||
Usage from IEx or Tidewave eval:
|
||||
|
||||
Berrypod.DevReset.run()
|
||||
"""
|
||||
|
||||
alias Berrypod.Repo
|
||||
|
||||
# Tables in deletion order (children before parents)
|
||||
@tables ~w(
|
||||
order_items
|
||||
orders
|
||||
abandoned_carts
|
||||
product_images
|
||||
product_variants
|
||||
products
|
||||
provider_connections
|
||||
favicon_variants
|
||||
images
|
||||
users_tokens
|
||||
users
|
||||
pages
|
||||
analytics_events
|
||||
newsletter_subscribers
|
||||
newsletter_campaigns
|
||||
redirects
|
||||
broken_urls
|
||||
dead_links
|
||||
activity_log
|
||||
email_suppressions
|
||||
settings
|
||||
)
|
||||
|
||||
def run do
|
||||
# Reconnect the Repo in case mix ecto.reset replaced the DB file
|
||||
# while the server was running (old connections would point to a ghost file)
|
||||
Supervisor.terminate_child(Berrypod.Supervisor, Repo)
|
||||
Supervisor.restart_child(Berrypod.Supervisor, Repo)
|
||||
Process.sleep(100)
|
||||
|
||||
IO.puts("Wiping all data...")
|
||||
|
||||
Repo.query!("PRAGMA foreign_keys = OFF")
|
||||
|
||||
for table <- @tables do
|
||||
Repo.query!("DELETE FROM \"#{table}\"")
|
||||
end
|
||||
|
||||
# Clear Oban jobs too
|
||||
Repo.query!("DELETE FROM oban_jobs")
|
||||
|
||||
Repo.query!("PRAGMA foreign_keys = ON")
|
||||
|
||||
IO.puts("Flushing caches and runtime config...")
|
||||
|
||||
# ETS caches (invalidate_all clears computed/cached values too)
|
||||
Berrypod.Settings.SettingsCache.invalidate_all()
|
||||
Berrypod.Theme.CSSCache.invalidate()
|
||||
Berrypod.Pages.PageCache.invalidate_all()
|
||||
|
||||
# Redirects ETS table
|
||||
if :ets.whereis(:redirects_cache) != :undefined do
|
||||
:ets.delete_all_objects(:redirects_cache)
|
||||
end
|
||||
|
||||
# Runtime Application env
|
||||
Application.put_env(:berrypod, Berrypod.Mailer, adapter: Swoosh.Adapters.Local)
|
||||
Application.put_env(:swoosh, :api_client, false)
|
||||
|
||||
IO.puts("Done — visit /setup to start fresh")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -202,23 +202,29 @@ defmodule Berrypod.Settings do
|
||||
The plaintext is encrypted via Vault before storage.
|
||||
"""
|
||||
def put_secret(key, plaintext) when is_binary(plaintext) do
|
||||
case Vault.encrypt(plaintext) do
|
||||
{:ok, encrypted} ->
|
||||
%Setting{key: key}
|
||||
|> Setting.changeset(%{
|
||||
key: key,
|
||||
value: "[encrypted]",
|
||||
value_type: "encrypted",
|
||||
encrypted_value: encrypted
|
||||
})
|
||||
|> Repo.insert(
|
||||
on_conflict: {:replace, [:value, :encrypted_value, :value_type, :updated_at]},
|
||||
conflict_target: :key
|
||||
)
|
||||
result =
|
||||
case Vault.encrypt(plaintext) do
|
||||
{:ok, encrypted} ->
|
||||
%Setting{key: key}
|
||||
|> Setting.changeset(%{
|
||||
key: key,
|
||||
value: "[encrypted]",
|
||||
value_type: "encrypted",
|
||||
encrypted_value: encrypted
|
||||
})
|
||||
|> Repo.insert(
|
||||
on_conflict: {:replace, [:value, :encrypted_value, :value_type, :updated_at]},
|
||||
conflict_target: :key
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
|
||||
SettingsCache.invalidate()
|
||||
SettingsCache.warm()
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
||||
@@ -28,7 +28,6 @@ defmodule BerrypodWeb.AdminLayoutHook do
|
||||
socket
|
||||
|> assign(:current_path, "")
|
||||
|> assign(:site_live, Settings.site_live?())
|
||||
|> assign(:email_configured, Berrypod.Mailer.email_configured?())
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:site_name, Settings.site_name())
|
||||
|> assign(:site_description, Settings.site_description())
|
||||
|
||||
@@ -18,17 +18,6 @@
|
||||
</.link>
|
||||
</header>
|
||||
|
||||
<%!-- email warning banner --%>
|
||||
<div :if={!@email_configured} class="admin-banner-warning">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
||||
<p>
|
||||
Email delivery isn't set up yet — customers won't receive order confirmations or shipping updates.
|
||||
<.link navigate={~p"/admin/settings/email"} class="admin-link">
|
||||
Configure email
|
||||
</.link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%!-- page content --%>
|
||||
<main class="admin-main">
|
||||
<div class="admin-container">
|
||||
|
||||
@@ -154,10 +154,8 @@ defmodule BerrypodWeb.Admin.Dashboard do
|
||||
defp launch_checklist(assigns) do
|
||||
items = checklist_items(assigns.setup)
|
||||
|
||||
# Email is optional — exclude from progress count
|
||||
required_items = Enum.reject(items, &(&1.key == :email_configured))
|
||||
done_count = Enum.count(required_items, & &1.done)
|
||||
total = length(required_items)
|
||||
done_count = Enum.count(items, & &1.done)
|
||||
total = length(items)
|
||||
progress_pct = round(done_count / total * 100)
|
||||
|
||||
assigns =
|
||||
@@ -248,33 +246,36 @@ defmodule BerrypodWeb.Admin.Dashboard do
|
||||
%{
|
||||
key: :provider_connected,
|
||||
label: "Connect a print provider",
|
||||
href: "/admin/providers"
|
||||
href: "/admin/providers?from=checklist"
|
||||
},
|
||||
%{
|
||||
key: :stripe_connected,
|
||||
label: "Connect Stripe",
|
||||
href: "/admin/settings"
|
||||
href: "/admin/settings?from=checklist"
|
||||
},
|
||||
# Post-setup items
|
||||
%{
|
||||
key: :products_synced,
|
||||
label: "Sync your products",
|
||||
href: if(setup.provider_connected, do: "/admin/products", else: "/admin/providers"),
|
||||
href:
|
||||
if(setup.provider_connected,
|
||||
do: "/admin/products?from=checklist",
|
||||
else: "/admin/providers?from=checklist"
|
||||
),
|
||||
hint: "Import products from your print provider."
|
||||
},
|
||||
%{
|
||||
key: :theme_customised,
|
||||
label: "Customise your theme",
|
||||
href: "/admin/theme",
|
||||
hint: "Upload your logo, pick your colours, and choose a font that matches your brand."
|
||||
},
|
||||
%{
|
||||
key: :email_configured,
|
||||
label: "Set up email",
|
||||
href: "/admin/settings",
|
||||
optional: true,
|
||||
href: "/admin/settings/email?from=checklist",
|
||||
hint: "Needed for order confirmations, abandoned cart emails, and the contact form."
|
||||
},
|
||||
%{
|
||||
key: :theme_customised,
|
||||
label: "Customise your theme",
|
||||
href: "/admin/theme?from=checklist",
|
||||
hint: "Upload your logo, pick your colours, and choose a font that matches your brand."
|
||||
},
|
||||
%{
|
||||
key: :has_orders,
|
||||
label: "Place a test order",
|
||||
|
||||
@@ -27,9 +27,15 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
||||
Settings.get_setting("email_from_address") || socket.assigns.current_scope.user.email
|
||||
)
|
||||
|> assign(:sending_test, false)
|
||||
|> assign(:from_checklist, false)
|
||||
|> assign(:form, to_form(%{}, as: :email))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _uri, socket) do
|
||||
{:noreply, assign(socket, :from_checklist, params["from"] == "checklist")}
|
||||
end
|
||||
|
||||
defp load_adapter_values(adapter_key) do
|
||||
case Adapters.get(adapter_key) do
|
||||
nil ->
|
||||
@@ -201,6 +207,15 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="admin-content-medium">
|
||||
<div :if={@from_checklist} class="admin-checklist-banner">
|
||||
<.icon name="hero-clipboard-document-check" class="size-5 admin-checklist-banner-icon" />
|
||||
<span class="admin-checklist-banner-text">
|
||||
You're setting up email for your shop.
|
||||
</span>
|
||||
<.link navigate={~p"/admin"} class="admin-link admin-checklist-banner-link">
|
||||
← Back to checklist
|
||||
</.link>
|
||||
</div>
|
||||
<.header>
|
||||
Email settings
|
||||
<:subtitle>
|
||||
|
||||
@@ -61,7 +61,12 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
||||
progress: &handle_progress/3
|
||||
)
|
||||
|
||||
{:ok, socket}
|
||||
{:ok, assign(socket, :from_checklist, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _uri, socket) do
|
||||
{:noreply, assign(socket, :from_checklist, params["from"] == "checklist")}
|
||||
end
|
||||
|
||||
defp handle_progress(:logo_upload, entry, socket) do
|
||||
|
||||
@@ -37,14 +37,21 @@
|
||||
<.link href={~p"/admin"} class="theme-back-link">
|
||||
<.icon name="hero-arrow-left-mini" class="size-4" /> Admin
|
||||
</.link>
|
||||
|
||||
<div :if={@from_checklist} class="admin-checklist-banner">
|
||||
<.icon name="hero-clipboard-document-check" class="size-5 admin-checklist-banner-icon" />
|
||||
<span class="admin-checklist-banner-text">
|
||||
You're customising your theme.
|
||||
</span>
|
||||
<.link navigate={~p"/admin"} class="admin-link admin-checklist-banner-link">
|
||||
← Back to checklist
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="theme-header">
|
||||
<div class="admin-fill">
|
||||
<h1 class="theme-title">Theme Studio</h1>
|
||||
<p class="theme-subtitle">
|
||||
One theme, infinite possibilities. Every combination is designed to work beautifully.
|
||||
</p>
|
||||
<h1 class="theme-title">Theme</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -12,17 +12,10 @@ defmodule BerrypodWeb.Auth.Login do
|
||||
<.header>
|
||||
<p>Log in</p>
|
||||
<:subtitle>
|
||||
<%= cond do %>
|
||||
<% @current_scope -> %>
|
||||
You need to reauthenticate to perform sensitive actions on your account.
|
||||
<% @registration_open -> %>
|
||||
Don't have an account? <.link
|
||||
navigate={~p"/setup"}
|
||||
class="font-semibold text-brand hover:underline"
|
||||
phx-no-format
|
||||
>Set up your shop</.link> to get started.
|
||||
<% true -> %>
|
||||
Log in with your admin credentials.
|
||||
<%= if @current_scope do %>
|
||||
You need to reauthenticate to perform sensitive actions on your account.
|
||||
<% else %>
|
||||
Log in with your admin credentials.
|
||||
<% end %>
|
||||
</:subtitle>
|
||||
</.header>
|
||||
@@ -116,7 +109,6 @@ defmodule BerrypodWeb.Auth.Login do
|
||||
assign(socket,
|
||||
form: form,
|
||||
trigger_submit: false,
|
||||
registration_open: !Accounts.has_admin?(),
|
||||
email_configured: Mailer.email_verified?()
|
||||
)}
|
||||
end
|
||||
|
||||
@@ -48,15 +48,11 @@ defmodule BerrypodWeb.Auth.Registration do
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
if Accounts.has_admin?() do
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, "Registration is closed")
|
||||
|> redirect(to: ~p"/users/log-in")}
|
||||
else
|
||||
# Fresh install — account creation happens on the setup page
|
||||
{:ok, redirect(socket, to: ~p"/setup")}
|
||||
end
|
||||
# Admin exists (hook handles no-admin), registration is single-user only
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, "Registration is closed")
|
||||
|> redirect(to: ~p"/users/log-in")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
||||
@@ -54,7 +54,10 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
||||
|> assign(:secret_verified, false)
|
||||
|> assign(:secret_form, to_form(%{"secret" => ""}, as: :secret))
|
||||
# Account (card 1)
|
||||
|> assign(:account_form, to_form(%{"email" => "", "shop_name" => ""}, as: :account))
|
||||
|> assign(
|
||||
:account_form,
|
||||
to_form(%{"email" => "", "password" => "", "shop_name" => ""}, as: :account)
|
||||
)
|
||||
# Provider (card 2)
|
||||
|> assign(:providers, Provider.all())
|
||||
|> assign(:selected_provider, nil)
|
||||
@@ -81,32 +84,38 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
||||
|
||||
def handle_event("create_account", %{"account" => params}, socket) do
|
||||
email = params["email"]
|
||||
password = params["password"]
|
||||
shop_name = String.trim(params["shop_name"] || "")
|
||||
|
||||
if email == "" do
|
||||
{:noreply, put_flash(socket, :error, "Please enter your email address")}
|
||||
else
|
||||
if shop_name != "" do
|
||||
cond do
|
||||
shop_name == "" ->
|
||||
{:noreply, put_flash(socket, :error, "Please enter a shop name")}
|
||||
|
||||
email == "" ->
|
||||
{:noreply, put_flash(socket, :error, "Please enter your email address")}
|
||||
|
||||
true ->
|
||||
Settings.put_setting("site_name", shop_name, "string")
|
||||
end
|
||||
|
||||
case Accounts.register_and_confirm_admin(%{email: email}) do
|
||||
{:ok, user} ->
|
||||
token = Accounts.generate_login_token(user)
|
||||
{:noreply, redirect(socket, to: ~p"/setup/login/#{token}")}
|
||||
case Accounts.register_and_confirm_admin(%{email: email, password: password}) do
|
||||
{:ok, user} ->
|
||||
token = Accounts.generate_login_token(user)
|
||||
{:noreply, redirect(socket, to: ~p"/setup/login/#{token}")}
|
||||
|
||||
{:error, :admin_already_exists} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "An admin account already exists")
|
||||
|> push_navigate(to: ~p"/setup")}
|
||||
{:error, :admin_already_exists} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "An admin account already exists")
|
||||
|> push_navigate(to: ~p"/setup")}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:account_form, to_form(changeset, as: :account))
|
||||
|> put_flash(:error, "Could not create account")}
|
||||
end
|
||||
{:error, changeset} ->
|
||||
form = to_form(params, as: :account, errors: changeset.errors, action: :validate)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:account_form, form)
|
||||
|> put_flash(:error, "Could not create account")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -255,6 +264,7 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
||||
label="Shop name"
|
||||
placeholder="e.g. Acme Prints"
|
||||
autocomplete="off"
|
||||
required
|
||||
phx-mounted={@current_step == 1 && JS.focus()}
|
||||
/>
|
||||
<.input
|
||||
@@ -264,6 +274,14 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={@account_form[:password]}
|
||||
type="password"
|
||||
label="Password"
|
||||
placeholder="12 characters minimum"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<div class="setup-actions">
|
||||
<.button phx-disable-with="Creating account...">Create account</.button>
|
||||
</div>
|
||||
|
||||
@@ -192,7 +192,10 @@ defmodule BerrypodWeb.Router do
|
||||
pipe_through [:browser]
|
||||
|
||||
live_session :current_user,
|
||||
on_mount: [{BerrypodWeb.UserAuth, :mount_current_scope}] do
|
||||
on_mount: [
|
||||
{BerrypodWeb.SetupHook, :require_admin},
|
||||
{BerrypodWeb.UserAuth, :mount_current_scope}
|
||||
] do
|
||||
live "/users/register", Auth.Registration, :new
|
||||
live "/users/log-in", Auth.Login, :new
|
||||
live "/users/log-in/:token", Auth.Confirmation, :new
|
||||
@@ -239,6 +242,7 @@ defmodule BerrypodWeb.Router do
|
||||
live_session :coming_soon,
|
||||
layout: {BerrypodWeb.Layouts, :shop},
|
||||
on_mount: [
|
||||
{BerrypodWeb.SetupHook, :require_admin},
|
||||
{BerrypodWeb.ThemeHook, :mount_theme}
|
||||
] do
|
||||
live "/coming-soon", Shop.ComingSoon, :index
|
||||
@@ -247,6 +251,7 @@ defmodule BerrypodWeb.Router do
|
||||
live_session :public_shop,
|
||||
layout: {BerrypodWeb.Layouts, :shop},
|
||||
on_mount: [
|
||||
{BerrypodWeb.SetupHook, :require_admin},
|
||||
{BerrypodWeb.UserAuth, :mount_current_scope},
|
||||
{BerrypodWeb.ThemeHook, :mount_theme},
|
||||
{BerrypodWeb.ThemeHook, :require_site_live},
|
||||
|
||||
18
lib/berrypod_web/setup_hook.ex
Normal file
18
lib/berrypod_web/setup_hook.ex
Normal file
@@ -0,0 +1,18 @@
|
||||
defmodule BerrypodWeb.SetupHook do
|
||||
@moduledoc """
|
||||
Redirects to /setup when no admin account exists (fresh install).
|
||||
|
||||
Add `{BerrypodWeb.SetupHook, :require_admin}` to any live_session
|
||||
that shouldn't be accessible before setup is complete.
|
||||
"""
|
||||
|
||||
import Phoenix.LiveView, only: [redirect: 2]
|
||||
|
||||
def on_mount(:require_admin, _params, _session, socket) do
|
||||
if Berrypod.Accounts.has_admin?() do
|
||||
{:cont, socket}
|
||||
else
|
||||
{:halt, redirect(socket, to: "/setup")}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -82,10 +82,6 @@ defmodule BerrypodWeb.ThemeHook do
|
||||
socket.assigns[:current_scope] && socket.assigns.current_scope.user ->
|
||||
{:cont, socket}
|
||||
|
||||
not Berrypod.Accounts.has_admin?() ->
|
||||
# Fresh install — send to setup
|
||||
{:halt, Phoenix.LiveView.redirect(socket, to: "/setup")}
|
||||
|
||||
true ->
|
||||
{:halt, Phoenix.LiveView.redirect(socket, to: "/coming-soon")}
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user