add admin email settings page with provider selection
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:
jamey
2026-02-21 19:29:34 +00:00
parent a2e46664c6
commit 366a1e6a48
17 changed files with 1176 additions and 39 deletions

View File

@@ -16,6 +16,8 @@ defmodule Berrypod.Application do
Supervisor.child_spec({Task, &Berrypod.Release.seed_defaults/0}, id: :seed_defaults),
# Load encrypted secrets from DB into Application env
{Task, &Berrypod.Secrets.load_all/0},
# Load email adapter config from DB (after secrets are available)
Supervisor.child_spec({Task, &Berrypod.Mailer.load_config/0}, id: :load_email_config),
{DNSCluster, query: Application.get_env(:berrypod, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Berrypod.PubSub},
# Background job processing

View File

@@ -1,6 +1,9 @@
defmodule Berrypod.Mailer do
use Swoosh.Mailer, otp_app: :berrypod
alias Berrypod.Mailer.Adapters
alias Berrypod.Settings
@doc """
Returns whether a real email adapter is configured.
@@ -11,4 +14,137 @@ defmodule Berrypod.Mailer do
adapter = Application.get_env(:berrypod, __MODULE__)[:adapter]
adapter != nil and adapter != Swoosh.Adapters.Local
end
@doc """
Returns true if email is configured via environment variables (SMTP_HOST).
When env vars are active, the admin UI shows the config as read-only.
"""
def env_var_configured? do
System.get_env("SMTP_HOST") != nil
end
@doc """
Returns the current adapter key and config for display in the admin UI.
Returns `{adapter_key, config_map}` or `{nil, %{}}` if using the default.
"""
def current_config do
mailer_config = Application.get_env(:berrypod, __MODULE__, [])
adapter = mailer_config[:adapter]
case Enum.find(Adapters.all(), &(&1.module == adapter)) do
nil ->
{nil, %{}}
adapter_info ->
config =
for field <- adapter_info.fields, into: %{} do
value =
case field.type do
:secret -> Settings.secret_hint("email_#{field.key}")
_ -> Settings.get_setting("email_#{field.key}")
end
{field.key, value}
end
{adapter_info.key, config}
end
end
@doc """
Loads email config from the Settings table and applies it to Application env.
Env vars take precedence — if SMTP_HOST is set, this is a no-op since
runtime.exs already configured the adapter.
Called on boot (from Application.start) and after admin saves email settings.
"""
def load_config do
if env_var_configured?() do
:ok
else
case Settings.get_setting("email_adapter") do
nil ->
:ok
adapter_key ->
case Adapters.get(adapter_key) do
nil ->
:ok
adapter_info ->
config = build_config(adapter_info)
Application.put_env(:berrypod, __MODULE__, config)
# API-based adapters need a real HTTP client (dev defaults to false)
if adapter_info.module != Swoosh.Adapters.SMTP do
Application.put_env(:swoosh, :api_client, Swoosh.ApiClient.Req)
end
:ok
end
end
end
end
@doc """
Sends a test email to the given address using the current config.
"""
def send_test_email(to_address, from \\ nil) do
import Swoosh.Email
email =
new()
|> to(to_address)
|> from({"Berrypod", from || from_address()})
|> subject("Berrypod test email")
|> text_body("This is a test email from your Berrypod shop. Email delivery is working.")
deliver(email)
end
@doc "Returns the configured from address for outbound email."
def from_address do
Settings.get_setting("email_from_address") || "noreply@#{default_from_domain()}"
end
# Build Swoosh config keyword list from Settings for a given adapter
defp build_config(adapter_info) do
opts =
for field <- adapter_info.fields, reduce: [] do
acc ->
settings_key = "email_#{field.key}"
value =
case field.type do
:secret -> Settings.get_secret(settings_key)
_ -> Settings.get_setting(settings_key)
end
case {value, field} do
{nil, _} ->
acc
{val, %{type: :integer}} ->
[{String.to_atom(field.key), to_integer(val)} | acc]
{val, _} ->
[{String.to_atom(field.key), val} | acc]
end
end
# SMTP uses :relay, others use the native Swoosh key names
[{:adapter, adapter_info.module} | opts]
end
defp to_integer(val) when is_integer(val), do: val
defp to_integer(val) when is_binary(val), do: String.to_integer(val)
defp default_from_domain do
case Application.get_env(:berrypod, BerrypodWeb.Endpoint)[:url][:host] do
nil -> "example.com"
host -> host
end
end
end

View File

@@ -0,0 +1,5 @@
defmodule Berrypod.Mailer.Adapter do
@moduledoc false
@enforce_keys [:key, :name, :module, :description, :tags, :fields]
defstruct [:key, :name, :module, :description, :tags, :fields, url: nil]
end

View File

@@ -0,0 +1,140 @@
defmodule Berrypod.Mailer.Adapters do
@moduledoc """
Registry of supported email adapters and their config shapes.
Each adapter entry defines the fields needed for configuration.
The UI renders dynamically from this registry. Adding a new adapter
is just adding an entry here — no other code changes needed.
"""
alias Berrypod.Mailer.{Adapter, Field}
# Ordered by capability: transactional + marketing first,
# then transactional only, then self-hosted.
@adapters [
%Adapter{
key: "smtp",
name: "SMTP",
module: Swoosh.Adapters.SMTP,
description: "Connect to any email server via SMTP. Works with most providers and hosts.",
tags: ["All email", "Any provider"],
fields: [
%Field{key: "relay", label: "Server host", type: :string, required: true},
%Field{key: "port", label: "Port", type: :integer, default: 587},
%Field{key: "username", label: "Username", type: :string},
%Field{key: "password", label: "Password", type: :secret}
]
},
%Adapter{
key: "sendgrid",
name: "SendGrid",
module: Swoosh.Adapters.Sendgrid,
description: "Generous free tier, widely used.",
tags: ["All email", "US"],
url: "https://sendgrid.com",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true}
]
},
%Adapter{
key: "brevo",
name: "Brevo",
module: Swoosh.Adapters.Brevo,
description: "All-in-one platform, GDPR-friendly.",
tags: ["All email", "France", "GDPR"],
url: "https://www.brevo.com",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true}
]
},
%Adapter{
key: "mailjet",
name: "Mailjet",
module: Swoosh.Adapters.Mailjet,
description: "EU data processing, good free tier.",
tags: ["All email", "France", "GDPR"],
url: "https://www.mailjet.com",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true},
%Field{key: "secret", label: "Secret key", type: :secret, required: true}
]
},
%Adapter{
key: "resend",
name: "Resend",
module: Swoosh.Adapters.Resend,
description: "Developer-friendly API, simple setup.",
tags: ["Transactional", "US"],
url: "https://resend.com",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true}
]
},
%Adapter{
key: "mailgun",
name: "Mailgun",
module: Swoosh.Adapters.Mailgun,
description: "EU region option available.",
tags: ["Transactional", "EU option", "Sweden"],
url: "https://www.mailgun.com",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true},
%Field{key: "domain", label: "Domain", type: :string, required: true}
]
},
%Adapter{
key: "postmark",
name: "Postmark",
module: Swoosh.Adapters.Postmark,
description: "Excellent deliverability tracking.",
tags: ["Transactional", "US"],
url: "https://postmarkapp.com",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true}
]
},
%Adapter{
key: "mailpace",
name: "MailPace",
module: Swoosh.Adapters.MailPace,
description: "Privacy-focused, simple API.",
tags: ["Transactional", "UK"],
url: "https://mailpace.com",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true}
]
},
%Adapter{
key: "postal",
name: "Postal",
module: Swoosh.Adapters.Postal,
description: "Full control over your email infrastructure.",
tags: ["All email", "Self-hosted", "Open source"],
url: "https://docs.postalserver.io",
fields: [
%Field{key: "api_key", label: "API key", type: :secret, required: true},
%Field{key: "base_url", label: "Server URL", type: :string, required: true}
]
}
]
@doc "Returns all supported adapters."
def all, do: @adapters
@doc "Returns an adapter by its string key, or nil."
def get(key) when is_binary(key) do
Enum.find(@adapters, &(&1.key == key))
end
@doc "Returns the settings keys for an adapter's fields (prefixed with `email_`)."
def field_keys(%{fields: fields}) do
Enum.map(fields, &"email_#{&1.key}")
end
@doc "Returns all possible settings keys across all adapters."
def all_field_keys do
@adapters
|> Enum.flat_map(&field_keys/1)
|> Enum.uniq()
end
end

View File

@@ -0,0 +1,5 @@
defmodule Berrypod.Mailer.Field do
@moduledoc false
@enforce_keys [:key, :label, :type]
defstruct [:key, :label, :type, :default, required: false]
end

View File

@@ -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} &nearr;
</a>
"""
end
def show_modal(js \\ %JS{}, id) when is_binary(id) do
js
|> JS.exec("showModal()", to: "##{id}")

View File

@@ -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

View File

@@ -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>

View 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"
>
&nearr;
</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

View File

@@ -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"

View File

@@ -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)