consolidate settings into single admin page

Merge shop status, payments, products (Printify), account (email/password),
and advanced (dashboard/error tracker links) into /admin/settings. Simplify
Auth.Settings to a redirector for /users/settings and confirm-email tokens.
Remove Providers from sidebar nav. Update all redirects and tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-12 09:04:51 +00:00
parent 26d3bd782a
commit 4514608c07
11 changed files with 526 additions and 338 deletions

View File

@ -61,14 +61,6 @@
<.icon name="hero-paint-brush" class="size-5" /> Theme <.icon name="hero-paint-brush" class="size-5" /> Theme
</.link> </.link>
</li> </li>
<li>
<.link
navigate={~p"/admin/providers"}
class={admin_nav_active?(@current_path, "/admin/providers")}
>
<.icon name="hero-cube" class="size-5" /> Providers
</.link>
</li>
<li> <li>
<.link <.link
navigate={~p"/admin/settings"} navigate={~p"/admin/settings"}

View File

@ -55,7 +55,7 @@ defmodule SimpleshopThemeWeb.UserSessionController do
UserAuth.disconnect_sessions(expired_tokens) UserAuth.disconnect_sessions(expired_tokens)
conn conn
|> put_session(:user_return_to, ~p"/users/settings") |> put_session(:user_return_to, ~p"/admin/settings")
|> create(params, "Password updated successfully!") |> create(params, "Password updated successfully!")
end end

View File

@ -83,7 +83,7 @@ defmodule SimpleshopThemeWeb.Admin.Providers.Form do
{:noreply, {:noreply,
socket socket
|> put_flash(:info, "Connected to Printify!") |> put_flash(:info, "Connected to Printify!")
|> push_navigate(to: ~p"/admin/providers")} |> push_navigate(to: ~p"/admin/settings")}
{:error, %Ecto.Changeset{} = changeset} -> {:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))} {:noreply, assign(socket, form: to_form(changeset))}
@ -96,7 +96,7 @@ defmodule SimpleshopThemeWeb.Admin.Providers.Form do
{:noreply, {:noreply,
socket socket
|> put_flash(:info, "Settings saved") |> put_flash(:info, "Settings saved")
|> push_navigate(to: ~p"/admin/providers")} |> push_navigate(to: ~p"/admin/settings")}
{:error, %Ecto.Changeset{} = changeset} -> {:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))} {:noreply, assign(socket, form: to_form(changeset))}

View File

@ -1,18 +1,26 @@
defmodule SimpleshopThemeWeb.Admin.Settings do defmodule SimpleshopThemeWeb.Admin.Settings do
use SimpleshopThemeWeb, :live_view use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Accounts
alias SimpleshopTheme.Products
alias SimpleshopTheme.Settings alias SimpleshopTheme.Settings
alias SimpleshopTheme.Stripe.Setup, as: StripeSetup alias SimpleshopTheme.Stripe.Setup, as: StripeSetup
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
user = socket.assigns.current_scope.user
{:ok, {:ok,
socket socket
|> assign(:page_title, "Settings") |> assign(:page_title, "Settings")
|> assign(:site_live, Settings.site_live?()) |> assign(:site_live, Settings.site_live?())
|> assign_stripe_state()} |> assign_stripe_state()
|> assign_products_state()
|> assign_account_state(user)}
end end
# -- Stripe assigns --
defp assign_stripe_state(socket) do defp assign_stripe_state(socket) do
has_key = Settings.has_secret?("stripe_api_key") has_key = Settings.has_secret?("stripe_api_key")
has_signing = Settings.has_secret?("stripe_signing_secret") has_signing = Settings.has_secret?("stripe_signing_secret")
@ -32,11 +40,58 @@ defmodule SimpleshopThemeWeb.Admin.Settings do
|> assign(:stripe_has_signing_secret, has_signing) |> assign(:stripe_has_signing_secret, has_signing)
|> assign(:connect_form, to_form(%{"api_key" => ""}, as: :stripe)) |> assign(:connect_form, to_form(%{"api_key" => ""}, as: :stripe))
|> assign(:secret_form, to_form(%{"signing_secret" => ""}, as: :webhook)) |> assign(:secret_form, to_form(%{"signing_secret" => ""}, as: :webhook))
|> assign(:advanced_open, false) |> assign(:stripe_advanced_open, false)
|> assign(:connecting, false) |> assign(:connecting, false)
end end
# -- Products assigns --
defp assign_products_state(socket) do
connections = Products.list_provider_connections()
connection_info =
case connections do
[conn | _] ->
product_count = Products.count_products_for_connection(conn.id)
%{connection: conn, product_count: product_count}
[] ->
nil
end
assign(socket, :printify, connection_info)
end
# -- Account assigns --
defp assign_account_state(socket, user) do
email_changeset = Accounts.change_user_email(user, %{}, validate_unique: false)
password_changeset = Accounts.change_user_password(user, %{}, hash_password: false)
socket
|> assign(:current_email, user.email)
|> assign(:email_form, to_form(email_changeset))
|> assign(:password_form, to_form(password_changeset))
|> assign(:trigger_submit, false)
end
# -- Events: shop status --
@impl true @impl true
def handle_event("toggle_site_live", _params, socket) do
new_value = !socket.assigns.site_live
{:ok, _} = Settings.set_site_live(new_value)
message = if new_value, do: "Shop is now live", else: "Shop taken offline"
{:noreply,
socket
|> assign(:site_live, new_value)
|> put_flash(:info, message)}
end
# -- Events: Stripe --
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
if api_key == "" do if api_key == "" do
{:noreply, put_flash(socket, :error, "Please enter your Stripe secret key")} {:noreply, put_flash(socket, :error, "Please enter your Stripe secret key")}
@ -100,42 +155,124 @@ defmodule SimpleshopThemeWeb.Admin.Settings do
end end
end end
def handle_event("toggle_site_live", _params, socket) do def handle_event("toggle_stripe_advanced", _params, socket) do
new_value = !socket.assigns.site_live {:noreply, assign(socket, :stripe_advanced_open, !socket.assigns.stripe_advanced_open)}
{:ok, _} = Settings.set_site_live(new_value) end
message = if new_value, do: "Shop is now live", else: "Shop taken offline" # -- Events: products --
def handle_event("sync", %{"id" => id}, socket) do
connection = Products.get_provider_connection!(id)
case Products.enqueue_sync(connection) do
{:ok, _job} ->
{:noreply,
socket
|> assign_products_state()
|> put_flash(:info, "Sync started for #{connection.name}")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Failed to start sync")}
end
end
def handle_event("delete_connection", %{"id" => id}, socket) do
connection = Products.get_provider_connection!(id)
{:ok, _} = Products.delete_provider_connection(connection)
{:noreply, {:noreply,
socket socket
|> assign(:site_live, new_value) |> assign_products_state()
|> put_flash(:info, message)} |> put_flash(:info, "Provider connection deleted")}
end end
def handle_event("toggle_advanced", _params, socket) do # -- Events: account --
{:noreply, assign(socket, :advanced_open, !socket.assigns.advanced_open)}
def handle_event("validate_email", %{"user" => user_params}, socket) do
email_form =
socket.assigns.current_scope.user
|> Accounts.change_user_email(user_params, validate_unique: false)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, email_form: email_form)}
end end
def handle_event("update_email", %{"user" => user_params}, socket) do
user = socket.assigns.current_scope.user
unless Accounts.sudo_mode?(user) do
{:noreply,
socket
|> put_flash(:error, "Please log in again to change account settings.")
|> redirect(to: ~p"/users/log-in")}
else
case Accounts.change_user_email(user, user_params) do
%{valid?: true} = changeset ->
Accounts.deliver_user_update_email_instructions(
Ecto.Changeset.apply_action!(changeset, :insert),
user.email,
&url(~p"/users/settings/confirm-email/#{&1}")
)
info = "A link to confirm your email change has been sent to the new address."
{:noreply, put_flash(socket, :info, info)}
changeset ->
{:noreply, assign(socket, :email_form, to_form(changeset, action: :insert))}
end
end
end
def handle_event("validate_password", %{"user" => user_params}, socket) do
password_form =
socket.assigns.current_scope.user
|> Accounts.change_user_password(user_params, hash_password: false)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, password_form: password_form)}
end
def handle_event("update_password", %{"user" => user_params}, socket) do
user = socket.assigns.current_scope.user
unless Accounts.sudo_mode?(user) do
{:noreply,
socket
|> put_flash(:error, "Please log in again to change account settings.")
|> redirect(to: ~p"/users/log-in")}
else
case Accounts.change_user_password(user, user_params) do
%{valid?: true} = changeset ->
{:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))}
changeset ->
{:noreply, assign(socket, password_form: to_form(changeset, action: :insert))}
end
end
end
# -- Render --
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="max-w-2xl"> <div class="max-w-2xl">
<.header> <.header>
Settings Settings
<:subtitle>Shop status, payment providers, and API keys</:subtitle>
</.header> </.header>
<%!-- Shop status --%>
<section class="mt-10"> <section class="mt-10">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h2 class="text-lg font-semibold">Shop status</h2> <h2 class="text-lg font-semibold">Shop status</h2>
<%= if @site_live do %> <%= if @site_live do %>
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset"> <.status_pill color="green">
<.icon name="hero-check-circle-mini" class="size-3" /> Live <.icon name="hero-check-circle-mini" class="size-3" /> Live
</span> </.status_pill>
<% else %> <% else %>
<span class="inline-flex items-center gap-1 rounded-full bg-zinc-50 px-2 py-1 text-xs font-medium text-zinc-600 ring-1 ring-zinc-500/10 ring-inset"> <.status_pill color="zinc">Offline</.status_pill>
Offline
</span>
<% end %> <% end %>
</div> </div>
<p class="mt-2 text-sm text-zinc-600"> <p class="mt-2 text-sm text-zinc-600">
@ -165,22 +302,21 @@ defmodule SimpleshopThemeWeb.Admin.Settings do
</div> </div>
</section> </section>
<%!-- Payments --%>
<section class="mt-10"> <section class="mt-10">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h2 class="text-lg font-semibold">Stripe</h2> <h2 class="text-lg font-semibold">Payments</h2>
<%= case @stripe_status do %> <%= case @stripe_status do %>
<% :connected -> %> <% :connected -> %>
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset"> <.status_pill color="green">
<.icon name="hero-check-circle-mini" class="size-3" /> Connected <.icon name="hero-check-circle-mini" class="size-3" /> Connected
</span> </.status_pill>
<% :connected_localhost -> %> <% :connected_localhost -> %>
<span class="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700 ring-1 ring-amber-600/20 ring-inset"> <.status_pill color="amber">
<.icon name="hero-exclamation-triangle-mini" class="size-3" /> Dev mode <.icon name="hero-exclamation-triangle-mini" class="size-3" /> Dev mode
</span> </.status_pill>
<% :not_configured -> %> <% :not_configured -> %>
<span class="inline-flex items-center gap-1 rounded-full bg-zinc-50 px-2 py-1 text-xs font-medium text-zinc-600 ring-1 ring-zinc-500/10 ring-inset"> <.status_pill color="zinc">Not connected</.status_pill>
Not connected
</span>
<% end %> <% end %>
</div> </div>
@ -194,10 +330,213 @@ defmodule SimpleshopThemeWeb.Admin.Settings do
stripe_signing_secret_hint={@stripe_signing_secret_hint} stripe_signing_secret_hint={@stripe_signing_secret_hint}
stripe_has_signing_secret={@stripe_has_signing_secret} stripe_has_signing_secret={@stripe_has_signing_secret}
secret_form={@secret_form} secret_form={@secret_form}
advanced_open={@advanced_open} advanced_open={@stripe_advanced_open}
/> />
<% end %> <% end %>
</section> </section>
<%!-- Products --%>
<section class="mt-10">
<div class="flex items-center gap-3">
<h2 class="text-lg font-semibold">Products</h2>
<%= if @printify do %>
<.status_pill color="green">
<.icon name="hero-check-circle-mini" class="size-3" /> Connected
</.status_pill>
<% else %>
<.status_pill color="zinc">Not connected</.status_pill>
<% end %>
</div>
<%= if @printify do %>
<.printify_connected printify={@printify} />
<% else %>
<div class="mt-4">
<p class="text-sm text-zinc-600">
Connect a print-on-demand provider to import products into your shop.
</p>
<div class="mt-4">
<.link
navigate={~p"/admin/providers/new"}
class="inline-flex items-center gap-2 rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-zinc-700"
>
<.icon name="hero-plus-mini" class="size-4" /> Connect to Printify
</.link>
</div>
</div>
<% end %>
</section>
<%!-- Account --%>
<section class="mt-10">
<h2 class="text-lg font-semibold">Account</h2>
<div class="mt-4 space-y-6">
<.form
for={@email_form}
id="email_form"
phx-submit="update_email"
phx-change="validate_email"
>
<.input
field={@email_form[:email]}
type="email"
label="Email"
autocomplete="username"
required
/>
<div class="mt-3">
<.button phx-disable-with="Saving...">Change email</.button>
</div>
</.form>
<div class="border-t border-zinc-200 pt-6">
<.form
for={@password_form}
id="password_form"
action={~p"/users/update-password"}
method="post"
phx-change="validate_password"
phx-submit="update_password"
phx-trigger-action={@trigger_submit}
>
<input
name={@password_form[:email].name}
type="hidden"
id="hidden_user_email"
autocomplete="username"
value={@current_email}
/>
<.input
field={@password_form[:password]}
type="password"
label="New password"
autocomplete="new-password"
required
/>
<.input
field={@password_form[:password_confirmation]}
type="password"
label="Confirm new password"
autocomplete="new-password"
/>
<div class="mt-3">
<.button phx-disable-with="Saving...">Change password</.button>
</div>
</.form>
</div>
</div>
</section>
<%!-- Advanced --%>
<section class="mt-10 pb-10">
<h2 class="text-lg font-semibold">Advanced</h2>
<div class="mt-4 flex flex-col gap-2">
<.link href={~p"/admin/dashboard"} class="text-sm text-zinc-600 hover:text-zinc-900">
<.icon name="hero-chart-bar" class="size-4 inline" /> System dashboard
</.link>
<.link href={~p"/admin/errors"} class="text-sm text-zinc-600 hover:text-zinc-900">
<.icon name="hero-bug-ant" class="size-4 inline" /> Error tracker
</.link>
</div>
</section>
</div>
"""
end
# -- Function components --
attr :color, :string, required: true
slot :inner_block, required: true
defp status_pill(assigns) do
classes =
case assigns.color do
"green" -> "bg-green-50 text-green-700 ring-green-600/20"
"amber" -> "bg-amber-50 text-amber-700 ring-amber-600/20"
"zinc" -> "bg-zinc-50 text-zinc-600 ring-zinc-500/10"
_ -> "bg-zinc-50 text-zinc-600 ring-zinc-500/10"
end
assigns = assign(assigns, :classes, classes)
~H"""
<span class={[
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
@classes
]}>
{render_slot(@inner_block)}
</span>
"""
end
attr :printify, :map, required: true
defp printify_connected(assigns) do
conn = assigns.printify.connection
assigns =
assigns
|> assign(:connection, conn)
|> assign(:product_count, assigns.printify.product_count)
|> assign(:syncing, conn.sync_status == "syncing")
~H"""
<div class="mt-4">
<dl class="text-sm">
<div class="flex gap-2 py-1">
<dt class="text-zinc-500 w-28 shrink-0">Provider</dt>
<dd class="text-zinc-700">Printify</dd>
</div>
<div class="flex gap-2 py-1">
<dt class="text-zinc-500 w-28 shrink-0">Shop</dt>
<dd class="text-zinc-700">{@connection.name}</dd>
</div>
<div class="flex gap-2 py-1">
<dt class="text-zinc-500 w-28 shrink-0">Products</dt>
<dd class="text-zinc-700">{@product_count}</dd>
</div>
<div class="flex gap-2 py-1">
<dt class="text-zinc-500 w-28 shrink-0">Last synced</dt>
<dd class="text-zinc-700">
<%= if @connection.last_synced_at do %>
{format_relative_time(@connection.last_synced_at)}
<% else %>
<span class="text-amber-600">Never</span>
<% end %>
</dd>
</div>
</dl>
<div class="mt-4 flex flex-wrap gap-2">
<button
phx-click="sync"
phx-value-id={@connection.id}
disabled={@syncing}
class="inline-flex items-center gap-1.5 rounded-md bg-zinc-100 px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-200 ring-1 ring-zinc-300 ring-inset"
>
<.icon
name="hero-arrow-path"
class={if @syncing, do: "size-4 animate-spin", else: "size-4"}
/>
{if @syncing, do: "Syncing...", else: "Sync products"}
</button>
<.link
navigate={~p"/admin/providers/#{@connection.id}/edit"}
class="inline-flex items-center gap-1.5 rounded-md bg-zinc-100 px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-200 ring-1 ring-zinc-300 ring-inset"
>
<.icon name="hero-cog-6-tooth" class="size-4" /> Settings
</.link>
<button
phx-click="delete_connection"
phx-value-id={@connection.id}
data-confirm="Disconnect from Printify? Your synced products will remain in your shop."
class="text-sm text-red-600 hover:text-red-800 px-2 py-1.5"
>
Disconnect
</button>
</div>
</div> </div>
""" """
end end
@ -291,7 +630,7 @@ defmodule SimpleshopThemeWeb.Admin.Settings do
<% else %> <% else %>
<div class="border-t border-zinc-200 pt-3"> <div class="border-t border-zinc-200 pt-3">
<button <button
phx-click="toggle_advanced" phx-click="toggle_stripe_advanced"
class="flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-700" class="flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-700"
> >
<.icon <.icon
@ -334,4 +673,15 @@ defmodule SimpleshopThemeWeb.Admin.Settings do
</div> </div>
""" """
end end
defp format_relative_time(datetime) do
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
cond do
diff < 60 -> "just now"
diff < 3600 -> "#{div(diff, 60)} min ago"
diff < 86400 -> "#{div(diff, 3600)} hours ago"
true -> "#{div(diff, 86400)} days ago"
end
end
end end

View File

@ -1,71 +1,9 @@
defmodule SimpleshopThemeWeb.Auth.Settings do defmodule SimpleshopThemeWeb.Auth.Settings do
use SimpleshopThemeWeb, :live_view use SimpleshopThemeWeb, :live_view
on_mount {SimpleshopThemeWeb.UserAuth, :require_sudo_mode}
alias SimpleshopTheme.Accounts alias SimpleshopTheme.Accounts
@impl true # Confirm-email token: process it and redirect to admin settings
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="text-center">
<.header>
Account Settings
<:subtitle>Manage your account email address and password settings</:subtitle>
</.header>
</div>
<.form for={@email_form} id="email_form" phx-submit="update_email" phx-change="validate_email">
<.input
field={@email_form[:email]}
type="email"
label="Email"
autocomplete="username"
required
/>
<.button variant="primary" phx-disable-with="Changing...">Change Email</.button>
</.form>
<div class="divider" />
<.form
for={@password_form}
id="password_form"
action={~p"/users/update-password"}
method="post"
phx-change="validate_password"
phx-submit="update_password"
phx-trigger-action={@trigger_submit}
>
<input
name={@password_form[:email].name}
type="hidden"
id="hidden_user_email"
autocomplete="username"
value={@current_email}
/>
<.input
field={@password_form[:password]}
type="password"
label="New password"
autocomplete="new-password"
required
/>
<.input
field={@password_form[:password_confirmation]}
type="password"
label="Confirm new password"
autocomplete="new-password"
/>
<.button variant="primary" phx-disable-with="Saving...">
Save Password
</.button>
</.form>
</Layouts.app>
"""
end
@impl true @impl true
def mount(%{"token" => token}, _session, socket) do def mount(%{"token" => token}, _session, socket) do
socket = socket =
@ -77,81 +15,16 @@ defmodule SimpleshopThemeWeb.Auth.Settings do
put_flash(socket, :error, "Email change link is invalid or it has expired.") put_flash(socket, :error, "Email change link is invalid or it has expired.")
end end
{:ok, push_navigate(socket, to: ~p"/users/settings")} {:ok, redirect(socket, to: ~p"/admin/settings")}
end end
# Main mount: just redirect — account settings live in admin now
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
user = socket.assigns.current_scope.user {:ok, redirect(socket, to: ~p"/admin/settings")}
email_changeset = Accounts.change_user_email(user, %{}, validate_unique: false)
password_changeset = Accounts.change_user_password(user, %{}, hash_password: false)
socket =
socket
|> assign(:current_email, user.email)
|> assign(:email_form, to_form(email_changeset))
|> assign(:password_form, to_form(password_changeset))
|> assign(:trigger_submit, false)
{:ok, socket}
end end
@impl true @impl true
def handle_event("validate_email", params, socket) do def render(assigns) do
%{"user" => user_params} = params ~H""
email_form =
socket.assigns.current_scope.user
|> Accounts.change_user_email(user_params, validate_unique: false)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, email_form: email_form)}
end
def handle_event("update_email", params, socket) do
%{"user" => user_params} = params
user = socket.assigns.current_scope.user
true = Accounts.sudo_mode?(user)
case Accounts.change_user_email(user, user_params) do
%{valid?: true} = changeset ->
Accounts.deliver_user_update_email_instructions(
Ecto.Changeset.apply_action!(changeset, :insert),
user.email,
&url(~p"/users/settings/confirm-email/#{&1}")
)
info = "A link to confirm your email change has been sent to the new address."
{:noreply, socket |> put_flash(:info, info)}
changeset ->
{:noreply, assign(socket, :email_form, to_form(changeset, action: :insert))}
end
end
def handle_event("validate_password", params, socket) do
%{"user" => user_params} = params
password_form =
socket.assigns.current_scope.user
|> Accounts.change_user_password(user_params, hash_password: false)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, password_form: password_form)}
end
def handle_event("update_password", params, socket) do
%{"user" => user_params} = params
user = socket.assigns.current_scope.user
true = Accounts.sudo_mode?(user)
case Accounts.change_user_password(user, user_params) do
%{valid?: true} = changeset ->
{:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))}
changeset ->
{:noreply, assign(socket, password_form: to_form(changeset, action: :insert))}
end
end end
end end

View File

@ -257,9 +257,9 @@ defmodule SimpleshopThemeWeb.UserAuth do
end end
@doc "Returns the path to redirect to after log in." @doc "Returns the path to redirect to after log in."
# the user was already logged in, redirect to settings # the user was already logged in, redirect to admin settings
def signed_in_path(%Plug.Conn{assigns: %{current_scope: %Scope{user: %Accounts.User{}}}}) do def signed_in_path(%Plug.Conn{assigns: %{current_scope: %Scope{user: %Accounts.User{}}}}) do
~p"/users/settings" ~p"/admin/settings"
end end
def signed_in_path(_), do: ~p"/" def signed_in_path(_), do: ~p"/"

View File

@ -21,7 +21,7 @@ defmodule SimpleshopThemeWeb.UserSessionControllerTest do
assert redirected_to(conn) == ~p"/" assert redirected_to(conn) == ~p"/"
# Now do a logged in request and assert on the page content # Now do a logged in request and assert on the page content
conn = get(conn, ~p"/users/settings") conn = get(conn, ~p"/admin/settings")
response = html_response(conn, 200) response = html_response(conn, 200)
assert response =~ user.email assert response =~ user.email
end end
@ -83,7 +83,7 @@ defmodule SimpleshopThemeWeb.UserSessionControllerTest do
assert redirected_to(conn) == ~p"/" assert redirected_to(conn) == ~p"/"
# Now do a logged in request and assert on the page content # Now do a logged in request and assert on the page content
conn = get(conn, ~p"/users/settings") conn = get(conn, ~p"/admin/settings")
response = html_response(conn, 200) response = html_response(conn, 200)
assert response =~ user.email assert response =~ user.email
end end
@ -105,7 +105,7 @@ defmodule SimpleshopThemeWeb.UserSessionControllerTest do
assert Accounts.get_user!(user.id).confirmed_at assert Accounts.get_user!(user.id).confirmed_at
# Now do a logged in request and assert on the page content # Now do a logged in request and assert on the page content
conn = get(conn, ~p"/users/settings") conn = get(conn, ~p"/admin/settings")
response = html_response(conn, 200) response = html_response(conn, 200)
assert response =~ user.email assert response =~ user.email
end end

View File

@ -19,7 +19,6 @@ defmodule SimpleshopThemeWeb.Admin.LayoutTest do
assert has_element?(view, ~s(a[href="/admin/orders"]), "Orders") assert has_element?(view, ~s(a[href="/admin/orders"]), "Orders")
assert has_element?(view, ~s(a[href="/admin/theme"]), "Theme") assert has_element?(view, ~s(a[href="/admin/theme"]), "Theme")
assert has_element?(view, ~s(a[href="/admin/providers"]), "Providers")
assert has_element?(view, ~s(a[href="/admin/settings"]), "Settings") assert has_element?(view, ~s(a[href="/admin/settings"]), "Settings")
end end

View File

@ -3,7 +3,9 @@ defmodule SimpleshopThemeWeb.Admin.SettingsTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import SimpleshopTheme.AccountsFixtures import SimpleshopTheme.AccountsFixtures
import SimpleshopTheme.ProductsFixtures
alias SimpleshopTheme.Accounts
alias SimpleshopTheme.Settings alias SimpleshopTheme.Settings
setup do setup do
@ -77,7 +79,7 @@ defmodule SimpleshopThemeWeb.Admin.SettingsTest do
html = html =
view view
|> form("form", %{stripe: %{api_key: ""}}) |> form(~s(form[phx-submit="connect_stripe"]), %{stripe: %{api_key: ""}})
|> render_submit() |> render_submit()
assert html =~ "Please enter your Stripe secret key" assert html =~ "Please enter your Stripe secret key"
@ -106,7 +108,9 @@ defmodule SimpleshopThemeWeb.Admin.SettingsTest do
html = html =
view view
|> form("form", %{webhook: %{signing_secret: "whsec_test_manual_456"}}) |> form(~s(form[phx-submit="save_signing_secret"]), %{
webhook: %{signing_secret: "whsec_test_manual_456"}
})
|> render_submit() |> render_submit()
assert html =~ "Webhook signing secret saved" assert html =~ "Webhook signing secret saved"
@ -118,7 +122,7 @@ defmodule SimpleshopThemeWeb.Admin.SettingsTest do
html = html =
view view
|> form("form", %{webhook: %{signing_secret: ""}}) |> form(~s(form[phx-submit="save_signing_secret"]), %{webhook: %{signing_secret: ""}})
|> render_submit() |> render_submit()
assert html =~ "Please enter a signing secret" assert html =~ "Please enter a signing secret"
@ -135,4 +139,118 @@ defmodule SimpleshopThemeWeb.Admin.SettingsTest do
refute Settings.has_secret?("stripe_api_key") refute Settings.has_secret?("stripe_api_key")
end end
end end
describe "products section" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn}
end
test "shows connect button when no provider connected", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/admin/settings")
assert html =~ "Products"
assert html =~ "Not connected"
assert has_element?(view, ~s(a[href="/admin/providers/new"]), "Connect to Printify")
end
test "shows connection info when provider connected", %{conn: conn} do
conn_record = provider_connection_fixture(%{name: "Test Shop"})
product_fixture(%{provider_connection: conn_record})
{:ok, _view, html} = live(conn, ~p"/admin/settings")
assert html =~ "Connected"
assert html =~ "Test Shop"
assert html =~ "Sync products"
end
end
describe "account section" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn, user: user}
end
test "renders email and password forms", %{conn: conn, user: user} do
{:ok, view, html} = live(conn, ~p"/admin/settings")
assert html =~ "Account"
assert html =~ user.email
assert has_element?(view, "#email_form")
assert has_element?(view, "#password_form")
end
test "validates email change", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings")
result =
view
|> element("#email_form")
|> render_change(%{"user" => %{"email" => "with spaces"}})
assert result =~ "must have the @ sign and no spaces"
end
test "submits email change", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings")
result =
view
|> form("#email_form", %{"user" => %{"email" => unique_user_email()}})
|> render_submit()
assert result =~ "A link to confirm your email"
end
test "validates password", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings")
result =
view
|> element("#password_form")
|> render_change(%{
"user" => %{
"password" => "short",
"password_confirmation" => "mismatch"
}
})
assert result =~ "should be at least 12 character(s)"
end
test "submits valid password change", %{conn: conn, user: user} do
new_password = valid_user_password()
{:ok, view, _html} = live(conn, ~p"/admin/settings")
form =
form(view, "#password_form", %{
"user" => %{
"email" => user.email,
"password" => new_password,
"password_confirmation" => new_password
}
})
render_submit(form)
new_password_conn = follow_trigger_action(form, conn)
assert redirected_to(new_password_conn) == ~p"/admin/settings"
assert Accounts.get_user_by_email_and_password(user.email, new_password)
end
end
describe "advanced section" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn}
end
test "shows links to system tools", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings")
assert has_element?(view, ~s(a[href="/admin/dashboard"]), "System dashboard")
assert has_element?(view, ~s(a[href="/admin/errors"]), "Error tracker")
end
end
end end

View File

@ -5,159 +5,18 @@ defmodule SimpleshopThemeWeb.Auth.SettingsTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import SimpleshopTheme.AccountsFixtures import SimpleshopTheme.AccountsFixtures
describe "Settings page" do describe "settings redirect" do
test "renders settings page", %{conn: conn} do test "redirects to admin settings when logged in", %{conn: conn} do
{:ok, _lv, html} = conn = log_in_user(conn, user_fixture())
conn assert {:error, {:redirect, %{to: "/admin/settings"}}} = live(conn, ~p"/users/settings")
|> log_in_user(user_fixture())
|> live(~p"/users/settings")
assert html =~ "Change Email"
assert html =~ "Save Password"
end end
test "redirects if user is not logged in", %{conn: conn} do test "redirects to login when not logged in", %{conn: conn} do
assert {:error, redirect} = live(conn, ~p"/users/settings") assert {:error, redirect} = live(conn, ~p"/users/settings")
assert {:redirect, %{to: path, flash: flash}} = redirect assert {:redirect, %{to: path, flash: flash}} = redirect
assert path == ~p"/users/log-in" assert path == ~p"/users/log-in"
assert %{"error" => "You must log in to access this page."} = flash assert %{"error" => "You must log in to access this page."} = flash
end end
test "redirects if user is not in sudo mode", %{conn: conn} do
{:ok, conn} =
conn
|> log_in_user(user_fixture(),
token_authenticated_at: DateTime.add(DateTime.utc_now(:second), -11, :minute)
)
|> live(~p"/users/settings")
|> follow_redirect(conn, ~p"/users/log-in")
assert conn.resp_body =~ "You must re-authenticate to access this page."
end
end
describe "update email form" do
setup %{conn: conn} do
user = user_fixture()
%{conn: log_in_user(conn, user), user: user}
end
test "updates the user email", %{conn: conn, user: user} do
new_email = unique_user_email()
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> form("#email_form", %{
"user" => %{"email" => new_email}
})
|> render_submit()
assert result =~ "A link to confirm your email"
assert Accounts.get_user_by_email(user.email)
end
test "renders errors with invalid data (phx-change)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> element("#email_form")
|> render_change(%{
"action" => "update_email",
"user" => %{"email" => "with spaces"}
})
assert result =~ "Change Email"
assert result =~ "must have the @ sign and no spaces"
end
test "renders errors with invalid data (phx-submit)", %{conn: conn, user: user} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> form("#email_form", %{
"user" => %{"email" => user.email}
})
|> render_submit()
assert result =~ "Change Email"
assert result =~ "did not change"
end
end
describe "update password form" do
setup %{conn: conn} do
user = user_fixture()
%{conn: log_in_user(conn, user), user: user}
end
test "updates the user password", %{conn: conn, user: user} do
new_password = valid_user_password()
{:ok, lv, _html} = live(conn, ~p"/users/settings")
form =
form(lv, "#password_form", %{
"user" => %{
"email" => user.email,
"password" => new_password,
"password_confirmation" => new_password
}
})
render_submit(form)
new_password_conn = follow_trigger_action(form, conn)
assert redirected_to(new_password_conn) == ~p"/users/settings"
assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token)
assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~
"Password updated successfully"
assert Accounts.get_user_by_email_and_password(user.email, new_password)
end
test "renders errors with invalid data (phx-change)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> element("#password_form")
|> render_change(%{
"user" => %{
"password" => "too short",
"password_confirmation" => "does not match"
}
})
assert result =~ "Save Password"
assert result =~ "should be at least 12 character(s)"
assert result =~ "does not match password"
end
test "renders errors with invalid data (phx-submit)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> form("#password_form", %{
"user" => %{
"password" => "too short",
"password_confirmation" => "does not match"
}
})
|> render_submit()
assert result =~ "Save Password"
assert result =~ "should be at least 12 character(s)"
assert result =~ "does not match password"
end
end end
describe "confirm email" do describe "confirm email" do
@ -176,33 +35,30 @@ defmodule SimpleshopThemeWeb.Auth.SettingsTest do
test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do
{:error, redirect} = live(conn, ~p"/users/settings/confirm-email/#{token}") {:error, redirect} = live(conn, ~p"/users/settings/confirm-email/#{token}")
assert {:live_redirect, %{to: path, flash: flash}} = redirect assert {:redirect, %{to: "/admin/settings", flash: flash}} = redirect
assert path == ~p"/users/settings" assert %{"info" => "Email changed successfully."} = flash
assert %{"info" => message} = flash
assert message == "Email changed successfully."
refute Accounts.get_user_by_email(user.email) refute Accounts.get_user_by_email(user.email)
assert Accounts.get_user_by_email(email) assert Accounts.get_user_by_email(email)
# use confirm token again # use confirm token again
{:error, redirect} = live(conn, ~p"/users/settings/confirm-email/#{token}") {:error, redirect} = live(conn, ~p"/users/settings/confirm-email/#{token}")
assert {:live_redirect, %{to: path, flash: flash}} = redirect
assert path == ~p"/users/settings" assert {:redirect, %{to: "/admin/settings", flash: flash}} = redirect
assert %{"error" => message} = flash assert %{"error" => "Email change link is invalid or it has expired."} = flash
assert message == "Email change link is invalid or it has expired."
end end
test "does not update email with invalid token", %{conn: conn, user: user} do test "does not update email with invalid token", %{conn: conn, user: user} do
{:error, redirect} = live(conn, ~p"/users/settings/confirm-email/oops") {:error, redirect} = live(conn, ~p"/users/settings/confirm-email/oops")
assert {:live_redirect, %{to: path, flash: flash}} = redirect
assert path == ~p"/users/settings" assert {:redirect, %{to: "/admin/settings", flash: flash}} = redirect
assert %{"error" => message} = flash assert %{"error" => "Email change link is invalid or it has expired."} = flash
assert message == "Email change link is invalid or it has expired."
assert Accounts.get_user_by_email(user.email) assert Accounts.get_user_by_email(user.email)
end end
test "redirects if user is not logged in", %{token: token} do test "redirects if user is not logged in", %{token: token} do
conn = build_conn() conn = build_conn()
{:error, redirect} = live(conn, ~p"/users/settings/confirm-email/#{token}") {:error, redirect} = live(conn, ~p"/users/settings/confirm-email/#{token}")
assert {:redirect, %{to: path, flash: flash}} = redirect assert {:redirect, %{to: path, flash: flash}} = redirect
assert path == ~p"/users/log-in" assert path == ~p"/users/log-in"
assert %{"error" => message} = flash assert %{"error" => message} = flash

View File

@ -80,7 +80,7 @@ defmodule SimpleshopThemeWeb.UserAuthTest do
|> assign(:current_scope, Scope.for_user(user)) |> assign(:current_scope, Scope.for_user(user))
|> UserAuth.log_in_user(user) |> UserAuth.log_in_user(user)
assert redirected_to(conn) == ~p"/users/settings" assert redirected_to(conn) == ~p"/admin/settings"
end end
test "writes a cookie if remember_me was set in previous session", %{conn: conn, user: user} do test "writes a cookie if remember_me was set in previous session", %{conn: conn, user: user} do