Some checks failed
deploy / deploy (push) Has been cancelled
Setup wizard no longer requires email delivery. Admin account is auto-confirmed and auto-logged-in via token redirect. Adds setup secret gate for prod (logged on boot), SMTP env var config in runtime.exs, email_configured? helper, and admin warning banner when email isn't set up. Includes plan files for this task and the follow-up email settings UI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
558 lines
17 KiB
Elixir
558 lines
17 KiB
Elixir
defmodule BerrypodWeb.Setup.Onboarding do
|
|
use BerrypodWeb, :live_view
|
|
|
|
alias Berrypod.{Accounts, Products, Settings, Setup}
|
|
alias Berrypod.Products.ProviderConnection
|
|
alias Berrypod.Providers.Provider
|
|
alias Berrypod.Stripe.Setup, as: StripeSetup
|
|
|
|
# ── Mount ──
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
setup = Setup.setup_status()
|
|
|
|
cond do
|
|
setup.site_live ->
|
|
{:ok, push_navigate(socket, to: ~p"/")}
|
|
|
|
setup.setup_complete ->
|
|
{:ok, push_navigate(socket, to: ~p"/admin")}
|
|
|
|
setup.admin_created and is_nil(get_user(socket)) ->
|
|
{:ok, push_navigate(socket, to: ~p"/users/log-in")}
|
|
|
|
true ->
|
|
{:ok, mount_setup(socket, setup)}
|
|
end
|
|
end
|
|
|
|
defp get_user(socket) do
|
|
case socket.assigns do
|
|
%{current_scope: %{user: user}} when not is_nil(user) -> user
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp mount_setup(socket, setup) do
|
|
logged_in? = get_user(socket) != nil
|
|
provider_conn = Products.get_first_provider_connection()
|
|
|
|
socket
|
|
|> assign(:page_title, "Set up your shop")
|
|
|> assign(:setup, setup)
|
|
|> assign(:logged_in?, logged_in?)
|
|
# Secret gate
|
|
|> assign(:require_secret?, Setup.require_setup_secret?())
|
|
|> assign(:secret_verified, false)
|
|
|> assign(:secret_form, to_form(%{"secret" => ""}, as: :secret))
|
|
# Account (card 1)
|
|
|> assign(:account_form, to_form(%{"email" => ""}, as: :account))
|
|
# Provider (card 2)
|
|
|> assign(:providers, Provider.all())
|
|
|> assign(:selected_provider, nil)
|
|
|> assign(:provider_form, to_form(%{"api_key" => ""}, as: :provider))
|
|
|> assign(:provider_testing, false)
|
|
|> assign(:provider_test_result, nil)
|
|
|> assign(:provider_connecting, false)
|
|
|> assign(:provider_conn, provider_conn)
|
|
|> assign(:pending_provider_key, nil)
|
|
# Stripe (card 3)
|
|
|> assign(:stripe_form, to_form(%{"api_key" => ""}, as: :stripe))
|
|
|> assign(:stripe_connecting, false)
|
|
end
|
|
|
|
# ── Events: Secret gate ──
|
|
|
|
@impl true
|
|
def handle_event("verify_secret", %{"secret" => %{"secret" => secret}}, socket) do
|
|
if Plug.Crypto.secure_compare(secret, Setup.setup_secret()) do
|
|
{:noreply, assign(socket, secret_verified: true)}
|
|
else
|
|
{:noreply, put_flash(socket, :error, "Wrong setup secret")}
|
|
end
|
|
end
|
|
|
|
# ── Events: Account ──
|
|
|
|
def handle_event("create_account", %{"account" => %{"email" => email}}, socket) do
|
|
if email == "" do
|
|
{:noreply, put_flash(socket, :error, "Please enter your email address")}
|
|
else
|
|
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}")}
|
|
|
|
{: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
|
|
end
|
|
end
|
|
|
|
# ── Events: Provider ──
|
|
|
|
def handle_event("select_provider", %{"type" => type}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:selected_provider, type)
|
|
|> assign(:provider_form, to_form(%{"api_key" => ""}, as: :provider))
|
|
|> assign(:provider_test_result, nil)
|
|
|> assign(:pending_provider_key, nil)}
|
|
end
|
|
|
|
def handle_event("validate_provider", %{"provider" => params}, socket) do
|
|
{:noreply, assign(socket, pending_provider_key: params["api_key"])}
|
|
end
|
|
|
|
def handle_event("test_provider", _params, socket) do
|
|
type = socket.assigns.selected_provider
|
|
api_key = socket.assigns.pending_provider_key
|
|
|
|
if api_key in [nil, ""] do
|
|
{:noreply, assign(socket, provider_test_result: {:error, :no_api_key})}
|
|
else
|
|
socket = assign(socket, provider_testing: true, provider_test_result: nil)
|
|
|
|
temp_conn = %ProviderConnection{
|
|
provider_type: type,
|
|
api_key_encrypted: encrypt_api_key(api_key)
|
|
}
|
|
|
|
result = Berrypod.Providers.test_connection(temp_conn)
|
|
|
|
{:noreply, assign(socket, provider_testing: false, provider_test_result: result)}
|
|
end
|
|
end
|
|
|
|
def handle_event("connect_provider", %{"provider" => %{"api_key" => api_key}}, socket) do
|
|
type = socket.assigns.selected_provider
|
|
|
|
if api_key == "" do
|
|
{:noreply, put_flash(socket, :error, "Please enter your API token")}
|
|
else
|
|
socket = assign(socket, provider_connecting: true)
|
|
|
|
params =
|
|
%{"api_key" => api_key, "provider_type" => type}
|
|
|> maybe_add_shop_config(socket.assigns.provider_test_result)
|
|
|> maybe_add_name(socket.assigns.provider_test_result, type)
|
|
|
|
case Products.create_provider_connection(params) do
|
|
{:ok, connection} ->
|
|
Products.enqueue_sync(connection)
|
|
|
|
setup = %{
|
|
socket.assigns.setup
|
|
| provider_connected: true,
|
|
provider_type: type
|
|
}
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:provider_connecting, false)
|
|
|> assign(:provider_conn, connection)
|
|
|> assign(:setup, setup)
|
|
|> put_flash(:info, "Connected! Product sync started in the background.")}
|
|
|
|
{:error, _changeset} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:provider_connecting, false)
|
|
|> put_flash(:error, "Failed to save connection")}
|
|
end
|
|
end
|
|
end
|
|
|
|
# ── Events: Stripe ──
|
|
|
|
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
|
|
if api_key == "" do
|
|
{:noreply, put_flash(socket, :error, "Please enter your Stripe secret key")}
|
|
else
|
|
socket = assign(socket, stripe_connecting: true)
|
|
|
|
case StripeSetup.connect(api_key) do
|
|
{:ok, _result} ->
|
|
setup = %{socket.assigns.setup | stripe_connected: true}
|
|
|
|
setup =
|
|
if setup.admin_created and setup.provider_connected do
|
|
%{setup | setup_complete: true}
|
|
else
|
|
setup
|
|
end
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:stripe_connecting, false)
|
|
|> assign(:setup, setup)
|
|
|> put_flash(:info, "Stripe connected")}
|
|
|
|
{:error, message} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:stripe_connecting, false)
|
|
|> put_flash(:error, "Stripe connection failed: #{message}")}
|
|
end
|
|
end
|
|
end
|
|
|
|
# ── Render ──
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<div class="setup-page">
|
|
<div class="setup-header">
|
|
<h1 class="setup-title">Set up your shop</h1>
|
|
<%= if @require_secret? and not @secret_verified do %>
|
|
<p class="setup-subtitle">
|
|
Enter the setup secret from your server logs to get started.
|
|
</p>
|
|
<% else %>
|
|
<p class="setup-subtitle">
|
|
Connect your accounts to get going.
|
|
</p>
|
|
<% end %>
|
|
</div>
|
|
|
|
<%!-- Secret gate (prod only, before admin exists) --%>
|
|
<%= if @require_secret? and not @secret_verified do %>
|
|
<div class="setup-sections">
|
|
<div class="setup-card">
|
|
<div class="setup-card-body">
|
|
<p class="setup-hint">
|
|
Find the setup secret in your server logs or set the <code>SETUP_SECRET</code>
|
|
environment variable.
|
|
</p>
|
|
<.form for={@secret_form} phx-submit="verify_secret">
|
|
<.input
|
|
field={@secret_form[:secret]}
|
|
type="password"
|
|
label="Setup secret"
|
|
autocomplete="off"
|
|
required
|
|
phx-mounted={JS.focus()}
|
|
/>
|
|
<div class="setup-actions">
|
|
<.button phx-disable-with="Checking...">Continue</.button>
|
|
</div>
|
|
</.form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<%!-- All three setup cards --%>
|
|
<div class="setup-sections">
|
|
<.section_card
|
|
title="Create admin account"
|
|
number={1}
|
|
done={@logged_in?}
|
|
summary={account_summary(assigns)}
|
|
>
|
|
<p class="setup-hint">Enter your email to create the admin account.</p>
|
|
<.form for={@account_form} phx-submit="create_account">
|
|
<.input
|
|
field={@account_form[:email]}
|
|
type="email"
|
|
label="Email address"
|
|
autocomplete="email"
|
|
required
|
|
phx-mounted={JS.focus()}
|
|
/>
|
|
<div class="setup-actions">
|
|
<.button phx-disable-with="Creating account...">Create account</.button>
|
|
</div>
|
|
</.form>
|
|
</.section_card>
|
|
|
|
<.section_card
|
|
title="Connect a print provider"
|
|
number={2}
|
|
done={@setup.provider_connected}
|
|
summary={provider_summary(assigns)}
|
|
>
|
|
<.provider_section
|
|
providers={@providers}
|
|
selected={@selected_provider}
|
|
form={@provider_form}
|
|
testing={@provider_testing}
|
|
test_result={@provider_test_result}
|
|
connecting={@provider_connecting}
|
|
/>
|
|
</.section_card>
|
|
|
|
<.section_card
|
|
title="Connect payments"
|
|
number={3}
|
|
done={@setup.stripe_connected}
|
|
summary={stripe_summary(assigns)}
|
|
>
|
|
<.stripe_section
|
|
form={@stripe_form}
|
|
connecting={@stripe_connecting}
|
|
/>
|
|
</.section_card>
|
|
</div>
|
|
|
|
<%!-- All done --%>
|
|
<div :if={@setup.setup_complete} class="setup-complete">
|
|
<.icon name="hero-check-badge" class="setup-complete-icon" />
|
|
<h2>You're all set</h2>
|
|
<p>Head to the dashboard to sync products, customise your theme, and go live.</p>
|
|
<.link navigate={~p"/admin"} class="admin-btn admin-btn-primary">
|
|
Go to dashboard <span aria-hidden="true">→</span>
|
|
</.link>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
# ── Section card component ──
|
|
|
|
attr :title, :string, required: true
|
|
attr :number, :integer, required: true
|
|
attr :done, :boolean, required: true
|
|
attr :summary, :string, default: nil
|
|
|
|
slot :inner_block, required: true
|
|
|
|
defp section_card(assigns) do
|
|
~H"""
|
|
<div class={["setup-card", @done && "setup-card-done"]}>
|
|
<div class="setup-card-header">
|
|
<span class={["setup-card-number", @done && "setup-card-number-done"]}>
|
|
<%= if @done do %>
|
|
<.icon name="hero-check-mini" class="size-4" />
|
|
<% else %>
|
|
{@number}
|
|
<% end %>
|
|
</span>
|
|
<h2 class="setup-card-title">{@title}</h2>
|
|
</div>
|
|
|
|
<%= if @done and @summary do %>
|
|
<p class="setup-card-summary">{@summary}</p>
|
|
<% else %>
|
|
<div :if={!@done} class="setup-card-body">
|
|
{render_slot(@inner_block)}
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
# ── Provider section ──
|
|
|
|
attr :providers, :list, required: true
|
|
attr :selected, :string, default: nil
|
|
attr :form, :any, required: true
|
|
attr :testing, :boolean, required: true
|
|
attr :test_result, :any, default: nil
|
|
attr :connecting, :boolean, required: true
|
|
|
|
defp provider_section(assigns) do
|
|
~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>
|
|
|
|
<%!-- API key form for selected provider --%>
|
|
<div :if={@selected} class="setup-provider-form">
|
|
<% provider_info = Enum.find(@providers, &(&1.type == @selected)) %>
|
|
<p class="setup-hint">
|
|
{provider_info.setup_hint}.
|
|
<a href={provider_info.setup_url} target="_blank" rel="noopener" class="setup-link">
|
|
Open {provider_info.name} →
|
|
</a>
|
|
</p>
|
|
|
|
<.form for={@form} phx-change="validate_provider" phx-submit="connect_provider">
|
|
<.input
|
|
field={@form[:api_key]}
|
|
type="password"
|
|
label="API token"
|
|
placeholder="Paste your token here"
|
|
autocomplete="off"
|
|
/>
|
|
|
|
<div class="setup-actions">
|
|
<button
|
|
type="button"
|
|
phx-click="test_provider"
|
|
disabled={@testing}
|
|
class="admin-btn admin-btn-secondary"
|
|
>
|
|
<%= if @testing do %>
|
|
<.icon name="hero-arrow-path" class="size-4 animate-spin" /> Checking...
|
|
<% else %>
|
|
<.icon name="hero-signal" class="size-4" /> Check connection
|
|
<% end %>
|
|
</button>
|
|
<.button type="submit" disabled={@connecting or @testing}>
|
|
{if @connecting, do: "Connecting...", else: "Connect"}
|
|
</.button>
|
|
</div>
|
|
|
|
<.provider_test_feedback :if={@test_result} result={@test_result} />
|
|
</.form>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
attr :result, :any, required: true
|
|
|
|
defp provider_test_feedback(assigns) do
|
|
~H"""
|
|
<div class="setup-test-result">
|
|
<%= case @result do %>
|
|
<% {:ok, info} -> %>
|
|
<span class="setup-test-ok">
|
|
<.icon name="hero-check-circle" class="size-4" />
|
|
Connected{if info[:shop_name], do: " to #{info.shop_name}", else: ""}
|
|
</span>
|
|
<% {:error, :no_api_key} -> %>
|
|
<span class="setup-test-error">
|
|
<.icon name="hero-x-circle" class="size-4" /> Please enter your API token
|
|
</span>
|
|
<% {:error, reason} -> %>
|
|
<span class="setup-test-error">
|
|
<.icon name="hero-x-circle" class="size-4" /> {format_error(reason)}
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
# ── Stripe section ──
|
|
|
|
attr :form, :any, required: true
|
|
attr :connecting, :boolean, required: true
|
|
|
|
defp stripe_section(assigns) do
|
|
~H"""
|
|
<div>
|
|
<p class="setup-hint">
|
|
Enter your Stripe secret key to accept payments.
|
|
<a
|
|
href="https://dashboard.stripe.com/apikeys"
|
|
target="_blank"
|
|
rel="noopener"
|
|
class="setup-link"
|
|
>
|
|
Open Stripe dashboard →
|
|
</a>
|
|
</p>
|
|
|
|
<.form for={@form} phx-submit="connect_stripe">
|
|
<.input
|
|
field={@form[:api_key]}
|
|
type="password"
|
|
label="Secret key"
|
|
autocomplete="off"
|
|
placeholder="sk_test_... or sk_live_..."
|
|
/>
|
|
<p class="setup-key-hint">
|
|
Starts with <code>sk_test_</code> or <code>sk_live_</code>. Encrypted at rest.
|
|
</p>
|
|
<div class="setup-actions">
|
|
<.button phx-disable-with="Connecting...">
|
|
{if @connecting, do: "Connecting...", else: "Connect Stripe"}
|
|
</.button>
|
|
</div>
|
|
</.form>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
# ── Helpers ──
|
|
|
|
defp account_summary(%{current_scope: %{user: user}}) when not is_nil(user) do
|
|
user.email
|
|
end
|
|
|
|
defp account_summary(_), do: "Account created"
|
|
|
|
defp provider_summary(%{setup: %{provider_type: type}}) when is_binary(type) do
|
|
case Provider.get(type) do
|
|
nil -> "Connected"
|
|
info -> "Connected to #{info.name}"
|
|
end
|
|
end
|
|
|
|
defp provider_summary(_), do: nil
|
|
|
|
defp stripe_summary(%{setup: %{stripe_connected: true}}) do
|
|
case Settings.secret_hint("stripe_api_key") do
|
|
nil -> "Connected"
|
|
hint -> "Connected · #{hint}"
|
|
end
|
|
end
|
|
|
|
defp stripe_summary(_), do: nil
|
|
|
|
defp encrypt_api_key(api_key) do
|
|
case Berrypod.Vault.encrypt(api_key) do
|
|
{:ok, encrypted} -> encrypted
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp maybe_add_shop_config(params, {:ok, %{shop_id: shop_id}}) do
|
|
config = Map.get(params, "config", %{}) |> Map.put("shop_id", to_string(shop_id))
|
|
Map.put(params, "config", config)
|
|
end
|
|
|
|
defp maybe_add_shop_config(params, _), do: params
|
|
|
|
defp maybe_add_name(params, {:ok, %{shop_name: name}}, _type) when is_binary(name) do
|
|
Map.put_new(params, "name", name)
|
|
end
|
|
|
|
defp maybe_add_name(params, _, type) do
|
|
case Provider.get(type) do
|
|
nil -> Map.put_new(params, "name", type)
|
|
info -> Map.put_new(params, "name", info.name)
|
|
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"
|
|
defp format_error({:http_error, _code}), do: "Something went wrong — try again"
|
|
defp format_error(error) when is_binary(error), do: error
|
|
defp format_error(_), do: "Connection failed — check your token and try again"
|
|
end
|