berrypod/lib/berrypod_web/live/setup/onboarding.ex
jamey e26a02a0fb
All checks were successful
deploy / deploy (push) Successful in 1m9s
fix setup flow stale state and Stripe URL issues
Onboarding: re-fetch setup_status() after provider/Stripe connect instead
of manually patching the local assigns, which could miss admin_created and
leave users stuck on the setup page with no way forward.

Dev config: respect PHX_HOST for endpoint URL so Stripe checkout redirects
to the correct host instead of always using localhost.

Stripe setup: detect private/LAN IPs (10.x, 172.16-31.x, 192.168.x) as
unreachable, not just localhost — prevents creating webhook endpoints that
Stripe can never reach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:51:44 +00:00

552 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", %{"provider_select" => %{"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 = Setup.setup_status()
{: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 = Setup.setup_status()
{: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">&rarr;</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
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>
<.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">
<% 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} &rarr;
</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 &rarr;
</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 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"
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