Return to create form after validation error for Ash authentication

I use this controller to setup a new registration form:

defmodule AniminaWeb.AccountsController do
  use AniminaWeb, :controller

  alias Animina.Accounts
  alias Animina.Accounts.User
  alias AshPhoenix.Form

  def register(conn, _params) do
    conn =
      conn
      |> assign(:form_id, "sign-up-form")
      |> assign(:cta, "Account anlegen")
      |> assign(:action, ~p"/auth/user/password/register")
      |> assign(:is_register?, true)
      |> assign(
        :form,
        Form.for_create(User, :register_with_password, api: Accounts, as: "user")
      )

    render(conn, :register, layout: false)
  end
end

It renders this template:

<.top_navigation />

<div class="py-6 space-y-10 px-5">
  <div class="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
    <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
      <ul class="error-messages">
        <%= if @form.errors do %>
          <%= for {k, v} <- @errors do %>
            <li>
              <%= humanize("#{k} #{v}") %>
            </li>
          <% end %>
        <% end %>
      </ul>

      <.form :let={f} for={@form} action={@action} method="POST" class="space-y-6">
        <%= if @is_register? do %>
          <div>
            <label for="username" class="block text-sm font-medium leading-6 text-gray-900">
              Username
            </label>
            <div class="mt-2">
              <%= text_input(f, :username,
                class:
                  "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6",
                placeholder: "Pusteblume1977",
                type: :text,
                required: true,
                autofocus: true
              ) %>
            </div>
          </div>
        <% end %>

        <div>
          <label for="email" class="block text-sm font-medium leading-6 text-gray-900">
            E-Mail Addresse
          </label>
          <div class="mt-2">
            <%= text_input(f, :email,
              class:
                "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6",
              placeholder: "eddie@beispiel.de",
              type: :email,
              required: true
            ) %>
          </div>
        </div>

        <div>
          <div class="flex items-center justify-between">
            <label for="password" class="block text-sm font-medium leading-6 text-gray-900">
              Passwort
            </label>
            <%= unless @is_register? do %>
              <div class="text-sm">
                <a href="#" class="font-semibold text-indigo-600 hover:text-indigo-500">
                  Passwort vergessen?
                </a>
              </div>
            <% end %>
          </div>
          <div class="mt-2">
            <%= password_input(f, :password,
              class:
                "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6",
              placeholder: "Passwort"
            ) %>
          </div>
        </div>

        <div>
          <%= submit(@cta,
            class:
              "flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
          ) %>
        </div>
      </.form>
    </div>
  </div>
</div>

Any validation error leads to a call of this function in AuthController:

  def failure(conn, _activity, _reason) do
    conn
    |> put_status(401)
    |> render("failure.html")
  end

But I’d like to go back to the original create form and display it with all the values and the validation errors. Do I have to code this in the failure/3 function or is there an AshAuthentication switch which I can use for this?

Yes, you’d put the redirect in your failure/3 handler. I think the rendering of failure.html should eventually be removed from the guide and the default behavior should be to redirect.

What is the best way to solve this? The following code doesn’t display the old values and doesn’t give the validation errors:

  def failure(conn, _activity, _reason) do
    conn =
      conn
      |> assign(:form_id, "sign-up-form")
      |> assign(:cta, "Create account")
      |> assign(:action, ~p"/auth/user/password/register")
      |> assign(:is_register?, true)
      |> assign(
        :form,
        Form.for_create(User, :register_with_password, api: Accounts, as: "user")
      )

    render(conn, :register, layout: false)
  end

The tooling clearly could use some improvement for the non-live view cases here.

@jimsynz might be able to help more than me. Does the reason contain a failed form in this case? Or just the error from the form? Or perhaps the conn contains the failed form in the assigns?

What you’d want to do ideally is reassigned the form that failed to submit. That would render errors and old values.

How can I use this reason to assign the needed values to the new form?

iex(8)> reason
%Ash.Error.Invalid{
  errors: [
    %Ash.Error.Changes.InvalidArgument{
      field: :password,
      message: "length must be greater than or equal to %{min}",
      value: "1234",
      changeset: nil,
      query: nil,
      error_context: [],
      vars: [min: 8],
      path: [],
      stacktrace: #Stacktrace<>,
      class: :invalid
    }
  ],
  stacktraces?: true,
  changeset: #Ash.Changeset<
    api: Animina.Accounts,
    action_type: :create,
    action: :register_with_password,
    attributes: %{
      id: "1a2be27e-d2eb-41a8-955a-013a30035fe4",
      username: "example",
      email: #Ash.CiString<"example@example.com">
    },
    relationships: %{},
    arguments: %{},
    errors: [
      %Ash.Error.Changes.InvalidArgument{
        field: :password,
        message: "length must be greater than or equal to %{min}",
        value: "1234",
        changeset: nil,
        query: nil,
        error_context: [],
        vars: [min: 8],
        path: [],
        stacktrace: #Stacktrace<>,
        class: :invalid
      }
    ],
    data: #Animina.Accounts.User<
      __meta__: #Ecto.Schema.Metadata<:built, "users">,
      id: nil,
      email: nil,
      username: nil,
      aggregates: %{},
      calculations: %{},
      ...
    >,
    context: %{authorize?: false, actor: nil},
    valid?: false
  >,
  query: nil,
  error_context: [nil],
  vars: [],
  path: [],
  stacktrace: #Stacktrace<>,
  class: :invalid
}

I’m adding a new utility to AshPhoenix to support this. Specifically, it will be AshPhoenix.Form.add_error(form, error).

Is there a way to add reason.changeset to the form so that I at have the old values?

Oh, interesting. You can replace the form source, i.e %{form | source: reason.changeset}

Oh, interesting. You can replace the form source, i.e %{form | source: reason.changeset}. I’ve added AshPhoenix.Form.add_error/3 to ash_phoenix main that you can try as well.

I don’t understand that because this doesn’t work (because form is not there):

    conn =
      conn
      |> assign(:form_id, "sign-up-form")
      |> assign(:cta, "Account anlegen")
      |> assign(:action, ~p"/auth/user/password/register")
      |> assign(:is_register?, true)
      |> assign(
        :form,
        %{form | source: reason.changeset}
      )

The error:

    error: undefined variable "form"
    │
 30 │         %{form | source: reason.changeset}
    │           ^^^^
    │
    └─ lib/animina_web/controllers/auth_controller.ex:30:11: AniminaWeb.AuthController.failure/3

Right, I was referring the form that you were creating manually Form.for_create(User, :register_with_password, api: Accounts, as: "user")

I think in your case, the best thing to do would be something like this:

# not all failures will have the changeset. this signals a design issue to me, specifically we should always provide the params that failed. This will need to be added in a future release. Until then this should work well enough.

params = 
  if reason.changeset do
    reason.changeset.params
  else
    %{}
  end

      ....
      |> assign(
        :form,
        Form.for_create(User, :register_with_password, api: Accounts, as: "user", params: params)
      )
1 Like