defmodule MixerWeb.MagicSignInLive do @moduledoc """ Custom magic-link sign-in LiveView that collects a username for new users. When a user clicks their magic link, this page is shown instead of the default auto-submit. If the user is brand new (no account) or has no username set yet, we ask them to choose one before completing sign-in. """ use AshAuthentication.Phoenix.Overrides.Overridable, root_class: "CSS class for the root `div` element.", magic_sign_in_id: "Element ID for the `MagicSignIn` LiveComponent." use AshAuthentication.Phoenix.Web, :live_view alias AshAuthentication.Info alias AshPhoenix.Form alias Phoenix.LiveView.{Rendered, Socket} import AshAuthentication.Phoenix.Components.Helpers, only: [auth_path: 5] import PhoenixHTMLHelpers.Form, only: [hidden_input: 3, submit: 2] import Slug @doc false @impl true def mount(params, session, socket) do overrides = session |> Map.get("overrides", [AshAuthentication.Phoenix.Overrides.Default]) resource = session["resource"] strategy_name = session["strategy"] token = params["token"] || params["magic_link"] strategy = Info.strategy!(resource, strategy_name) subject_name = Info.authentication_subject_name!(resource) domain = Info.authentication_domain!(resource) # Determine whether this user needs to pick a username needs_username? = needs_username?(token, resource, domain) form = resource |> Form.for_action(strategy.sign_in_action_name, params: %{"token" => token}, domain: domain, as: subject_name |> to_string(), id: "#{subject_name}-#{strategy_name}-sign-in-form" |> slugify(), context: %{strategy: strategy, private: %{ash_authentication?: true}} ) socket = socket |> assign(overrides: overrides) |> assign(:token, token) |> assign(:strategy, strategy) |> assign(:subject_name, subject_name) |> assign(:resource, resource) |> assign(:needs_username?, needs_username?) |> assign(:form, form) |> assign(:trigger_action, false) |> assign(:current_tenant, session["tenant"]) |> assign(:auth_routes_prefix, session["auth_routes_prefix"]) {:ok, socket} end @doc false @impl true @spec handle_params(map, String.t(), Socket.t()) :: {:noreply, Socket.t()} def handle_params(_params, _uri, socket), do: {:noreply, socket} @doc false @impl true @spec render(Socket.assigns()) :: Rendered.t() def render(assigns) do ~H"""
<.live_component module={AshAuthentication.Phoenix.Components.Banner} id="magic-sign-in-banner" overrides={@overrides} />
<.form :let={form} for={@form} phx-change="validate" phx-submit="submit" phx-trigger-action={@trigger_action} action={auth_path(@socket, @subject_name, @auth_routes_prefix, @strategy, :sign_in)} method="POST"> {hidden_input(form, :token, [])} <%!-- Using the unified component --%> {submit("Sign in", class: "btn btn-primary w-full", phx_disable_with: "Signing in...")}
""" end @doc false @impl true @spec handle_event(String.t(), map(), Socket.t()) :: {:noreply, Socket.t()} def handle_event("submit", params, socket) do subject_name = socket.assigns.subject_name |> to_string() |> slugify() form_params = Map.get(params, subject_name, %{}) # Use Form.validate with :all_errors to surface uniqueness constraints form = socket.assigns.form |> Form.validate(form_params, errors: true) if form.valid? do # Only trigger the POST redirect if the data is truly valid {:noreply, assign(socket, form: form, trigger_action: true)} else socket = socket |> assign(form: form, trigger_action: false) {:noreply, socket} end end @impl true def handle_event("validate", params, socket) do subject_name = socket.assigns.subject_name |> to_string() |> slugify() form_params = Map.get(params, subject_name, %{}) form = Form.validate(socket.assigns.form, form_params, errors: true) {:noreply, assign(socket, form: form)} end # ── Helpers ────────────────────────────────────────────────────────────────── # Returns true if the user is new or has no username set yet. defp needs_username?(nil, _resource, _domain), do: true defp needs_username?(token, resource, domain) do with {:ok, claims} <- AshAuthentication.Jwt.peek(token), # 1. Try to find an existing user from the claims user <- find_user(claims, resource, domain), # 2. If a user exists, check if they already have a username false <- is_nil(user) do is_nil(user.username) or user.username == "" else _ -> # Unknown / new user — ask for username to be safe true end end defp find_user(claims, resource, domain) do # Try 'sub' first if it looks like a user subject (e.g. "User:123") sub = Map.get(claims, "sub") user = if is_binary(sub) and String.contains?(sub, ":") do case AshAuthentication.subject_to_user(sub, resource) do {:ok, user} -> user _ -> nil end end # If not found via subject, try 'identity' (common in magic link tokens) user || case Map.get(claims, "identity") || Map.get(claims, "email") do email when is_binary(email) -> # Use for_read with the explicit action and arguments resource |> Ash.Query.for_read(:get_by_email, %{email: email}) |> Ash.read_one(domain: domain, authorize?: false) |> case do {:ok, user} -> user _ -> nil end _ -> nil end end end