add admin email settings page with provider selection
All checks were successful
deploy / deploy (push) Successful in 56s
All checks were successful
deploy / deploy (push) Successful in 56s
Card radio component for picking email providers (SMTP, SendGrid, Mailjet, etc.) with instant client-side switching via JS hook. Adapter configs are pre-rendered and toggled without a server round-trip. Secrets are preserved when re-saving with blank password fields. Includes from address field, test email sending, and disconnect flow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -519,6 +519,99 @@ defmodule BerrypodWeb.CoreComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a radio card group — a set of selectable cards backed by radio inputs.
|
||||
|
||||
Each option is a map with `:value` and `:name`, plus optional `:description`,
|
||||
`:tags`, `:url`, `:badge`, and `:disabled` keys.
|
||||
|
||||
The `display` attr controls card content layout:
|
||||
- `:tags` (default) — name + tag pills + short description
|
||||
- `:description` — name + description text + link
|
||||
|
||||
## Examples
|
||||
|
||||
<.card_radio_group
|
||||
name="email[adapter]"
|
||||
value={@selected}
|
||||
legend="Email provider"
|
||||
options={[
|
||||
%{value: "postmark", name: "Postmark", description: "Fast email.", tags: ["Transactional", "US"]},
|
||||
%{value: "smtp", name: "SMTP", description: "Any SMTP server.", tags: ["Any type"]}
|
||||
]}
|
||||
/>
|
||||
"""
|
||||
attr :name, :string, required: true
|
||||
attr :value, :string, default: nil
|
||||
attr :legend, :string, required: true
|
||||
attr :options, :list, required: true
|
||||
attr :disabled, :boolean, default: false
|
||||
attr :display, :atom, default: :tags, values: [:description, :tags]
|
||||
|
||||
def card_radio_group(assigns) do
|
||||
~H"""
|
||||
<fieldset class="card-radio-fieldset" disabled={@disabled}>
|
||||
<legend class="admin-label">{@legend}</legend>
|
||||
<div class="card-radio-grid">
|
||||
<label
|
||||
:for={option <- @options}
|
||||
class={[
|
||||
"card-radio-card",
|
||||
@value == option.value && "card-radio-card-selected",
|
||||
option[:disabled] && "card-radio-card-disabled"
|
||||
]}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id={"#{@name}-#{option.value}"}
|
||||
name={@name}
|
||||
value={option.value}
|
||||
checked={@value == option.value}
|
||||
disabled={option[:disabled] || @disabled}
|
||||
class="card-radio-input"
|
||||
/>
|
||||
<span class="card-radio-name">{option.name}</span>
|
||||
<.card_radio_content option={option} display={@display} />
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :option, :map, required: true
|
||||
attr :display, :atom, required: true
|
||||
|
||||
defp card_radio_content(%{display: :tags} = assigns) do
|
||||
~H"""
|
||||
<span :if={@option[:tags]} class="card-radio-tags">
|
||||
<span :for={tag <- @option.tags} class="card-radio-tag">{tag}</span>
|
||||
</span>
|
||||
<span :if={@option[:description]} class="card-radio-description">
|
||||
{@option.description}
|
||||
</span>
|
||||
<span :if={@option[:badge]} class="card-radio-badge">{@option.badge}</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp card_radio_content(assigns) do
|
||||
~H"""
|
||||
<span :if={@option[:description]} class="card-radio-description">
|
||||
{@option.description}
|
||||
</span>
|
||||
<span :if={@option[:badge]} class="card-radio-badge">{@option.badge}</span>
|
||||
<a
|
||||
:if={@option[:url]}
|
||||
href={@option.url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="card-radio-link"
|
||||
onclick="event.stopPropagation();"
|
||||
>
|
||||
{@option.name} ↗
|
||||
</a>
|
||||
"""
|
||||
end
|
||||
|
||||
def show_modal(js \\ %JS{}, id) when is_binary(id) do
|
||||
js
|
||||
|> JS.exec("showModal()", to: "##{id}")
|
||||
|
||||
@@ -50,6 +50,10 @@ defmodule BerrypodWeb.Layouts do
|
||||
if current_path == "/admin", do: "active", else: nil
|
||||
end
|
||||
|
||||
def admin_nav_active?(current_path, "/admin/settings") do
|
||||
if current_path == "/admin/settings", do: "active", else: nil
|
||||
end
|
||||
|
||||
def admin_nav_active?(current_path, link_path) do
|
||||
if String.starts_with?(current_path, link_path), do: "active", else: nil
|
||||
end
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
<.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="underline font-medium">
|
||||
Configure email
|
||||
</.link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -99,6 +102,14 @@
|
||||
<.icon name="hero-cog-6-tooth" class="size-5" /> Settings
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/admin/settings/email"}
|
||||
class={admin_nav_active?(@current_path, "/admin/settings/email")}
|
||||
>
|
||||
<.icon name="hero-envelope" class="size-5" /> Email
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
|
||||
355
lib/berrypod_web/live/admin/email_settings.ex
Normal file
355
lib/berrypod_web/live/admin/email_settings.ex
Normal file
@@ -0,0 +1,355 @@
|
||||
defmodule BerrypodWeb.Admin.EmailSettings do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Mailer
|
||||
alias Berrypod.Mailer.Adapters
|
||||
alias Berrypod.Settings
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
env_locked = Mailer.env_var_configured?()
|
||||
{current_adapter, current_values} = Mailer.current_config()
|
||||
saved_adapter = Settings.get_setting("email_adapter")
|
||||
|
||||
adapter_key = current_adapter || saved_adapter
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Email settings")
|
||||
|> assign(:env_locked, env_locked)
|
||||
|> assign(:adapter_key, adapter_key)
|
||||
|> assign(:current_values, current_values)
|
||||
|> assign(:all_adapters, Adapters.all())
|
||||
|> assign(:provider_options, provider_options())
|
||||
|> assign(:email_configured, Mailer.email_configured?())
|
||||
|> assign(
|
||||
:from_address,
|
||||
Settings.get_setting("email_from_address") || socket.assigns.current_scope.user.email
|
||||
)
|
||||
|> assign(:sending_test, false)
|
||||
|> assign(:form, to_form(%{}, as: :email))}
|
||||
end
|
||||
|
||||
defp provider_options do
|
||||
Enum.map(Adapters.all(), fn adapter ->
|
||||
%{
|
||||
value: adapter.key,
|
||||
name: adapter.name,
|
||||
description: adapter.description,
|
||||
tags: adapter.tags,
|
||||
url: adapter.url
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("change_adapter", %{"email" => %{"adapter" => key}}, socket) do
|
||||
{:noreply, assign(socket, :adapter_key, key)}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"email" => params}, socket) do
|
||||
if socket.assigns.env_locked do
|
||||
{:noreply, put_flash(socket, :error, "Email config is controlled by environment variables")}
|
||||
else
|
||||
adapter_key = params["adapter"]
|
||||
adapter_info = Adapters.get(adapter_key)
|
||||
|
||||
if adapter_info do
|
||||
save_adapter_config(socket, adapter_info, params)
|
||||
else
|
||||
{:noreply, put_flash(socket, :error, "Please select an email provider")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("disconnect", _params, socket) do
|
||||
if socket.assigns.env_locked do
|
||||
{:noreply, put_flash(socket, :error, "Email config is controlled by environment variables")}
|
||||
else
|
||||
# Clear all email settings
|
||||
Settings.delete_setting("email_adapter")
|
||||
|
||||
for key <- Adapters.all_field_keys() do
|
||||
Settings.delete_setting(key)
|
||||
end
|
||||
|
||||
# Reset to Local adapter
|
||||
Application.put_env(:berrypod, Mailer, adapter: Swoosh.Adapters.Local)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:adapter_key, nil)
|
||||
|> assign(:current_values, %{})
|
||||
|> assign(:email_configured, false)
|
||||
|> put_flash(:info, "Email provider disconnected")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("send_test", _params, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
socket = assign(socket, :sending_test, true)
|
||||
|
||||
case Mailer.send_test_email(user.email, socket.assigns.from_address) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:sending_test, false)
|
||||
|> put_flash(:info, "Test email sent to #{user.email}")}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:sending_test, false)
|
||||
|> put_flash(:error, "Failed to send test email: #{inspect(reason)}")}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_adapter_config(socket, adapter_info, params) do
|
||||
# Validate required fields
|
||||
missing =
|
||||
adapter_info.fields
|
||||
|> Enum.filter(& &1.required)
|
||||
|> Enum.filter(fn field ->
|
||||
val = params[field.key]
|
||||
empty = is_nil(val) or val == ""
|
||||
# Secret fields can be left blank to keep existing value
|
||||
empty and not (field.type == :secret and Settings.get_secret("email_#{field.key}") != nil)
|
||||
end)
|
||||
|
||||
if missing != [] do
|
||||
labels = Enum.map_join(missing, ", ", & &1.label)
|
||||
{:noreply, put_flash(socket, :error, "Missing required fields: #{labels}")}
|
||||
else
|
||||
# Save adapter type
|
||||
Settings.put_setting("email_adapter", adapter_info.key)
|
||||
|
||||
# Clear fields from other adapters
|
||||
current_keys = MapSet.new(Enum.map(adapter_info.fields, &"email_#{&1.key}"))
|
||||
|
||||
for key <- Adapters.all_field_keys(), key not in current_keys do
|
||||
Settings.delete_setting(key)
|
||||
end
|
||||
|
||||
# Save current adapter fields (blank secrets keep existing value)
|
||||
for field <- adapter_info.fields do
|
||||
value = params[field.key]
|
||||
settings_key = "email_#{field.key}"
|
||||
|
||||
cond do
|
||||
value && value != "" ->
|
||||
case field.type do
|
||||
:secret -> Settings.put_secret(settings_key, value)
|
||||
:integer -> Settings.put_setting(settings_key, String.to_integer(value), "integer")
|
||||
_ -> Settings.put_setting(settings_key, value)
|
||||
end
|
||||
|
||||
field.type == :secret ->
|
||||
:keep
|
||||
|
||||
true ->
|
||||
Settings.delete_setting(settings_key)
|
||||
end
|
||||
end
|
||||
|
||||
# Save from address
|
||||
from_address = params["from_address"] || ""
|
||||
|
||||
if from_address != "" do
|
||||
Settings.put_setting("email_from_address", from_address)
|
||||
end
|
||||
|
||||
# Apply config immediately
|
||||
Mailer.load_config()
|
||||
|
||||
# Re-read current state
|
||||
{current_adapter, current_values} = Mailer.current_config()
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:adapter_key, current_adapter)
|
||||
|> assign(:current_values, current_values)
|
||||
|> assign(:from_address, from_address)
|
||||
|> assign(:email_configured, Mailer.email_configured?())
|
||||
|> put_flash(:info, "Email settings saved")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-2xl">
|
||||
<.header>
|
||||
Email settings
|
||||
<:subtitle>
|
||||
Configure how your shop sends email. <strong>Transactional</strong>
|
||||
providers only handle order confirmations and password resets. <strong>All email</strong>
|
||||
providers also support newsletters and marketing campaigns.
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<%= if @env_locked do %>
|
||||
<div class="mt-6 rounded-md bg-amber-50 p-4 ring-1 ring-amber-600/10 ring-inset">
|
||||
<div class="flex gap-3">
|
||||
<.icon name="hero-lock-closed" class="size-5 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-amber-800">
|
||||
Controlled by environment variables
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-amber-700">
|
||||
Email is configured via <code>SMTP_HOST</code> and related env vars.
|
||||
Remove them to configure email from this page instead.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<section class="mt-8">
|
||||
<.form for={@form} phx-change="change_adapter" phx-submit="save">
|
||||
<div id="email-provider-cards" phx-hook="CardRadioScroll">
|
||||
<.card_radio_group
|
||||
name="email[adapter]"
|
||||
value={@adapter_key}
|
||||
legend="Email provider"
|
||||
options={@provider_options}
|
||||
disabled={@env_locked}
|
||||
display={:tags}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%= for adapter <- @all_adapters do %>
|
||||
<% selected = @adapter_key == adapter.key %>
|
||||
<div
|
||||
id={"adapter-config-#{adapter.key}"}
|
||||
class="mt-6 space-y-4"
|
||||
hidden={!selected}
|
||||
data-adapter={adapter.key}
|
||||
>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold">
|
||||
{adapter.name}
|
||||
<a
|
||||
:if={adapter.url}
|
||||
href={adapter.url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-sm font-normal text-base-content/50 hover:text-base-content/80"
|
||||
>
|
||||
↗
|
||||
</a>
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/60">{adapter.description}</p>
|
||||
</div>
|
||||
<.input
|
||||
name="email[from_address]"
|
||||
value={@from_address}
|
||||
type="email"
|
||||
label="From address"
|
||||
placeholder="noreply@yourshop.com"
|
||||
disabled={!selected || @env_locked}
|
||||
/>
|
||||
<%= for field <- adapter.fields do %>
|
||||
<.adapter_field_static
|
||||
field_def={field}
|
||||
value={if selected, do: @current_values[field.key]}
|
||||
disabled={!selected || @env_locked}
|
||||
/>
|
||||
<% end %>
|
||||
<%= unless @env_locked do %>
|
||||
<div class="flex items-center gap-3">
|
||||
<.button phx-disable-with="Saving..." disabled={!selected}>
|
||||
Save settings
|
||||
</.button>
|
||||
<%= if selected && @email_configured do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="disconnect"
|
||||
data-confirm="Remove email configuration? Transactional emails will stop being sent."
|
||||
class="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</.form>
|
||||
</section>
|
||||
|
||||
<%= if @email_configured do %>
|
||||
<section class="mt-8 border-t border-base-200 pt-6">
|
||||
<h2 class="text-lg font-semibold">Test email</h2>
|
||||
<p class="mt-1 text-sm text-base-content/60">
|
||||
Send a test email to <strong>{@current_scope.user.email}</strong>
|
||||
to verify delivery works.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<button
|
||||
phx-click="send_test"
|
||||
disabled={@sending_test}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-base-200 px-3 py-2 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset"
|
||||
>
|
||||
<.icon name="hero-paper-airplane" class="size-4" />
|
||||
{if @sending_test, do: "Sending...", else: "Send test email"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :field_def, :map, required: true
|
||||
attr :value, :any, default: nil
|
||||
attr :disabled, :boolean, default: false
|
||||
|
||||
defp adapter_field_static(%{field_def: %{type: :secret}} = assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.input
|
||||
name={"email[#{@field_def.key}]"}
|
||||
value=""
|
||||
type="password"
|
||||
label={@field_def.label}
|
||||
autocomplete="off"
|
||||
placeholder={if @value, do: @value, else: ""}
|
||||
required={@field_def.required && !@value}
|
||||
disabled={@disabled}
|
||||
/>
|
||||
<%= if @value && !@disabled do %>
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
Current: <code>{@value}</code> — leave blank to keep existing value
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp adapter_field_static(%{field_def: %{type: :integer}} = assigns) do
|
||||
~H"""
|
||||
<.input
|
||||
name={"email[#{@field_def.key}]"}
|
||||
value={@value || @field_def.default || ""}
|
||||
type="number"
|
||||
label={@field_def.label}
|
||||
required={@field_def.required}
|
||||
disabled={@disabled}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
defp adapter_field_static(assigns) do
|
||||
~H"""
|
||||
<.input
|
||||
name={"email[#{@field_def.key}]"}
|
||||
value={@value || @field_def.default || ""}
|
||||
type="text"
|
||||
label={@field_def.label}
|
||||
required={@field_def.required}
|
||||
disabled={@disabled}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -101,7 +101,7 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
||||
|
||||
# ── Events: Provider ──
|
||||
|
||||
def handle_event("select_provider", %{"type" => type}, socket) do
|
||||
def handle_event("select_provider", %{"provider_select" => %{"type" => type}}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:selected_provider, type)
|
||||
@@ -363,30 +363,20 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
||||
attr :connecting, :boolean, required: true
|
||||
|
||||
defp provider_section(assigns) do
|
||||
assigns = assign(assigns, :provider_options, provider_card_options(assigns.providers))
|
||||
|
||||
~H"""
|
||||
<div>
|
||||
<p class="setup-hint">Choose a print-on-demand provider and connect your API key.</p>
|
||||
|
||||
<div class="setup-provider-grid">
|
||||
<button
|
||||
:for={provider <- @providers}
|
||||
type="button"
|
||||
phx-click={provider.status == :available && "select_provider"}
|
||||
phx-value-type={provider.type}
|
||||
disabled={provider.status == :coming_soon}
|
||||
class={[
|
||||
"setup-provider-card",
|
||||
@selected == provider.type && "setup-provider-card-selected",
|
||||
provider.status == :coming_soon && "setup-provider-card-disabled"
|
||||
]}
|
||||
>
|
||||
<span class="setup-provider-name">{provider.name}</span>
|
||||
<span class="setup-provider-tagline">{provider.tagline}</span>
|
||||
<span :if={provider.status == :coming_soon} class="setup-provider-badge">
|
||||
Coming soon
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<.form for={%{}} as={:provider_select} phx-change="select_provider">
|
||||
<.card_radio_group
|
||||
name="provider_select[type]"
|
||||
value={@selected}
|
||||
legend="Print provider"
|
||||
options={@provider_options}
|
||||
/>
|
||||
</.form>
|
||||
|
||||
<%!-- API key form for selected provider --%>
|
||||
<div :if={@selected} class="setup-provider-form">
|
||||
@@ -548,6 +538,22 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
||||
end
|
||||
end
|
||||
|
||||
defp provider_card_options(providers) do
|
||||
Enum.map(providers, fn provider ->
|
||||
option = %{
|
||||
value: provider.type,
|
||||
name: provider.name,
|
||||
description: provider.tagline
|
||||
}
|
||||
|
||||
if provider.status == :coming_soon do
|
||||
Map.merge(option, %{badge: "Coming soon", disabled: true})
|
||||
else
|
||||
option
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp format_error(:unauthorized), do: "That token doesn't seem to be valid"
|
||||
defp format_error(:timeout), do: "Couldn't reach the provider — try again"
|
||||
defp format_error(:provider_not_implemented), do: "This provider isn't supported yet"
|
||||
|
||||
@@ -176,6 +176,7 @@ defmodule BerrypodWeb.Router do
|
||||
live "/providers/new", Admin.Providers.Form, :new
|
||||
live "/providers/:id/edit", Admin.Providers.Form, :edit
|
||||
live "/settings", Admin.Settings, :index
|
||||
live "/settings/email", Admin.EmailSettings, :index
|
||||
end
|
||||
|
||||
# Theme editor: admin root layout but full-screen (no sidebar)
|
||||
|
||||
Reference in New Issue
Block a user