diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs
index a7268ef8..ad008767 100644
--- a/.dialyzer_ignore.exs
+++ b/.dialyzer_ignore.exs
@@ -143,6 +143,10 @@
# Pre-existing false positive unrelated to UUID migration
{"lib/modules/entities/entities.ex", :pattern_match},
+ # UUID FK columns migration - prefix parameter is typed as binary() by Dialyzer
+ # but nil is a valid runtime value (no prefix configured)
+ {"lib/phoenix_kit/migrations/uuid_fk_columns.ex", :pattern_match},
+
# ExUnit.CaseTemplate macro generates calls to internal ExUnit functions
# that Dialyzer cannot resolve (Elixir 1.18+ internal API changes)
{"test/support/conn_case.ex", :unknown_function},
diff --git a/lib/modules/shop/import/product_transformer.ex b/lib/modules/shop/import/product_transformer.ex
index bf8bf5e8..41dce10a 100644
--- a/lib/modules/shop/import/product_transformer.ex
+++ b/lib/modules/shop/import/product_transformer.ex
@@ -174,9 +174,9 @@ defmodule PhoenixKit.Modules.Shop.Import.ProductTransformer do
{:error, _changeset} ->
# Unique constraint hit - category was created by concurrent process, fetch it
case Shop.get_category_by_slug_localized(slug, language) do
- {:ok, %{id: id}} ->
- Logger.info("Category #{slug} already exists (id: #{id}), using existing")
- id
+ {:ok, %{uuid: uuid}} ->
+ Logger.info("Category #{slug} already exists (uuid: #{uuid}), using existing")
+ uuid
{:error, :not_found} ->
Logger.warning("Failed to create or find category: #{slug}")
diff --git a/lib/modules/shop/import/prom_ua_format.ex b/lib/modules/shop/import/prom_ua_format.ex
index 9997b805..a27d648b 100644
--- a/lib/modules/shop/import/prom_ua_format.ex
+++ b/lib/modules/shop/import/prom_ua_format.ex
@@ -224,8 +224,8 @@ defmodule PhoenixKit.Modules.Shop.Import.PromUaFormat do
lang = Translations.default_language()
case Shop.get_category_by_slug_localized(slug, lang) do
- {:ok, %{id: id}} ->
- id
+ {:ok, %{uuid: uuid}} ->
+ uuid
{:error, :not_found} ->
attrs = %{
@@ -237,7 +237,7 @@ defmodule PhoenixKit.Modules.Shop.Import.PromUaFormat do
case Shop.create_category(attrs) do
{:ok, category} ->
Logger.info("Auto-created Prom.ua category: #{slug} (#{group_name})")
- category.id
+ category.uuid
{:error, changeset} ->
Logger.warning("Failed to create category #{slug}: #{inspect(changeset.errors)}")
diff --git a/lib/modules/shop/schemas/cart.ex b/lib/modules/shop/schemas/cart.ex
index 674bf52f..b521c74c 100644
--- a/lib/modules/shop/schemas/cart.ex
+++ b/lib/modules/shop/schemas/cart.ex
@@ -233,7 +233,8 @@ defmodule PhoenixKit.Modules.Shop.Cart do
defp validate_status_transition(changeset, from, to) do
valid_transitions = %{
- "active" => ~w(merged converted abandoned expired),
+ "active" => ~w(converting merged converted abandoned expired),
+ "converting" => ~w(converted active),
"merged" => [],
"converted" => [],
"abandoned" => ~w(active),
diff --git a/lib/modules/shop/shop.ex b/lib/modules/shop/shop.ex
index e88f8413..d00543cf 100644
--- a/lib/modules/shop/shop.ex
+++ b/lib/modules/shop/shop.ex
@@ -2134,9 +2134,13 @@ defmodule PhoenixKit.Modules.Shop do
:ok <- maybe_send_guest_confirmation(user_id) do
{:ok, order}
else
- error ->
- # Rollback transaction on any error
- repo().rollback(error)
+ {:error, reason} ->
+ # Rollback transaction on any error, unwrapping the {:error, _} tuple
+ # so the transaction returns {:error, reason} (not {:error, {:error, reason}})
+ repo().rollback(reason)
+
+ other ->
+ repo().rollback(other)
end
end)
# unwrap the transaction result
diff --git a/lib/modules/shop/web/catalog_category.ex b/lib/modules/shop/web/catalog_category.ex
index 1d6e1e4e..cd9227c1 100644
--- a/lib/modules/shop/web/catalog_category.ex
+++ b/lib/modules/shop/web/catalog_category.ex
@@ -576,10 +576,10 @@ defmodule PhoenixKit.Modules.Shop.Web.CatalogCategory do
# Get signed URL for Storage image
defp get_storage_image_url(file_id, variant) do
case Storage.get_file(file_id) do
- %{id: id} ->
- case Storage.get_file_instance_by_name(id, variant) do
+ %{uuid: uuid} ->
+ case Storage.get_file_instance_by_name(uuid, variant) do
nil ->
- case Storage.get_file_instance_by_name(id, "original") do
+ case Storage.get_file_instance_by_name(uuid, "original") do
nil -> nil
_instance -> URLSigner.signed_url(file_id, "original")
end
diff --git a/lib/modules/shop/web/catalog_product.ex b/lib/modules/shop/web/catalog_product.ex
index 1b929474..8e761cd5 100644
--- a/lib/modules/shop/web/catalog_product.ex
+++ b/lib/modules/shop/web/catalog_product.ex
@@ -1143,12 +1143,12 @@ defmodule PhoenixKit.Modules.Shop.Web.CatalogProduct do
defp get_storage_image_url(file_id, variant) do
# Storage.get_file/1 returns %File{} struct or nil (not {:ok, file} tuple)
case Storage.get_file(file_id) do
- %{id: id} = _file ->
+ %{uuid: uuid} = _file ->
# Check if requested variant exists, fall back to original if not
- case Storage.get_file_instance_by_name(id, variant) do
+ case Storage.get_file_instance_by_name(uuid, variant) do
nil ->
# Variant doesn't exist - try original
- case Storage.get_file_instance_by_name(id, "original") do
+ case Storage.get_file_instance_by_name(uuid, "original") do
nil -> placeholder_image_url()
_instance -> URLSigner.signed_url(file_id, "original")
end
diff --git a/lib/modules/shop/web/checkout_complete.ex b/lib/modules/shop/web/checkout_complete.ex
index af71b891..ccb92f1f 100644
--- a/lib/modules/shop/web/checkout_complete.ex
+++ b/lib/modules/shop/web/checkout_complete.ex
@@ -118,18 +118,22 @@ defmodule PhoenixKit.Modules.Shop.Web.CheckoutComplete do
<%!-- Guest Order Email Confirmation Reminder --%>
<%= if @is_guest_order do %>
-
+
- <.icon name="hero-envelope" class="w-8 h-8 text-warning flex-shrink-0" />
+ <.icon name="hero-envelope" class="w-8 h-8 text-info flex-shrink-0" />
-
Please confirm your email
+
Check your inbox
We've sent a confirmation email to {@order_email} .
- Please click the link in the email to verify your address.
-
- Your order will remain in "pending" status until your email is confirmed.
+
+ Open the email titled "Confirm your account"
+ Click the confirmation link inside
+ Your account will be activated and you can track your order
+
+
+ Don't see it? Check your spam or junk folder. The email may take a minute to arrive.
@@ -142,9 +146,11 @@ defmodule PhoenixKit.Modules.Shop.Web.CheckoutComplete do
Order Number
{@order.order_number}
-
- A confirmation email will be sent to your email address.
-
+ <%= unless @is_guest_order do %>
+
+ A confirmation email will be sent to your email address.
+
+ <% end %>
diff --git a/lib/modules/shop/web/checkout_page.ex b/lib/modules/shop/web/checkout_page.ex
index 73403c36..4ea3f32e 100644
--- a/lib/modules/shop/web/checkout_page.ex
+++ b/lib/modules/shop/web/checkout_page.ex
@@ -140,6 +140,7 @@ defmodule PhoenixKit.Modules.Shop.Web.CheckoutPage do
|> assign(:step, assigns.initial_step)
|> assign(:processing, false)
|> assign(:error_message, nil)
+ |> assign(:email_exists_error, false)
|> assign(:form_errors, %{})
end
@@ -410,11 +411,8 @@ defmodule PhoenixKit.Modules.Shop.Web.CheckoutPage do
{:noreply,
socket
|> assign(:processing, false)
- |> assign(
- :error_message,
- "An account with this email already exists. Please log in to continue."
- )
- |> put_flash(:error, "Email already registered. Please log in.")}
+ |> assign(:email_exists_error, true)
+ |> assign(:error_message, nil)}
{:error, _reason} ->
{:noreply,
@@ -540,15 +538,15 @@ defmodule PhoenixKit.Modules.Shop.Web.CheckoutPage do
Review & Confirm
- <%!-- Guest Checkout Warning --%>
+ <%!-- Guest Checkout Info --%>
<%= if @is_guest do %>
-
- <.icon name="hero-exclamation-triangle" class="w-5 h-5" />
+
+ <.icon name="hero-envelope" class="w-5 h-5" />
-
Email confirmation required
+
Checking out as a guest
- Your order will require email verification. After checkout, you will receive
- a confirmation email. Please click the link to verify your email address.
+ After placing your order, we'll send a confirmation email to verify your address.
+ Check your inbox and click the link to activate your account and track your order.
@@ -587,6 +585,7 @@ defmodule PhoenixKit.Modules.Shop.Web.CheckoutPage do
currency={@currency}
processing={@processing}
error_message={@error_message}
+ email_exists_error={@email_exists_error}
selected_payment_option={@selected_payment_option}
needs_billing={@needs_billing}
payment_options={@payment_options}
@@ -1101,6 +1100,33 @@ defmodule PhoenixKit.Modules.Shop.Web.CheckoutPage do
+ <%!-- Email Already Registered --%>
+ <%= if @email_exists_error do %>
+
+
+
+ <.icon name="hero-user-circle" class="w-8 h-8 text-warning flex-shrink-0" />
+
+
Account already exists
+
+ An account with this email is already registered.
+ Please log in to complete your order.
+
+
+ <.link
+ navigate={Routes.path("/users/log-in") <> "?return_to=" <> Routes.path("/checkout")}
+ class="btn btn-primary btn-sm"
+ >
+ <.icon name="hero-arrow-right-on-rectangle" class="w-4 h-4 mr-1" />
+ Log in to continue
+
+
+
+
+
+
+ <% end %>
+
<%!-- Error Message --%>
<%= if @error_message do %>
diff --git a/lib/modules/shop/web/product_detail.ex b/lib/modules/shop/web/product_detail.ex
index 11ef298d..0baa8889 100644
--- a/lib/modules/shop/web/product_detail.ex
+++ b/lib/modules/shop/web/product_detail.ex
@@ -664,10 +664,10 @@ defmodule PhoenixKit.Modules.Shop.Web.ProductDetail do
defp get_storage_image_url(file_id, variant) do
case Storage.get_file(file_id) do
- %{id: id} ->
- case Storage.get_file_instance_by_name(id, variant) do
+ %{uuid: uuid} ->
+ case Storage.get_file_instance_by_name(uuid, variant) do
nil ->
- case Storage.get_file_instance_by_name(id, "original") do
+ case Storage.get_file_instance_by_name(uuid, "original") do
nil -> nil
_instance -> URLSigner.signed_url(file_id, "original")
end
diff --git a/lib/modules/shop/web/products.ex b/lib/modules/shop/web/products.ex
index bc03ee14..1ab79386 100644
--- a/lib/modules/shop/web/products.ex
+++ b/lib/modules/shop/web/products.ex
@@ -756,10 +756,10 @@ defmodule PhoenixKit.Modules.Shop.Web.Products do
defp get_storage_image_url(file_id, variant) do
case Storage.get_file(file_id) do
- %{id: id} ->
- case Storage.get_file_instance_by_name(id, variant) do
+ %{uuid: uuid} ->
+ case Storage.get_file_instance_by_name(uuid, variant) do
nil ->
- case Storage.get_file_instance_by_name(id, "original") do
+ case Storage.get_file_instance_by_name(uuid, "original") do
nil -> nil
_instance -> URLSigner.signed_url(file_id, "original")
end
diff --git a/lib/modules/shop/web/shop_catalog.ex b/lib/modules/shop/web/shop_catalog.ex
index 20e5e769..f8fde07a 100644
--- a/lib/modules/shop/web/shop_catalog.ex
+++ b/lib/modules/shop/web/shop_catalog.ex
@@ -501,10 +501,10 @@ defmodule PhoenixKit.Modules.Shop.Web.ShopCatalog do
# Get signed URL for Storage image
defp get_storage_image_url(file_id, variant) do
case Storage.get_file(file_id) do
- %{id: id} ->
- case Storage.get_file_instance_by_name(id, variant) do
+ %{uuid: uuid} ->
+ case Storage.get_file_instance_by_name(uuid, variant) do
nil ->
- case Storage.get_file_instance_by_name(id, "original") do
+ case Storage.get_file_instance_by_name(uuid, "original") do
nil -> nil
_instance -> URLSigner.signed_url(file_id, "original")
end
diff --git a/lib/phoenix_kit/migrations/uuid_fk_columns.ex b/lib/phoenix_kit/migrations/uuid_fk_columns.ex
index 39d085f8..a3bba4f3 100644
--- a/lib/phoenix_kit/migrations/uuid_fk_columns.ex
+++ b/lib/phoenix_kit/migrations/uuid_fk_columns.ex
@@ -242,6 +242,18 @@ defmodule PhoenixKit.Migrations.UUIDFKColumns do
{:phoenix_kit_referral_code_usage, "code_uuid"}
]
+ # ── Legacy Integer FK Columns to Relax (DROP NOT NULL) ──────────────────
+ # Integer FK columns where the Ecto schema now exclusively uses UUID FKs.
+ # Keeping NOT NULL on these causes insert failures when code only writes UUIDs.
+ # Applied AFTER UUID NOT NULL constraints are set (UUID is the new primary path).
+
+ @relax_integer_fks [
+ {:phoenix_kit_user_role_assignments, "user_id"},
+ {:phoenix_kit_user_role_assignments, "role_id"},
+ {:phoenix_kit_user_role_assignments, "assigned_by"},
+ {:phoenix_kit_role_permissions, "role_id"}
+ ]
+
# ── FK Constraints for UUID FK columns ──────────────────────────────────
# Only where the integer FK has an explicit DB-level FK constraint.
# ON DELETE behavior matches the integer FK's behavior.
@@ -414,6 +426,13 @@ defmodule PhoenixKit.Migrations.UUIDFKColumns do
for {table, uuid_fk, ref_table, ref_col, on_delete} <- @fk_constraints do
add_fk_constraint(table, uuid_fk, ref_table, ref_col, on_delete, prefix, escaped_prefix)
end
+
+ # Relax NOT NULL on legacy integer FK columns where Ecto schemas
+ # now exclusively write UUID FKs. Without this, inserts that only
+ # populate UUID columns fail with NOT NULL violations.
+ for {table, int_fk} <- @relax_integer_fks do
+ relax_integer_not_null(table, int_fk, prefix, escaped_prefix)
+ end
end
@doc """
@@ -526,7 +545,7 @@ defmodule PhoenixKit.Migrations.UUIDFKColumns do
execute("""
ALTER TABLE #{table_name}
- ADD COLUMN #{uuid_fk} UUID
+ ADD COLUMN IF NOT EXISTS #{uuid_fk} UUID
""")
end
end
@@ -637,6 +656,37 @@ defmodule PhoenixKit.Migrations.UUIDFKColumns do
end
end
+ # ── Legacy Integer FK Relaxation ─────────────────────────────────────
+
+ defp relax_integer_not_null(table, int_fk, prefix, escaped_prefix) do
+ table_str = Atom.to_string(table)
+
+ if table_exists?(table_str, escaped_prefix) and
+ column_exists?(table_str, int_fk, escaped_prefix) and
+ column_is_not_null?(table_str, int_fk, escaped_prefix) do
+ table_name = prefix_table_name(table_str, prefix)
+
+ execute("""
+ ALTER TABLE #{table_name}
+ ALTER COLUMN #{int_fk} DROP NOT NULL
+ """)
+ end
+ end
+
+ defp column_is_not_null?(table_str, column_str, escaped_prefix) do
+ query = """
+ SELECT is_nullable FROM information_schema.columns
+ WHERE table_name = '#{table_str}'
+ AND column_name = '#{column_str}'
+ AND table_schema = '#{escaped_prefix}'
+ """
+
+ case repo().query(query, [], log: false) do
+ {:ok, %{rows: [["NO"]]}} -> true
+ _ -> false
+ end
+ end
+
# ── FK Constraint Operations ──────────────────────────────────────────
defp add_fk_constraint(table, uuid_fk, ref_table, ref_col, on_delete, prefix, escaped_prefix) do
diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex
index b7af6489..0adc0657 100644
--- a/lib/phoenix_kit_web/components/layout_wrapper.ex
+++ b/lib/phoenix_kit_web/components/layout_wrapper.ex
@@ -71,7 +71,7 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do
attr :page_title, :string, default: nil
attr :current_path, :string, default: nil
attr :inner_content, :string, default: nil
- attr :project_title, :string, default: "PhoenixKit"
+ attr :project_title, :string, default: nil
attr :current_locale, :string, default: nil
slot :inner_block, required: false
diff --git a/lib/phoenix_kit_web/live/modules.html.heex b/lib/phoenix_kit_web/live/modules.html.heex
index 933cebf1..81a2ee8d 100644
--- a/lib/phoenix_kit_web/live/modules.html.heex
+++ b/lib/phoenix_kit_web/live/modules.html.heex
@@ -217,7 +217,7 @@
<%= if @languages_enabled do %>
{if @languages_default,
- do: @languages_default["name"],
+ do: @languages_default.name,
else: "No Default"}
<% end %>
@@ -358,7 +358,7 @@
# Determine default language base code for URL display
default_lang_code =
if @languages_enabled && @languages_default do
- @languages_default["code"]
+ @languages_default.code
|> PhoenixKit.Modules.Languages.DialectMapper.extract_base()
else
nil
diff --git a/lib/phoenix_kit_web/users/auth.ex b/lib/phoenix_kit_web/users/auth.ex
index c4dc1a80..1b74f983 100644
--- a/lib/phoenix_kit_web/users/auth.ex
+++ b/lib/phoenix_kit_web/users/auth.ex
@@ -42,6 +42,7 @@ defmodule PhoenixKitWeb.Users.Auth do
alias PhoenixKit.Modules.Languages
alias PhoenixKit.Modules.Languages.DialectMapper
alias PhoenixKit.Modules.Maintenance
+ alias PhoenixKit.Modules.Shop
alias PhoenixKit.Users.Auth
alias PhoenixKit.Users.Auth.{Scope, User}
alias PhoenixKit.Users.Permissions
@@ -93,6 +94,10 @@ defmodule PhoenixKitWeb.Users.Auth do
token = Auth.generate_user_session_token(user, opts)
user_return_to = get_session(conn, :user_return_to)
+ # Merge guest cart into user cart before session renewal clears session data.
+ # The shop_session_id cookie survives renew_session (only session data is cleared).
+ maybe_merge_guest_cart(conn, user)
+
conn
|> renew_session()
|> put_token_in_session(token)
@@ -100,6 +105,19 @@ defmodule PhoenixKitWeb.Users.Auth do
|> redirect(to: user_return_to || signed_in_path(conn))
end
+ defp maybe_merge_guest_cart(conn, user) do
+ shop_session_id =
+ conn.cookies["shop_session_id"] || get_session(conn, :shop_session_id)
+
+ if shop_session_id do
+ try do
+ Shop.merge_guest_cart(shop_session_id, user)
+ rescue
+ _ -> :ok
+ end
+ end
+ end
+
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
end
diff --git a/lib/phoenix_kit_web/users/login.ex b/lib/phoenix_kit_web/users/login.ex
index 2dc48f77..7fcfbf9c 100644
--- a/lib/phoenix_kit_web/users/login.ex
+++ b/lib/phoenix_kit_web/users/login.ex
@@ -15,7 +15,7 @@ defmodule PhoenixKitWeb.Users.Login do
alias PhoenixKit.Utils.IpAddress
alias PhoenixKit.Utils.Routes
- def mount(_params, session, socket) do
+ def mount(params, session, socket) do
# Track anonymous visitor session
if connected?(socket) do
session_id = session["live_socket_id"] || generate_session_id()
@@ -44,13 +44,17 @@ defmodule PhoenixKitWeb.Users.Login do
form = to_form(%{"email_or_username" => email_or_username}, as: "user")
+ # Support return_to query param for post-login redirect (e.g., from guest checkout)
+ return_to = sanitize_return_to(params["return_to"])
+
socket =
assign(socket,
form: form,
project_title: project_title,
allow_registration: allow_registration,
magic_link_enabled: magic_link_enabled,
- show_language_switcher: show_language_switcher
+ show_language_switcher: show_language_switcher,
+ return_to: return_to
)
{:ok, socket, temporary_assigns: [form: form]}
@@ -63,4 +67,15 @@ defmodule PhoenixKitWeb.Users.Login do
defp generate_session_id do
:crypto.strong_rand_bytes(16) |> Base.encode64()
end
+
+ # Only allow relative paths to prevent open redirect attacks
+ defp sanitize_return_to(path) when is_binary(path) do
+ if String.starts_with?(path, "/") and not String.starts_with?(path, "//") do
+ path
+ else
+ nil
+ end
+ end
+
+ defp sanitize_return_to(_), do: nil
end
diff --git a/lib/phoenix_kit_web/users/login.html.heex b/lib/phoenix_kit_web/users/login.html.heex
index c2f5f37c..8ccba644 100644
--- a/lib/phoenix_kit_web/users/login.html.heex
+++ b/lib/phoenix_kit_web/users/login.html.heex
@@ -15,6 +15,7 @@
action={Routes.path("/users/log-in")}
phx-update="ignore"
>
+
{gettext("Login with Password")}
diff --git a/lib/phoenix_kit_web/users/session.ex b/lib/phoenix_kit_web/users/session.ex
index a1444794..3c436e17 100644
--- a/lib/phoenix_kit_web/users/session.ex
+++ b/lib/phoenix_kit_web/users/session.ex
@@ -55,6 +55,7 @@ defmodule PhoenixKitWeb.Users.Session do
{:ok, user} ->
# Valid credentials and active account
conn
+ |> maybe_store_return_to_from_params(user_params)
|> put_flash(:info, info)
|> UserAuth.log_in_user(user, user_params)
@@ -81,6 +82,18 @@ defmodule PhoenixKitWeb.Users.Session do
|> UserAuth.log_out_user()
end
+ # Store return_to from form params (e.g., guest checkout → login → back to checkout)
+ defp maybe_store_return_to_from_params(conn, %{"return_to" => return_to})
+ when is_binary(return_to) and return_to != "" do
+ if String.starts_with?(return_to, "/") and not String.starts_with?(return_to, "//") do
+ put_session(conn, :user_return_to, return_to)
+ else
+ conn
+ end
+ end
+
+ defp maybe_store_return_to_from_params(conn, _params), do: conn
+
# Support GET logout for direct URL access
def get_logout(conn, _params) do
conn