diff --git a/assets/js/app.ts b/assets/js/app.ts index 1e9546461..e0da6e681 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -623,3 +623,23 @@ window.addEventListener("phx:js-exec", ({ detail }) => { liveSocket.execJS(el, el.getAttribute(detail.attr)); }); }); + +window.addEventListener("phx:open_popup", (e: CustomEvent) => { + const url = e.detail.url; + if (!url) return; + + const width = e.detail.width || 600; + const height = e.detail.height || 600; + const left = e.detail.left || window.screen.width / 2 - width / 2; + const top = e.detail.top || window.screen.height / 2 - height / 2; + + const newWindow = window.open( + url, + "oauth", + `width=${width},height=${height},left=${left},top=${top},toolbar=0,scrollbars=1,status=1` + ); + + if (window.focus && newWindow) { + newWindow.focus(); + } +}); diff --git a/lib/algora_web/controllers/oauth_callback_controller.ex b/lib/algora_web/controllers/oauth_callback_controller.ex index d431777f5..b555c4cd2 100644 --- a/lib/algora_web/controllers/oauth_callback_controller.ex +++ b/lib/algora_web/controllers/oauth_callback_controller.ex @@ -14,44 +14,54 @@ defmodule AlgoraWeb.OAuthCallbackController do end end + def translate_error(:invalid), do: "Unable to verify your login request. Please try signing in again" + def translate_error(:expired), do: "Your login link has expired. Please request a new one to continue" + def translate_error(%Ecto.Changeset{}), do: "We were unable to fetch the necessary information from your GitHub account" + def translate_error(_reason), do: "We were unable to contact GitHub. Please try again later" + def new(conn, %{"provider" => "github", "code" => code, "state" => state}) do - with {:ok, data} <- Github.verify_oauth_state(state), - {:ok, info} <- Github.OAuth.exchange_access_token(code: code, state: state), - %{info: info, primary_email: primary, emails: emails, token: token} = info, - {:ok, user} <- Accounts.register_github_user(primary, info, emails, token) do - conn = - case data[:return_to] do - nil -> conn - return_to -> put_session(conn, :user_return_to, return_to) - end + res = Github.verify_oauth_state(state) - conn - |> put_flash(:info, welcome_message(user)) - |> AlgoraWeb.UserAuth.log_in_user(user) - else - {:error, :invalid} -> - conn - |> put_flash(:error, "Unable to verify your login request. Please try signing in again.") - |> redirect(to: "/") + socket_id = + case res do + {:ok, %{socket_id: socket_id}} -> socket_id + _ -> nil + end - {:error, :expired} -> - conn - |> put_flash(:error, "Your login link has expired. Please request a new one to continue.") - |> redirect(to: "/") + type = if(socket_id, do: :popup, else: :redirect) - {:error, %Ecto.Changeset{} = changeset} -> - Logger.debug("failed GitHub insert #{inspect(changeset.errors)}") + with {:ok, data} <- res, + {:ok, info} <- Github.OAuth.exchange_access_token(code: code, state: state), + %{info: info, primary_email: primary, emails: emails, token: token} = info, + {:ok, user} <- Accounts.register_github_user(primary, info, emails, token) do + if socket_id do + Phoenix.PubSub.broadcast(Algora.PubSub, "auth:#{socket_id}", {:authenticated, user}) + end - conn - |> put_flash(:error, "We were unable to fetch the necessary information from your GitHub account") - |> redirect(to: "/") + case type do + :popup -> + conn + |> AlgoraWeb.UserAuth.put_current_user(user) + |> render(:success) + :redirect -> + conn + |> put_flash(:info, welcome_message(user)) + |> AlgoraWeb.UserAuth.put_current_user(user) + |> redirect(to: data[:return_to] || AlgoraWeb.UserAuth.signed_in_path(conn)) + end + else {:error, reason} -> Logger.debug("failed GitHub exchange #{inspect(reason)}") + conn = put_flash(conn, :error, translate_error(reason)) - conn - |> put_flash(:error, "We were unable to contact GitHub. Please try again later") - |> redirect(to: "/") + case type do + :popup -> + render(conn, :error) + + :redirect -> + redirect(conn, to: "/") + end end end diff --git a/lib/algora_web/controllers/oauth_callback_html.ex b/lib/algora_web/controllers/oauth_callback_html.ex new file mode 100644 index 000000000..2e18319a8 --- /dev/null +++ b/lib/algora_web/controllers/oauth_callback_html.ex @@ -0,0 +1,5 @@ +defmodule AlgoraWeb.OAuthCallbackHTML do + use AlgoraWeb, :html + + embed_templates "oauth_callback_html/*" +end diff --git a/lib/algora_web/controllers/oauth_callback_html/error.html.heex b/lib/algora_web/controllers/oauth_callback_html/error.html.heex new file mode 100644 index 000000000..0d212d667 --- /dev/null +++ b/lib/algora_web/controllers/oauth_callback_html/error.html.heex @@ -0,0 +1,4 @@ +
+

Authentication Failed

+

Please try again.

+
diff --git a/lib/algora_web/controllers/oauth_callback_html/success.html.heex b/lib/algora_web/controllers/oauth_callback_html/success.html.heex new file mode 100644 index 000000000..3ece0e759 --- /dev/null +++ b/lib/algora_web/controllers/oauth_callback_html/success.html.heex @@ -0,0 +1,7 @@ + +
+

Authentication Successful!

+

You can close this window.

+
diff --git a/lib/algora_web/controllers/user_auth.ex b/lib/algora_web/controllers/user_auth.ex index eafed9833..cdc4b11f5 100644 --- a/lib/algora_web/controllers/user_auth.ex +++ b/lib/algora_web/controllers/user_auth.ex @@ -86,16 +86,20 @@ defmodule AlgoraWeb.UserAuth do """ def log_in_user(conn, user) do user_return_to = get_session(conn, :user_return_to) - conn = assign(conn, :current_user, user) - conn = - conn - |> renew_session() - |> put_session(:user_id, user.id) - |> put_session(:last_context, user.last_context) - |> put_session(:live_socket_id, "users_sessions:#{user.id}") + conn + |> put_current_user(user) + |> redirect(to: user_return_to || signed_in_path(conn)) + end - redirect(conn, to: user_return_to || signed_in_path(conn)) + def put_current_user(conn, user) do + conn = assign(conn, :current_user, user) + + conn + |> renew_session() + |> put_session(:user_id, user.id) + |> put_session(:last_context, user.last_context) + |> put_session(:live_socket_id, "users_sessions:#{user.id}") end defp renew_session(conn) do diff --git a/lib/algora_web/live/swift_bounties_live.ex b/lib/algora_web/live/swift_bounties_live.ex index 802c80146..24b721229 100644 --- a/lib/algora_web/live/swift_bounties_live.ex +++ b/lib/algora_web/live/swift_bounties_live.ex @@ -4,15 +4,70 @@ defmodule AlgoraWeb.SwiftBountiesLive do import AlgoraWeb.Components.Bounties import AlgoraWeb.Components.Footer + import Ecto.Changeset import Ecto.Query + alias Algora.Accounts alias Algora.Bounties + alias Algora.Github alias Algora.Repo + alias Algora.Types.USD + alias Algora.Validations + alias Algora.Workspace alias AlgoraWeb.Components.Logos + require Logger + + defmodule BountyForm do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + embedded_schema do + field :url, :string + field :amount, USD + + embeds_one :ticket_ref, TicketRef, primary_key: false do + field :owner, :string + field :repo, :string + field :number, :integer + field :type, :string + end + end + + def changeset(form, attrs \\ %{}) do + form + |> cast(attrs, [:url, :amount]) + |> validate_required([:url, :amount]) + |> Validations.validate_money_positive(:amount) + |> Validations.validate_ticket_ref(:url, :ticket_ref) + end + end + + defmodule TipForm do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + embedded_schema do + field :github_handle, :string + field :amount, USD + end + + def changeset(form, attrs \\ %{}) do + form + |> cast(attrs, [:github_handle, :amount]) + |> validate_required([:github_handle, :amount]) + |> Validations.validate_money_positive(:amount) + end + end + def mount(_params, _session, socket) do if connected?(socket) do Bounties.subscribe() + Phoenix.PubSub.subscribe(Algora.PubSub, "auth:#{socket.id}") end socket = @@ -23,6 +78,10 @@ defmodule AlgoraWeb.SwiftBountiesLive do |> assign(:page_title, "Fund Swift Together") |> assign(:page_description, "Help grow the Swift ecosystem by funding the work we all depend on.") |> assign(:page_image, "#{AlgoraWeb.Endpoint.url()}/images/og/swift.png") + |> assign(:bounty_form, to_form(BountyForm.changeset(%BountyForm{}, %{}))) + |> assign(:tip_form, to_form(TipForm.changeset(%TipForm{}, %{}))) + |> assign(:oauth_url, Github.authorize_url(%{socket_id: socket.id})) + |> assign(:pending_action, nil) |> assign_tickets() |> assign_active_repos() end @@ -196,6 +255,21 @@ defmodule AlgoraWeb.SwiftBountiesLive do +
+
+

+ Start Contributing +

+

+ Fund Swift Development in Seconds +

+
+ {create_bounty(assigns)} + {create_tip(assigns)} +
+
+
+

@@ -464,6 +538,147 @@ defmodule AlgoraWeb.SwiftBountiesLive do """ end + defp create_bounty(assigns) do + ~H""" + <.card class="bg-muted/30"> + <.card_header> +
+ <.icon name="tabler-diamond" class="h-8 w-8" /> +

Post a bounty

+
+ + <.card_content> + <.simple_form for={@bounty_form} phx-submit="create_bounty"> +
+ <.input + label="URL" + field={@bounty_form[:url]} + placeholder="https://github.com/swift-lang/swift/issues/1337" + /> + <.input label="Amount" icon="tabler-currency-dollar" field={@bounty_form[:amount]} /> +
+ <.button variant="subtle">Submit +
+
+ + + + """ + end + + defp create_tip(assigns) do + ~H""" + <.card class="bg-muted/30"> + <.card_header> +
+ <.icon name="tabler-gift" class="h-8 w-8" /> +

Tip a developer

+
+ + <.card_content> + <.simple_form for={@tip_form} phx-submit="create_tip"> +
+ <.input label="GitHub handle" field={@tip_form[:github_handle]} placeholder="jsmith" /> + <.input label="Amount" icon="tabler-currency-dollar" field={@tip_form[:amount]} /> +
+ <.button variant="subtle">Submit +
+
+ + + + """ + end + + def handle_event("create_bounty" = event, %{"bounty_form" => params} = unsigned_params, socket) do + changeset = + %BountyForm{} + |> BountyForm.changeset(params) + |> Map.put(:action, :validate) + + amount = get_field(changeset, :amount) + ticket_ref = get_field(changeset, :ticket_ref) + + if changeset.valid? do + if socket.assigns[:current_user] do + case Bounties.create_bounty(%{ + creator: socket.assigns.current_user, + owner: socket.assigns.current_user, + amount: amount, + ticket_ref: ticket_ref + }) do + {:ok, bounty} -> + Bounties.notify_bounty(%{owner: socket.assigns.current_user, bounty: bounty, ticket_ref: ticket_ref}) + + {:noreply, + socket + |> put_flash(:info, "Bounty created") + |> push_navigate(to: ~p"/home")} + + {:error, :already_exists} -> + {:noreply, put_flash(socket, :warning, "You have already created a bounty for this ticket")} + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, "Something went wrong")} + end + else + {:noreply, + socket + |> assign(:pending_action, {event, unsigned_params}) + |> push_event("open_popup", %{url: socket.assigns.oauth_url})} + end + else + {:noreply, assign(socket, :bounty_form, to_form(changeset))} + end + end + + def handle_event("create_tip" = event, %{"tip_form" => params} = unsigned_params, socket) do + changeset = + %TipForm{} + |> TipForm.changeset(params) + |> Map.put(:action, :validate) + + if changeset.valid? do + if socket.assigns[:current_user] do + with {:ok, token} <- Accounts.get_access_token(socket.assigns.current_user), + {:ok, recipient} <- Workspace.ensure_user(token, get_field(changeset, :github_handle)), + {:ok, checkout_url} <- + Bounties.create_tip(%{ + creator: socket.assigns.current_user, + owner: socket.assigns.current_user, + recipient: recipient, + amount: get_field(changeset, :amount) + }) do + {:noreply, redirect(socket, external: checkout_url)} + else + {:error, reason} -> + Logger.error("Failed to create tip: #{inspect(reason)}") + {:noreply, put_flash(socket, :error, "Something went wrong")} + end + else + {:noreply, + socket + |> assign(:pending_action, {event, unsigned_params}) + |> push_event("open_popup", %{url: socket.assigns.oauth_url})} + end + else + {:noreply, assign(socket, :tip_form, to_form(changeset))} + end + end + + def handle_info({:authenticated, user}, socket) do + socket = assign(socket, :current_user, user) + + case socket.assigns.pending_action do + {event, params} -> + socket = assign(socket, :pending_action, nil) + handle_event(event, params, socket) + + nil -> + {:noreply, socket} + end + end + def handle_info(:bounties_updated, socket) do {:noreply, assign_tickets(socket)} end