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:
parent
0853b6f528
commit
64f083d271
@ -934,6 +934,25 @@
|
|||||||
border: 1px solid var(--t-border-default);
|
border: 1px solid var(--t-border-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Checklist banner (shown when arriving from the launch checklist) ── */
|
||||||
|
|
||||||
|
.admin-checklist-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background-color: color-mix(in oklch, var(--t-status-info) 10%, var(--t-surface-base));
|
||||||
|
border: 1px solid color-mix(in oklch, var(--t-status-info) 20%, var(--t-surface-base));
|
||||||
|
|
||||||
|
& .admin-checklist-banner-icon { color: var(--t-status-info); flex-shrink: 0; }
|
||||||
|
& .admin-checklist-banner-text { flex: 1; color: var(--t-text-secondary); }
|
||||||
|
& .admin-checklist-banner-link { font-weight: 500; white-space: nowrap; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* ── Dashboard stats grid ── */
|
/* ── Dashboard stats grid ── */
|
||||||
@ -3422,7 +3441,7 @@
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--t-surface-sunken);
|
background: var(--t-surface-sunken);
|
||||||
|
|
||||||
@media (min-width: 64em) {
|
@media (min-width: 48em) {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
@ -3434,7 +3453,7 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: width 0.3s, padding 0.3s;
|
transition: width 0.3s, padding 0.3s;
|
||||||
|
|
||||||
@media (min-width: 64em) {
|
@media (min-width: 48em) {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3444,7 +3463,7 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
|
||||||
@media (min-width: 64em) {
|
@media (min-width: 48em) {
|
||||||
width: 380px;
|
width: 380px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -4168,6 +4187,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
||||||
|
&[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-adapter-link {
|
.admin-adapter-link {
|
||||||
|
|||||||
@ -386,6 +386,18 @@
|
|||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-clipboard-document-check {
|
||||||
|
--hero-clipboard-document-check: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M11.35%203.836c-.065.21-.1.433-.1.664%200%20.414.336.75.75.75h4.5a.75.75%200%200%200%20.75-.75%202.25%202.25%200%200%200-.1-.664m-5.8%200A2.251%202.251%200%200%201%2013.5%202.25H15c1.012%200%201.867.668%202.15%201.586m-5.8%200c-.376.023-.75.05-1.124.08C9.095%204.01%208.25%204.973%208.25%206.108V8.25m8.9-4.414c.376.023.75.05%201.124.08%201.131.094%201.976%201.057%201.976%202.192V16.5A2.25%202.25%200%200%201%2018%2018.75h-2.25m-7.5-10.5H4.875c-.621%200-1.125.504-1.125%201.125v11.25c0%20.621.504%201.125%201.125%201.125h9.75c.621%200%201.125-.504%201.125-1.125V18.75m-7.5-10.5h6.375c.621%200%201.125.504%201.125%201.125v9.375m-8.25-3%201.5%201.5%203-3.75"/></svg>');
|
||||||
|
-webkit-mask: var(--hero-clipboard-document-check);
|
||||||
|
mask: var(--hero-clipboard-document-check);
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
background-color: currentColor;
|
||||||
|
vertical-align: middle;
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-clipboard-document-list {
|
.hero-clipboard-document-list {
|
||||||
--hero-clipboard-document-list: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M9%2012h3.75M9%2015h3.75M9%2018h3.75m3%20.75H18a2.25%202.25%200%200%200%202.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424%2048.424%200%200%200-1.123-.08m-5.801%200c-.065.21-.1.433-.1.664%200%20.414.336.75.75.75h4.5a.75.75%200%200%200%20.75-.75%202.25%202.25%200%200%200-.1-.664m-5.8%200A2.251%202.251%200%200%201%2013.5%202.25H15c1.012%200%201.867.668%202.15%201.586m-5.8%200c-.376.023-.75.05-1.124.08C9.095%204.01%208.25%204.973%208.25%206.108V8.25m0%200H4.875c-.621%200-1.125.504-1.125%201.125v11.25c0%20.621.504%201.125%201.125%201.125h9.75c.621%200%201.125-.504%201.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75%2012h.008v.008H6.75V12Zm0%203h.008v.008H6.75V15Zm0%203h.008v.008H6.75V18Z"/></svg>');
|
--hero-clipboard-document-list: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M9%2012h3.75M9%2015h3.75M9%2018h3.75m3%20.75H18a2.25%202.25%200%200%200%202.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424%2048.424%200%200%200-1.123-.08m-5.801%200c-.065.21-.1.433-.1.664%200%20.414.336.75.75.75h4.5a.75.75%200%200%200%20.75-.75%202.25%202.25%200%200%200-.1-.664m-5.8%200A2.251%202.251%200%200%201%2013.5%202.25H15c1.012%200%201.867.668%202.15%201.586m-5.8%200c-.376.023-.75.05-1.124.08C9.095%204.01%208.25%204.973%208.25%206.108V8.25m0%200H4.875c-.621%200-1.125.504-1.125%201.125v11.25c0%20.621.504%201.125%201.125%201.125h9.75c.621%200%201.125-.504%201.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75%2012h.008v.008H6.75V12Zm0%203h.008v.008H6.75V15Zm0%203h.008v.008H6.75V18Z"/></svg>');
|
||||||
-webkit-mask: var(--hero-clipboard-document-list);
|
-webkit-mask: var(--hero-clipboard-document-list);
|
||||||
|
|||||||
@ -109,6 +109,7 @@ defmodule Berrypod.Accounts do
|
|||||||
else
|
else
|
||||||
%User{}
|
%User{}
|
||||||
|> User.email_changeset(attrs)
|
|> User.email_changeset(attrs)
|
||||||
|
|> User.password_changeset(attrs)
|
||||||
|> Ecto.Changeset.put_change(:confirmed_at, DateTime.utc_now(:second))
|
|> Ecto.Changeset.put_change(:confirmed_at, DateTime.utc_now(:second))
|
||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
end
|
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,6 +202,7 @@ defmodule Berrypod.Settings do
|
|||||||
The plaintext is encrypted via Vault before storage.
|
The plaintext is encrypted via Vault before storage.
|
||||||
"""
|
"""
|
||||||
def put_secret(key, plaintext) when is_binary(plaintext) do
|
def put_secret(key, plaintext) when is_binary(plaintext) do
|
||||||
|
result =
|
||||||
case Vault.encrypt(plaintext) do
|
case Vault.encrypt(plaintext) do
|
||||||
{:ok, encrypted} ->
|
{:ok, encrypted} ->
|
||||||
%Setting{key: key}
|
%Setting{key: key}
|
||||||
@ -219,6 +220,11 @@ defmodule Berrypod.Settings do
|
|||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:error, reason}
|
{:error, reason}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
SettingsCache.invalidate()
|
||||||
|
SettingsCache.warm()
|
||||||
|
|
||||||
|
result
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|||||||
@ -28,7 +28,6 @@ defmodule BerrypodWeb.AdminLayoutHook do
|
|||||||
socket
|
socket
|
||||||
|> assign(:current_path, "")
|
|> assign(:current_path, "")
|
||||||
|> assign(:site_live, Settings.site_live?())
|
|> assign(:site_live, Settings.site_live?())
|
||||||
|> assign(:email_configured, Berrypod.Mailer.email_configured?())
|
|
||||||
|> assign(:theme_settings, theme_settings)
|
|> assign(:theme_settings, theme_settings)
|
||||||
|> assign(:site_name, Settings.site_name())
|
|> assign(:site_name, Settings.site_name())
|
||||||
|> assign(:site_description, Settings.site_description())
|
|> assign(:site_description, Settings.site_description())
|
||||||
|
|||||||
@ -18,17 +18,6 @@
|
|||||||
</.link>
|
</.link>
|
||||||
</header>
|
</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 --%>
|
<%!-- page content --%>
|
||||||
<main class="admin-main">
|
<main class="admin-main">
|
||||||
<div class="admin-container">
|
<div class="admin-container">
|
||||||
|
|||||||
@ -154,10 +154,8 @@ defmodule BerrypodWeb.Admin.Dashboard do
|
|||||||
defp launch_checklist(assigns) do
|
defp launch_checklist(assigns) do
|
||||||
items = checklist_items(assigns.setup)
|
items = checklist_items(assigns.setup)
|
||||||
|
|
||||||
# Email is optional — exclude from progress count
|
done_count = Enum.count(items, & &1.done)
|
||||||
required_items = Enum.reject(items, &(&1.key == :email_configured))
|
total = length(items)
|
||||||
done_count = Enum.count(required_items, & &1.done)
|
|
||||||
total = length(required_items)
|
|
||||||
progress_pct = round(done_count / total * 100)
|
progress_pct = round(done_count / total * 100)
|
||||||
|
|
||||||
assigns =
|
assigns =
|
||||||
@ -248,33 +246,36 @@ defmodule BerrypodWeb.Admin.Dashboard do
|
|||||||
%{
|
%{
|
||||||
key: :provider_connected,
|
key: :provider_connected,
|
||||||
label: "Connect a print provider",
|
label: "Connect a print provider",
|
||||||
href: "/admin/providers"
|
href: "/admin/providers?from=checklist"
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
key: :stripe_connected,
|
key: :stripe_connected,
|
||||||
label: "Connect Stripe",
|
label: "Connect Stripe",
|
||||||
href: "/admin/settings"
|
href: "/admin/settings?from=checklist"
|
||||||
},
|
},
|
||||||
# Post-setup items
|
# Post-setup items
|
||||||
%{
|
%{
|
||||||
key: :products_synced,
|
key: :products_synced,
|
||||||
label: "Sync your products",
|
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."
|
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,
|
key: :email_configured,
|
||||||
label: "Set up email",
|
label: "Set up email",
|
||||||
href: "/admin/settings",
|
href: "/admin/settings/email?from=checklist",
|
||||||
optional: true,
|
|
||||||
hint: "Needed for order confirmations, abandoned cart emails, and the contact form."
|
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,
|
key: :has_orders,
|
||||||
label: "Place a test order",
|
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
|
Settings.get_setting("email_from_address") || socket.assigns.current_scope.user.email
|
||||||
)
|
)
|
||||||
|> assign(:sending_test, false)
|
|> assign(:sending_test, false)
|
||||||
|
|> assign(:from_checklist, false)
|
||||||
|> assign(:form, to_form(%{}, as: :email))}
|
|> assign(:form, to_form(%{}, as: :email))}
|
||||||
end
|
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
|
defp load_adapter_values(adapter_key) do
|
||||||
case Adapters.get(adapter_key) do
|
case Adapters.get(adapter_key) do
|
||||||
nil ->
|
nil ->
|
||||||
@ -201,6 +207,15 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="admin-content-medium">
|
<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>
|
<.header>
|
||||||
Email settings
|
Email settings
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
|
|||||||
@ -61,7 +61,12 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
|||||||
progress: &handle_progress/3
|
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
|
end
|
||||||
|
|
||||||
defp handle_progress(:logo_upload, entry, socket) do
|
defp handle_progress(:logo_upload, entry, socket) do
|
||||||
|
|||||||
@ -38,13 +38,20 @@
|
|||||||
<.icon name="hero-arrow-left-mini" class="size-4" /> Admin
|
<.icon name="hero-arrow-left-mini" class="size-4" /> Admin
|
||||||
</.link>
|
</.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 -->
|
<!-- Header -->
|
||||||
<div class="theme-header">
|
<div class="theme-header">
|
||||||
<div class="admin-fill">
|
<div class="admin-fill">
|
||||||
<h1 class="theme-title">Theme Studio</h1>
|
<h1 class="theme-title">Theme</h1>
|
||||||
<p class="theme-subtitle">
|
|
||||||
One theme, infinite possibilities. Every combination is designed to work beautifully.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -12,16 +12,9 @@ defmodule BerrypodWeb.Auth.Login do
|
|||||||
<.header>
|
<.header>
|
||||||
<p>Log in</p>
|
<p>Log in</p>
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
<%= cond do %>
|
<%= if @current_scope do %>
|
||||||
<% @current_scope -> %>
|
|
||||||
You need to reauthenticate to perform sensitive actions on your account.
|
You need to reauthenticate to perform sensitive actions on your account.
|
||||||
<% @registration_open -> %>
|
<% else %>
|
||||||
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.
|
Log in with your admin credentials.
|
||||||
<% end %>
|
<% end %>
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
@ -116,7 +109,6 @@ defmodule BerrypodWeb.Auth.Login do
|
|||||||
assign(socket,
|
assign(socket,
|
||||||
form: form,
|
form: form,
|
||||||
trigger_submit: false,
|
trigger_submit: false,
|
||||||
registration_open: !Accounts.has_admin?(),
|
|
||||||
email_configured: Mailer.email_verified?()
|
email_configured: Mailer.email_verified?()
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|||||||
@ -48,15 +48,11 @@ defmodule BerrypodWeb.Auth.Registration do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
if Accounts.has_admin?() do
|
# Admin exists (hook handles no-admin), registration is single-user only
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> put_flash(:error, "Registration is closed")
|
|> put_flash(:error, "Registration is closed")
|
||||||
|> redirect(to: ~p"/users/log-in")}
|
|> redirect(to: ~p"/users/log-in")}
|
||||||
else
|
|
||||||
# Fresh install — account creation happens on the setup page
|
|
||||||
{:ok, redirect(socket, to: ~p"/setup")}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|||||||
@ -54,7 +54,10 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
|||||||
|> assign(:secret_verified, false)
|
|> assign(:secret_verified, false)
|
||||||
|> assign(:secret_form, to_form(%{"secret" => ""}, as: :secret))
|
|> assign(:secret_form, to_form(%{"secret" => ""}, as: :secret))
|
||||||
# Account (card 1)
|
# 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)
|
# Provider (card 2)
|
||||||
|> assign(:providers, Provider.all())
|
|> assign(:providers, Provider.all())
|
||||||
|> assign(:selected_provider, nil)
|
|> assign(:selected_provider, nil)
|
||||||
@ -81,16 +84,20 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
|||||||
|
|
||||||
def handle_event("create_account", %{"account" => params}, socket) do
|
def handle_event("create_account", %{"account" => params}, socket) do
|
||||||
email = params["email"]
|
email = params["email"]
|
||||||
|
password = params["password"]
|
||||||
shop_name = String.trim(params["shop_name"] || "")
|
shop_name = String.trim(params["shop_name"] || "")
|
||||||
|
|
||||||
if email == "" do
|
cond do
|
||||||
{:noreply, put_flash(socket, :error, "Please enter your email address")}
|
shop_name == "" ->
|
||||||
else
|
{:noreply, put_flash(socket, :error, "Please enter a shop name")}
|
||||||
if shop_name != "" do
|
|
||||||
Settings.put_setting("site_name", shop_name, "string")
|
|
||||||
end
|
|
||||||
|
|
||||||
case Accounts.register_and_confirm_admin(%{email: email}) do
|
email == "" ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Please enter your email address")}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
Settings.put_setting("site_name", shop_name, "string")
|
||||||
|
|
||||||
|
case Accounts.register_and_confirm_admin(%{email: email, password: password}) do
|
||||||
{:ok, user} ->
|
{:ok, user} ->
|
||||||
token = Accounts.generate_login_token(user)
|
token = Accounts.generate_login_token(user)
|
||||||
{:noreply, redirect(socket, to: ~p"/setup/login/#{token}")}
|
{:noreply, redirect(socket, to: ~p"/setup/login/#{token}")}
|
||||||
@ -102,9 +109,11 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
|||||||
|> push_navigate(to: ~p"/setup")}
|
|> push_navigate(to: ~p"/setup")}
|
||||||
|
|
||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
|
form = to_form(params, as: :account, errors: changeset.errors, action: :validate)
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:account_form, to_form(changeset, as: :account))
|
|> assign(:account_form, form)
|
||||||
|> put_flash(:error, "Could not create account")}
|
|> put_flash(:error, "Could not create account")}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -255,6 +264,7 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
|||||||
label="Shop name"
|
label="Shop name"
|
||||||
placeholder="e.g. Acme Prints"
|
placeholder="e.g. Acme Prints"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
required
|
||||||
phx-mounted={@current_step == 1 && JS.focus()}
|
phx-mounted={@current_step == 1 && JS.focus()}
|
||||||
/>
|
/>
|
||||||
<.input
|
<.input
|
||||||
@ -264,6 +274,14 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
|||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<.input
|
||||||
|
field={@account_form[:password]}
|
||||||
|
type="password"
|
||||||
|
label="Password"
|
||||||
|
placeholder="12 characters minimum"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<div class="setup-actions">
|
<div class="setup-actions">
|
||||||
<.button phx-disable-with="Creating account...">Create account</.button>
|
<.button phx-disable-with="Creating account...">Create account</.button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -192,7 +192,10 @@ defmodule BerrypodWeb.Router do
|
|||||||
pipe_through [:browser]
|
pipe_through [:browser]
|
||||||
|
|
||||||
live_session :current_user,
|
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/register", Auth.Registration, :new
|
||||||
live "/users/log-in", Auth.Login, :new
|
live "/users/log-in", Auth.Login, :new
|
||||||
live "/users/log-in/:token", Auth.Confirmation, :new
|
live "/users/log-in/:token", Auth.Confirmation, :new
|
||||||
@ -239,6 +242,7 @@ defmodule BerrypodWeb.Router do
|
|||||||
live_session :coming_soon,
|
live_session :coming_soon,
|
||||||
layout: {BerrypodWeb.Layouts, :shop},
|
layout: {BerrypodWeb.Layouts, :shop},
|
||||||
on_mount: [
|
on_mount: [
|
||||||
|
{BerrypodWeb.SetupHook, :require_admin},
|
||||||
{BerrypodWeb.ThemeHook, :mount_theme}
|
{BerrypodWeb.ThemeHook, :mount_theme}
|
||||||
] do
|
] do
|
||||||
live "/coming-soon", Shop.ComingSoon, :index
|
live "/coming-soon", Shop.ComingSoon, :index
|
||||||
@ -247,6 +251,7 @@ defmodule BerrypodWeb.Router do
|
|||||||
live_session :public_shop,
|
live_session :public_shop,
|
||||||
layout: {BerrypodWeb.Layouts, :shop},
|
layout: {BerrypodWeb.Layouts, :shop},
|
||||||
on_mount: [
|
on_mount: [
|
||||||
|
{BerrypodWeb.SetupHook, :require_admin},
|
||||||
{BerrypodWeb.UserAuth, :mount_current_scope},
|
{BerrypodWeb.UserAuth, :mount_current_scope},
|
||||||
{BerrypodWeb.ThemeHook, :mount_theme},
|
{BerrypodWeb.ThemeHook, :mount_theme},
|
||||||
{BerrypodWeb.ThemeHook, :require_site_live},
|
{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 ->
|
socket.assigns[:current_scope] && socket.assigns.current_scope.user ->
|
||||||
{:cont, socket}
|
{:cont, socket}
|
||||||
|
|
||||||
not Berrypod.Accounts.has_admin?() ->
|
|
||||||
# Fresh install — send to setup
|
|
||||||
{:halt, Phoenix.LiveView.redirect(socket, to: "/setup")}
|
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
{:halt, Phoenix.LiveView.redirect(socket, to: "/coming-soon")}
|
{:halt, Phoenix.LiveView.redirect(socket, to: "/coming-soon")}
|
||||||
end
|
end
|
||||||
|
|||||||
@ -65,24 +65,49 @@ defmodule Berrypod.AccountsTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe "register_and_confirm_admin/1" do
|
describe "register_and_confirm_admin/1" do
|
||||||
test "creates a confirmed admin user" do
|
test "creates a confirmed admin user with password" do
|
||||||
email = unique_user_email()
|
email = unique_user_email()
|
||||||
assert {:ok, user} = Accounts.register_and_confirm_admin(%{email: email})
|
|
||||||
|
assert {:ok, user} =
|
||||||
|
Accounts.register_and_confirm_admin(%{
|
||||||
|
email: email,
|
||||||
|
password: "valid_password_123"
|
||||||
|
})
|
||||||
|
|
||||||
assert user.email == email
|
assert user.email == email
|
||||||
assert user.confirmed_at
|
assert user.confirmed_at
|
||||||
|
assert user.hashed_password
|
||||||
end
|
end
|
||||||
|
|
||||||
test "fails if admin already exists" do
|
test "fails if admin already exists" do
|
||||||
user_fixture()
|
user_fixture()
|
||||||
|
|
||||||
assert {:error, :admin_already_exists} =
|
assert {:error, :admin_already_exists} =
|
||||||
Accounts.register_and_confirm_admin(%{email: unique_user_email()})
|
Accounts.register_and_confirm_admin(%{
|
||||||
|
email: unique_user_email(),
|
||||||
|
password: "valid_password_123"
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
test "validates email" do
|
test "validates email" do
|
||||||
assert {:error, changeset} = Accounts.register_and_confirm_admin(%{email: "bad"})
|
assert {:error, changeset} =
|
||||||
|
Accounts.register_and_confirm_admin(%{
|
||||||
|
email: "bad",
|
||||||
|
password: "valid_password_123"
|
||||||
|
})
|
||||||
|
|
||||||
assert errors_on(changeset).email
|
assert errors_on(changeset).email
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "validates password" do
|
||||||
|
assert {:error, changeset} =
|
||||||
|
Accounts.register_and_confirm_admin(%{
|
||||||
|
email: unique_user_email(),
|
||||||
|
password: "short"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert errors_on(changeset).password
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "generate_login_token/1" do
|
describe "generate_login_token/1" do
|
||||||
|
|||||||
@ -99,11 +99,10 @@ defmodule BerrypodWeb.Admin.DashboardTest do
|
|||||||
assert html =~ "4242 4242 4242 4242"
|
assert html =~ "4242 4242 4242 4242"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows email setup as optional", %{conn: conn} do
|
test "shows email setup item", %{conn: conn} do
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin")
|
{:ok, _view, html} = live(conn, ~p"/admin")
|
||||||
|
|
||||||
assert html =~ "Set up email"
|
assert html =~ "Set up email"
|
||||||
assert html =~ "optional"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -29,7 +29,7 @@ defmodule BerrypodWeb.Admin.ThemeTest do
|
|||||||
test "renders theme editor page", %{conn: conn} do
|
test "renders theme editor page", %{conn: conn} do
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin/theme")
|
{:ok, _view, html} = live(conn, ~p"/admin/theme")
|
||||||
|
|
||||||
assert html =~ "Theme Studio"
|
assert html =~ "<h1 class=\"theme-title\">Theme</h1>"
|
||||||
assert html =~ "preset"
|
assert html =~ "preset"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ defmodule BerrypodWeb.Auth.LoginTest do
|
|||||||
|
|
||||||
describe "login page" do
|
describe "login page" do
|
||||||
setup do
|
setup do
|
||||||
|
user_fixture()
|
||||||
Mailer.mark_email_verified()
|
Mailer.mark_email_verified()
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
@ -16,7 +17,6 @@ defmodule BerrypodWeb.Auth.LoginTest do
|
|||||||
{:ok, _lv, html} = live(conn, ~p"/users/log-in")
|
{:ok, _lv, html} = live(conn, ~p"/users/log-in")
|
||||||
|
|
||||||
assert html =~ "Log in"
|
assert html =~ "Log in"
|
||||||
assert html =~ "Set up your shop"
|
|
||||||
assert html =~ "Log in with email"
|
assert html =~ "Log in with email"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -44,6 +44,8 @@ defmodule BerrypodWeb.Auth.LoginTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "does not disclose if user is registered", %{conn: conn} do
|
test "does not disclose if user is registered", %{conn: conn} do
|
||||||
|
user_fixture()
|
||||||
|
|
||||||
{:ok, lv, _html} = live(conn, ~p"/users/log-in")
|
{:ok, lv, _html} = live(conn, ~p"/users/log-in")
|
||||||
|
|
||||||
{:ok, _lv, html} =
|
{:ok, _lv, html} =
|
||||||
@ -74,6 +76,8 @@ defmodule BerrypodWeb.Auth.LoginTest do
|
|||||||
test "redirects to login page with a flash error if credentials are invalid", %{
|
test "redirects to login page with a flash error if credentials are invalid", %{
|
||||||
conn: conn
|
conn: conn
|
||||||
} do
|
} do
|
||||||
|
user_fixture()
|
||||||
|
|
||||||
{:ok, lv, _html} = live(conn, ~p"/users/log-in")
|
{:ok, lv, _html} = live(conn, ~p"/users/log-in")
|
||||||
|
|
||||||
form =
|
form =
|
||||||
@ -107,6 +111,9 @@ defmodule BerrypodWeb.Auth.LoginTest do
|
|||||||
|
|
||||||
describe "email configured and verified" do
|
describe "email configured and verified" do
|
||||||
setup do
|
setup do
|
||||||
|
# Create user before switching adapter (fixture sends a confirmation email)
|
||||||
|
_user = user_fixture()
|
||||||
|
|
||||||
original = Application.get_env(:berrypod, Berrypod.Mailer)
|
original = Application.get_env(:berrypod, Berrypod.Mailer)
|
||||||
|
|
||||||
Application.put_env(:berrypod, Berrypod.Mailer,
|
Application.put_env(:berrypod, Berrypod.Mailer,
|
||||||
@ -154,17 +161,9 @@ defmodule BerrypodWeb.Auth.LoginTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "login navigation" do
|
describe "no admin exists" do
|
||||||
test "redirects to setup page when the setup link is clicked", %{conn: conn} do
|
test "redirects to setup", %{conn: conn} do
|
||||||
{:ok, lv, _html} = live(conn, ~p"/users/log-in")
|
assert {:error, {:redirect, %{to: "/setup"}}} = live(conn, ~p"/users/log-in")
|
||||||
|
|
||||||
{:ok, _setup_live, setup_html} =
|
|
||||||
lv
|
|
||||||
|> element("main a", "Set up your shop")
|
|
||||||
|> render_click()
|
|
||||||
|> follow_redirect(conn, ~p"/setup")
|
|
||||||
|
|
||||||
assert setup_html =~ "Set up your shop"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -69,7 +69,13 @@ defmodule BerrypodWeb.Setup.OnboardingTest do
|
|||||||
{:ok, view, _html} = live(conn, ~p"/setup")
|
{:ok, view, _html} = live(conn, ~p"/setup")
|
||||||
|
|
||||||
view
|
view
|
||||||
|> form(~s(form[phx-submit="create_account"]), account: %{email: "admin@example.com"})
|
|> form(~s(form[phx-submit="create_account"]),
|
||||||
|
account: %{
|
||||||
|
shop_name: "Test Shop",
|
||||||
|
email: "admin@example.com",
|
||||||
|
password: "valid_password_123"
|
||||||
|
}
|
||||||
|
)
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
# The LiveView redirects to /setup/login/:token
|
# The LiveView redirects to /setup/login/:token
|
||||||
|
|||||||
@ -17,6 +17,7 @@ defmodule BerrypodWeb.Shop.ComingSoonTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "displays the shop name", %{conn: conn} do
|
test "displays the shop name", %{conn: conn} do
|
||||||
|
user_fixture()
|
||||||
Settings.put_setting("site_name", "My Test Shop", "string")
|
Settings.put_setting("site_name", "My Test Shop", "string")
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/coming-soon")
|
{:ok, _view, html} = live(conn, ~p"/coming-soon")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user