berrypod/lib/berrypod_web/live/admin/email_settings.ex
jamey ae6cf209aa complete admin CSS refactor: delete utilities.css, add layout primitives
- Delete utilities.css (701 lines / 24 KB of Tailwind utility clones)
- Add layout.css with admin-stack, admin-row, admin-cluster, admin-grid
  primitives and gap variants (sm, md, lg, xl)
- Add transitions.css import and layout.css import to admin.css entry point
- Replace all Tailwind utility classes across 26 admin templates with
  semantic admin-*/theme-*/page-specific CSS classes
- Replace all non-dynamic inline styles with semantic classes
- Add ~100 new semantic classes to components.css (analytics, dashboard,
  order detail, settings, theme editor, generic utilities)
- Fix stray text-error → admin-text-error in media.ex
- Add missing .truncate definition to admin CSS
- Only remaining inline styles are dynamic data values (progress bars,
  chart dimensions) and one JS.toggle target

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:40:21 +00:00

380 lines
12 KiB
Elixir

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 load_adapter_values(adapter_key) do
case Adapters.get(adapter_key) do
nil ->
%{}
adapter_info ->
for field <- adapter_info.fields, into: %{} do
settings_key = Adapters.settings_key(adapter_key, field.key)
value =
case field.type do
:secret -> Settings.secret_hint(settings_key)
_ -> Settings.get_setting(settings_key)
end
{field.key, value}
end
end
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
values = load_adapter_values(key)
{:noreply, socket |> assign(:adapter_key, key) |> assign(:current_values, values)}
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
Mailer.clear_email_verified()
# 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, _} ->
Mailer.mark_email_verified()
{: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 == ""
settings_key = Adapters.settings_key(adapter_info.key, field.key)
# Secret fields can be left blank to keep existing value
empty and not (field.type == :secret and Settings.get_secret(settings_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)
# Save current adapter fields (blank secrets keep existing value)
for field <- adapter_info.fields do
value = params[field.key]
settings_key = Adapters.settings_key(adapter_info.key, 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
# Config changed — require re-verification
Mailer.clear_email_verified()
# 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="admin-content-medium">
<.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="admin-callout-warning">
<div class="admin-callout-warning-body">
<span class="admin-callout-warning-icon">
<.icon name="hero-lock-closed" class="size-5" />
</span>
<div>
<p class="admin-callout-warning-title">
Controlled by environment variables
</p>
<p class="admin-callout-warning-desc">
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="admin-section">
<.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="admin-adapter-config"
hidden={!selected}
data-adapter={adapter.key}
>
<div>
<h3 class="admin-section-subheading">
{adapter.name}
<a
:if={adapter.url}
href={adapter.url}
target="_blank"
rel="noopener"
class="admin-link-subtle admin-adapter-link"
>
&nearr;
</a>
</h3>
<p class="admin-section-desc">{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="admin-row admin-row-lg">
<.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="admin-link-danger"
>
Disconnect
</button>
<% end %>
</div>
<% end %>
</div>
<% end %>
</.form>
</section>
<%= if @email_configured do %>
<section class="admin-section-bordered">
<h2 class="admin-section-heading">Test email</h2>
<p class="admin-help-text">
Send a test email to <strong>{@current_scope.user.email}</strong>
to verify delivery works.
</p>
<div class="admin-section-body">
<button
phx-click="send_test"
disabled={@sending_test}
class="admin-btn admin-btn-outline"
>
<.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="admin-help-text">
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