diff --git a/lib/algora/accounts/schemas/user.ex b/lib/algora/accounts/schemas/user.ex index 2e5d1a3b0..4bab015c7 100644 --- a/lib/algora/accounts/schemas/user.ex +++ b/lib/algora/accounts/schemas/user.ex @@ -71,6 +71,8 @@ defmodule Algora.Accounts.User do field :og_title, :string field :og_image_url, :string + field :login_token, :string, virtual: true + has_many :identities, Identity has_many :memberships, Member, foreign_key: :user_id has_many :members, Member, foreign_key: :org_id @@ -247,6 +249,10 @@ defmodule Algora.Accounts.User do |> validate_timezone() end + def login_changeset(%User{} = user, params) do + cast(user, params, [:email, :login_token]) + end + defp validate_email(changeset) do changeset |> validate_required([:email]) diff --git a/lib/algora_web/controllers/user_auth.ex b/lib/algora_web/controllers/user_auth.ex index 5bd011280..a7f27f423 100644 --- a/lib/algora_web/controllers/user_auth.ex +++ b/lib/algora_web/controllers/user_auth.ex @@ -200,8 +200,15 @@ defmodule AlgoraWeb.UserAuth do defp login_code_ttl, do: 3600 defp login_code_salt, do: "algora-login-code" - def generate_login_code(email, domain \\ nil, tech_stack) do - payload = "#{email}:#{domain || ""}:#{Enum.join(tech_stack, ":")}" + def generate_login_code(email) do + sign_login_code(email) + end + + def generate_login_code(email, domain, tech_stack) do + sign_login_code("#{email}:#{domain || ""}:#{Enum.join(tech_stack, ":")}") + end + + def sign_login_code(payload) do Phoenix.Token.sign(AlgoraWeb.Endpoint, login_code_salt(), payload, max_age: login_code_ttl()) end @@ -261,7 +268,7 @@ defmodule AlgoraWeb.UserAuth do For correspondence, please email the Algora founders at ioannis@algora.io and zafer@algora.io - © 2023 Algora PBC. + © 2025 Algora PBC. """ end end diff --git a/lib/algora_web/live/sign_in_live.ex b/lib/algora_web/live/sign_in_live.ex index 626cb833a..86a83f723 100644 --- a/lib/algora_web/live/sign_in_live.ex +++ b/lib/algora_web/live/sign_in_live.ex @@ -2,20 +2,56 @@ defmodule AlgoraWeb.SignInLive do @moduledoc false use AlgoraWeb, :live_view + alias Algora.Accounts.User + alias Swoosh.Email + def render(assigns) do ~H""" -
-
-

- Algora Console -

- <.link - href={@authorize_url} - rel="noopener" - class="mt-8 flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-offset-2" - > - Sign in with GitHub - +
+
+
+

+ Algora Console +

+ <.header class="text-center p-4"> + Sign in with Github + <:subtitle>Sign in and link your Github Account + + <.link + href={@authorize_url} + rel="noopener" + class="mt-4 flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-offset-2" + > + Authorize with GitHub + +
+
+ <.header class="text-center p-4"> + Sign in with Email + <:subtitle :if={!@secret_code}>We'll send a login code to your inbox + <:subtitle :if={@secret_code}>We sent a login code to your inbox + + + <.simple_form for={@form} id="send_login_code_form" phx-submit="send_login_code"> + <.input :if={!@secret_code} field={@form[:email]} type="email" placeholder="Email" required /> + <.input + :if={@secret_code} + field={@form[:login_code]} + type="text" + placeholder="Enter Login Code" + required + /> + <:actions> + <.button + if={!@secret_code} + phx-disable-with="Sending..." + class="mt-4 flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-offset-2" + > + Send login code +
Login with code
+ + +
""" @@ -28,6 +64,87 @@ defmodule AlgoraWeb.SignInLive do return_to -> Algora.Github.authorize_url(%{return_to: return_to}) end - {:ok, assign(socket, authorize_url: authorize_url)} + changeset = User.login_changeset(%User{}, %{}) + + {:ok, + socket + |> assign(authorize_url: authorize_url) + |> assign(secret_code: nil) + |> assign_form(changeset)} + end + + def handle_event("send_login_code", %{"user" => %{"email" => email}}, socket) do + code = Nanoid.generate() + + case Algora.Accounts.get_user_by_email(email) do + %User{} = user -> + changeset = User.login_changeset(%User{}, %{}) + + case send_login_code_to_user(user, code) do + {:ok, _id} -> + {:noreply, + socket + |> assign(:secret_code, code) + |> assign(:user, user) + |> assign_form(changeset) + |> put_flash(:info, "Login code sent to #{email}!")} + + {:error, _reason} -> + # capture_error reason + {:noreply, put_flash(socket, :error, "We had trouble sending mail to #{email}. Please try again")} + end + + nil -> + throttle() + {:noreply, put_flash(socket, :error, "Email address not found.")} + end + end + + def handle_event("send_login_code", %{"user" => %{"login_code" => code}}, socket) do + if Plug.Crypto.secure_compare(code, socket.assigns.secret_code) do + user = socket.assigns.user + token = AlgoraWeb.UserAuth.generate_login_code(user.email) + path = AlgoraWeb.UserAuth.login_path(user.email, token) + + {:noreply, + socket + |> redirect(to: path) + |> put_flash(:info, "Logged in successfully!")} + else + throttle() + {:noreply, put_flash(socket, :error, "Invalid login code")} + end end + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + assign(socket, :form, to_form(changeset)) + end + + @from_name "Algora" + @from_email "info@algora.io" + + defp send_login_code_to_user(user, code) do + email = + Email.new() + |> Email.to({user.display_name, user.email}) + |> Email.from({@from_name, @from_email}) + |> Email.subject("Login code for Algora") + |> Email.text_body(""" + Here is your login code for Algora! + + #{code} + + If you didn't request this link, you can safely ignore this email. + + -------------------------------------------------------------------------------- + + For correspondence, please email the Algora founders at ioannis@algora.io and zafer@algora.io + + © 2025 Algora PBC. + """) + + Algora.Mailer.deliver(email) + end + + defp throttle, do: :timer.sleep(1000) end