diff --git a/assets/js/app.js b/assets/js/app.js index 9ac680f..5fe1320 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -48,19 +48,6 @@ liveSocket.connect(); // >> liveSocket.disableLatencySim() window.liveSocket = liveSocket; -// Login is a DeadView form, this adds a nice little spinner for it -document.addEventListener("submit", (e) => { - if (e.target?.id !== "login-form") return; - - const btn = e.target.querySelector("button[type='submit']"); - - if (!btn) return; - - btn.disabled = true; - btn.innerHTML = - 'Connecting…'; -}); - // The lines below enable quality of life phoenix_live_reload // development features: // diff --git a/lib/annot_at/atproto/directory.ex b/lib/annot_at/atproto/directory.ex new file mode 100644 index 0000000..d6aa016 --- /dev/null +++ b/lib/annot_at/atproto/directory.ex @@ -0,0 +1,45 @@ +defmodule AnnotAt.Atproto.Directory do + @moduledoc """ + Public atproto handle search, used for login page typeahead. + """ + + alias AnnotAt.Atproto.HTTP + + @endpoint "https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead" + + @type suggestion :: %{ + handle: String.t(), + display_name: String.t() | nil, + avatar: String.t() | nil + } + + @doc """ + Search typeahead.waow.tech for matching handles, passing the x-client header + for attribution. Doesn't really do error handling to avoid spamming logs, and + it's not a critical code path. + """ + @spec search_handles(String.t()) :: [suggestion()] + def search_handles(query) do + encoded_query = URI.encode_query(q: query, limit: 6) + + url = + @endpoint + |> URI.new!() + |> URI.append_query(encoded_query) + |> URI.to_string() + + case HTTP.get_json(url, headers: [{"x-client", "annot.at"}]) do + {:ok, %{"actors" => actors}} -> + Enum.map(actors, fn actor -> + %{ + handle: actor["handle"], + display_name: actor["displayName"], + avatar: actor["avatar"] + } + end) + + _ -> + [] + end + end +end diff --git a/lib/annot_at/atproto/http.ex b/lib/annot_at/atproto/http.ex index c5251b5..261a4dd 100644 --- a/lib/annot_at/atproto/http.ex +++ b/lib/annot_at/atproto/http.ex @@ -17,8 +17,8 @@ defmodule AnnotAt.Atproto.HTTP do @spec get_json(String.t()) :: {:ok, map()} | {:error, {:http_status, pos_integer()} | {:transport, term()} | :invalid_json} - def get_json(url) when is_binary(url) do - with {:ok, body} <- get_body(url) do + def get_json(url, opts \\ []) when is_binary(url) do + with {:ok, body} <- get_body(url, opts) do case Jason.decode(body) do {:ok, %{} = json} -> {:ok, json} _ -> {:error, :invalid_json} @@ -94,8 +94,10 @@ defmodule AnnotAt.Atproto.HTTP do defp method_atom("GET"), do: :get defp method_atom("POST"), do: :post - defp get_body(url) when is_binary(url) do - case Req.get(url, decode_body: false, receive_timeout: @receive_timeout) do + defp get_body(url, opts \\ []) when is_binary(url) do + opts = Keyword.merge([decode_body: false, receive_timeout: @receive_timeout], opts) + + case Req.get(url, opts) do {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> {:ok, body} {:ok, %Req.Response{status: status}} -> {:error, {:http_status, status}} {:error, reason} -> {:error, {:transport, reason}} diff --git a/lib/annot_at_web/controllers/auth_controller.ex b/lib/annot_at_web/controllers/auth_controller.ex index 4a0d351..0a97661 100644 --- a/lib/annot_at_web/controllers/auth_controller.ex +++ b/lib/annot_at_web/controllers/auth_controller.ex @@ -7,22 +7,6 @@ defmodule AnnotAtWeb.AuthController do alias AnnotAt.Atproto.OAuth.Config alias AnnotAt.Atproto.OAuth.Login - def new(conn, _params) do - render(conn, :new, handle: "") - end - - def create(conn, %{"handle" => handle}) do - case Login.start_login(handle) do - {:ok, url} -> - redirect(conn, external: url) - - {:error, reason} -> - conn - |> put_flash(:error, error_message(reason)) - |> render(:new, handle: handle) - end - end - def callback(conn, params) do case Login.complete_login(params) do {:ok, user} -> @@ -56,7 +40,6 @@ defmodule AnnotAtWeb.AuthController do json(conn, metadata) end - defp error_message(:invalid_handle), do: "That doesn't look like a valid handle." defp error_message(:invalid_state), do: "Your login link expired. Please try again." defp error_message({:oauth_error, _}), do: "Authorization was denied or failed." defp error_message(_reason), do: "Something went wrong. Please try again." diff --git a/lib/annot_at_web/controllers/auth_html/new.html.heex b/lib/annot_at_web/controllers/auth_html/new.html.heex deleted file mode 100644 index 3f5ee54..0000000 --- a/lib/annot_at_web/controllers/auth_html/new.html.heex +++ /dev/null @@ -1,47 +0,0 @@ - -
- <.link href={~p"/"} class="mb-8 font-display text-2xl font-bold tracking-tight"> - annot.at - - -
-

Sign in

-

- Enter your atproto handle to publish to the ATmosphere. -

- - <.form for={%{}} action={~p"/login"} method="post" id="login-form" class="mt-6 space-y-4"> -
- - -
- - - -

- Any atproto handle works, Bluesky or your own domain. -

-
- - <.link href={~p"/"} class="mt-8 text-sm text-ink/55 transition hover:text-ink"> - ← Back to home - -
-
diff --git a/lib/annot_at_web/controllers/live/login_live.ex b/lib/annot_at_web/controllers/live/login_live.ex new file mode 100644 index 0000000..b6432c9 --- /dev/null +++ b/lib/annot_at_web/controllers/live/login_live.ex @@ -0,0 +1,298 @@ +defmodule AnnotAtWeb.LoginLive do + use AnnotAtWeb, :live_view + + alias AnnotAt.Atproto.Directory + alias AnnotAt.Atproto.OAuth.Login + + @min_query_length 2 + + @impl Phoenix.LiveView + def render(assigns) do + ~H""" + +
+ <.link href={~p"/"} class="mb-8 font-display text-2xl font-bold tracking-tight"> + annot.at + + +
+

+ Sign in +

+ +

+ Enter your atproto handle to Publish to the ATmosphere. +

+ + <.form for={@form} phx-change="suggest" phx-submit="login" class="mt-6 space-y-4"> +
+ +
+ + + <.icon name="hero-user" class="size-4" /> + + + {@selected.handle |> String.first() |> String.upcase()} + + + + + + +
    +
  • + +
    + {actor.handle |> String.first() |> String.upcase()} +
    +
    +

    {actor.handle}

    +

    + {actor.display_name} +

    +
    +
  • + +
  • + No matches. +
  • +
+
+
+ + + + +

+ Any atproto handle works, Bluesky, Eurosky, or your own domain. +

+
+ + <.link href={~p"/"} class="mt-8 text-sm text-ink/55 transition hover:text-ink"> + ← Back to home + +
+ + +
+ """ + end + + @impl Phoenix.LiveView + def mount(_params, _session, socket) do + {:ok, + assign(socket, + form: to_form(%{"handle" => ""}), + suggestions: [], + open: false, + selected: nil + )} + end + + @impl Phoenix.LiveView + def handle_event("suggest", %{"handle" => raw}, socket) do + query = + raw + |> String.trim() + |> String.trim_leading("@") + + {open, suggestions} = + if String.length(query) >= @min_query_length do + {true, Directory.search_handles(query)} + else + {false, []} + end + + {:noreply, + assign(socket, + form: to_form(%{"handle" => raw}), + suggestions: suggestions, + open: open, + selected: resolve_selected(suggestions, raw) + )} + end + + def handle_event("select", %{"handle" => handle}, socket) do + {:noreply, + assign(socket, + form: to_form(%{"handle" => handle}), + open: false, + selected: resolve_selected(socket.assigns.suggestions, handle) + )} + end + + def handle_event("close", _params, socket) do + {:noreply, assign(socket, open: false)} + end + + def handle_event("login", %{"handle" => handle}, socket) do + case Login.start_login(handle) do + {:ok, url} -> + {:noreply, redirect(socket, external: url)} + + {:error, reason} -> + socket = + socket + |> put_flash(:error, error_message(reason)) + |> assign(form: to_form(%{"handle" => handle}), open: false) + + {:noreply, socket} + end + end + + defp resolve_selected(suggestions, raw) do + handle = + raw + |> String.trim() + |> String.trim_leading("@") + |> String.downcase() + + Enum.find(suggestions, fn suggestion -> String.downcase(suggestion.handle) == handle end) + end + + defp error_message(:invalid_handle), do: "That doesn't look like a valid handle." + defp error_message(:login_failed), do: "Authorization was denied or failed." +end diff --git a/lib/annot_at_web/router.ex b/lib/annot_at_web/router.ex index 0545094..1439888 100644 --- a/lib/annot_at_web/router.ex +++ b/lib/annot_at_web/router.ex @@ -22,8 +22,9 @@ defmodule AnnotAtWeb.Router do get "/", PageController, :home - get "/login", AuthController, :new - post "/login", AuthController, :create + live_session :login, on_mount: [{AnnotAtWeb.UserAuth, :mount_current_scope}] do + live "/login", LoginLive + end end scope "/", AnnotAtWeb do diff --git a/test/annot_at_web/controllers/auth_controller_test.exs b/test/annot_at_web/controllers/auth_controller_test.exs index 9527c42..781a203 100644 --- a/test/annot_at_web/controllers/auth_controller_test.exs +++ b/test/annot_at_web/controllers/auth_controller_test.exs @@ -7,23 +7,6 @@ defmodule AnnotAtWeb.AuthControllerTest do @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" - defp create_user do - {:ok, user} = - Accounts.upsert_login( - %{did: @did, handle: "alice.test", pds_host: "https://pds.example.com"}, - %{ - auth_server_issuer: "https://bsky.social", - granted_scopes: "atproto", - access_token: "a", - refresh_token: "r", - dpop_private_jwk: "{}", - expires_at: ~U[2026-01-01 01:00:00Z] - } - ) - - user - end - test "GET /oauth-client-metadata.json serves the client metadata", %{conn: conn} do conn = get(conn, ~p"/oauth-client-metadata.json") metadata = json_response(conn, 200) @@ -38,27 +21,6 @@ defmodule AnnotAtWeb.AuthControllerTest do refute Map.has_key?(key, "d") end - test "GET /login renders the form", %{conn: conn} do - conn = get(conn, ~p"/login") - assert html_response(conn, 200) =~ "login-form" - end - - test "POST /login redirects to the authorization URL", %{conn: conn} do - expect(Login, :start_login, fn "alice.test" -> - {:ok, "https://bsky.social/oauth/authorize?x=1"} - end) - - conn = post(conn, ~p"/login", %{"handle" => "alice.test"}) - assert "https://bsky.social/oauth/authorize?x=1" == redirected_to(conn) - end - - test "POST /login re-renders with an error for an invalid handle", %{conn: conn} do - expect(Login, :start_login, fn _ -> {:error, :invalid_handle} end) - - conn = post(conn, ~p"/login", %{"handle" => "nope"}) - assert html_response(conn, 200) =~ "valid handle" - end - test "GET /auth/callback logs in and redirects dashboard", %{conn: conn} do user = create_user() expect(Login, :complete_login, fn _ -> {:ok, user} end) @@ -87,4 +49,21 @@ defmodule AnnotAtWeb.AuthControllerTest do assert ~p"/" == redirected_to(conn) refute get_session(conn, :user_id) end + + defp create_user do + {:ok, user} = + Accounts.upsert_login( + %{did: @did, handle: "alice.test", pds_host: "https://pds.example.com"}, + %{ + auth_server_issuer: "https://bsky.social", + granted_scopes: "atproto", + access_token: "a", + refresh_token: "r", + dpop_private_jwk: "{}", + expires_at: ~U[2026-01-01 01:00:00Z] + } + ) + + user + end end diff --git a/test/annot_at_web/live/login_live_test.exs b/test/annot_at_web/live/login_live_test.exs new file mode 100644 index 0000000..ce97e16 --- /dev/null +++ b/test/annot_at_web/live/login_live_test.exs @@ -0,0 +1,83 @@ +defmodule AnnotAtWeb.LoginLiveTest do + use AnnotAtWeb.ConnCase, async: true + use Mimic + + import Phoenix.LiveViewTest + + alias AnnotAt.Atproto.Directory + alias AnnotAt.Atproto.OAuth.Login + + test "renders the sign-in form", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/login") + + assert has_element?(lv, "input#handle") + assert has_element?(lv, "button[type=submit]") + end + + test "submitting a valid handle redirects to the authorization URL", %{conn: conn} do + authorization_url = "https://bsky.social/oauth/authorize?x=1" + + expect(Login, :start_login, fn "jola.dev" -> + {:ok, authorization_url} + end) + + {:ok, lv, _html} = live(conn, ~p"/login") + + lv + |> form("form", %{"handle" => "jola.dev"}) + |> render_submit() + + assert_redirect(lv, authorization_url) + end + + test "submitting an invalid handle shows an error", %{conn: conn} do + expect(Login, :start_login, fn _ -> + {:error, :invalid_handle} + end) + + {:ok, lv, _html} = live(conn, ~p"/login") + + html = + lv + |> form("form", %{"handle" => "nope"}) + |> render_submit() + + assert html =~ "That doesn't look like a valid handle" + end + + test "typing suggests matching handles", %{conn: conn} do + expect(Directory, :search_handles, fn "jola" -> + [%{handle: "jola.dev", display_name: "Johanna", avatar: nil}] + end) + + {:ok, lv, _html} = live(conn, ~p"/login") + + html = + lv + |> form("form", %{"handle" => "jola"}) + |> render_change() + + assert html =~ "jola.dev" + end + + test "selecting a suggestion shows matched profile avatar", %{conn: conn} do + avatar = "https://cdn.example/jola.jpg" + + expect(Directory, :search_handles, fn "jola" -> + [%{handle: "jola.dev", display_name: "Johanna", avatar: avatar}] + end) + + {:ok, lv, _html} = live(conn, ~p"/login") + + lv + |> form("form", %{"handle" => "jola"}) + |> render_change() + + lv + |> element("#suggestion-0") + |> render_click() + + refute has_element?(lv, "#handle-listbox") + assert has_element?(lv, "img[src='#{avatar}']") + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index fb80624..391c5c8 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,5 +1,6 @@ Mimic.copy(AnnotAt.Atproto.DNS) Mimic.copy(AnnotAt.Feeds.Client) +Mimic.copy(AnnotAt.Atproto.Directory) Mimic.copy(AnnotAt.Atproto.HTTP) Mimic.copy(AnnotAt.Atproto.Identity) Mimic.copy(AnnotAt.Atproto.Profile)