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. +

    +
  1. Open the email titled "Confirm your account"
  2. +
  3. Click the confirmation link inside
  4. +
  5. Your account will be activated and you can track your order
  6. +
+

+ 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