189 lines
6.2 KiB
Elixir
189 lines
6.2 KiB
Elixir
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"""
|
|
<div class="min-h-screen flex flex-col items-center justify-center bg-base-100 p-4">
|
|
<div class="w-full max-w-sm mb-8 text-center">
|
|
<.live_component
|
|
module={AshAuthentication.Phoenix.Components.Banner}
|
|
id="magic-sign-in-banner"
|
|
overrides={@overrides}
|
|
/>
|
|
</div>
|
|
|
|
<div class="w-full max-w-sm p-6 bg-base-100 border border-base-200 rounded-xl shadow-sm">
|
|
<.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 --%>
|
|
<MixerWeb.AuthComponents.username_field :if={@needs_username?} form={form} />
|
|
|
|
{submit("Sign in", class: "btn btn-primary w-full", phx_disable_with: "Signing in...")}
|
|
</.form>
|
|
</div>
|
|
</div>
|
|
"""
|
|
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
|