diff --git a/lib/who_need_help/accounts.ex b/lib/who_need_help/accounts.ex index a5bf87d..0eaf618 100644 --- a/lib/who_need_help/accounts.ex +++ b/lib/who_need_help/accounts.ex @@ -32,6 +32,7 @@ defmodule WhoNeedHelp.Accounts do "display_name" => :display_name, "email" => :email, "email_verified" => :email_verified, + "hosted_domain" => :hosted_domain, "locale" => :locale, "provider_uid" => :provider_uid, "terms_accepted" => :terms_accepted @@ -423,6 +424,21 @@ defmodule WhoNeedHelp.Accounts do end end + def change_google_registration(identity_attrs, registration_attrs \\ %{}) + when is_map(registration_attrs) do + case normalize_google_identity(identity_attrs) do + {:ok, identity_attrs} -> + %User{} + |> User.registration_changeset( + google_registration_attrs(identity_attrs, registration_attrs), + validate_unique: false + ) + + {:error, _reason} -> + User.registration_changeset(%User{}, %{}, validate_unique: false) + end + end + def register_user_by_google(identity_attrs, registration_attrs) when is_map(registration_attrs) do with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do @@ -451,6 +467,36 @@ defmodule WhoNeedHelp.Accounts do def register_user_by_google(_identity_attrs, _registration_attrs), do: {:error, :invalid_registration} + def link_google_identity_by_authoritative_email(%User{id: user_id}, identity_attrs) do + with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs), + true <- WhoNeedHelp.GoogleAuth.authoritative_email?(identity_attrs) do + Repo.transact(fn -> + user = + User + |> where([user], user.id == ^user_id) + |> lock("FOR UPDATE") + |> Repo.one() + + cond do + is_nil(user) or user.moderation_status == :suspended -> + {:error, :not_found} + + normalized_email(user.email) != identity_attrs.email -> + {:error, :email_mismatch} + + true -> + with {:ok, user} <- confirm_google_email_owner(user), + {:ok, identity} <- upsert_google_identity_for_user(user, identity_attrs) do + {:ok, {user, identity}} + end + end + end) + else + false -> {:error, :email_not_authoritative} + {:error, _reason} = error -> error + end + end + def link_google_identity(%User{id: user_id}, identity_attrs) do with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do Repo.transact(fn -> @@ -499,12 +545,7 @@ defmodule WhoNeedHelp.Accounts do if existing_user do {:error, :email_already_registered} else - attrs = %{ - "email" => identity_attrs.email, - "display_name" => identity_attrs.display_name, - "locale" => registration_value(registration_attrs, "locale", "en"), - "terms_accepted" => registration_value(registration_attrs, "terms_accepted", false) - } + attrs = google_registration_attrs(identity_attrs, registration_attrs) with {:ok, user} <- register_user(attrs), {:ok, user} <- user |> User.confirm_changeset() |> Repo.update(), @@ -585,11 +626,30 @@ defmodule WhoNeedHelp.Accounts do end end + defp confirm_google_email_owner(%User{confirmed_at: nil} = user) do + user + |> User.confirm_changeset() + |> Repo.update() + end + + defp confirm_google_email_owner(%User{} = user), do: {:ok, user} + + defp google_registration_attrs(identity_attrs, registration_attrs) do + %{ + "email" => identity_attrs.email, + "display_name" => + registration_value(registration_attrs, "display_name", identity_attrs.display_name), + "locale" => registration_value(registration_attrs, "locale", "en"), + "terms_accepted" => registration_value(registration_attrs, "terms_accepted", false) + } + end + defp normalize_google_identity(identity_attrs) when is_map(identity_attrs) do provider_uid = registration_value(identity_attrs, "provider_uid", nil) email = registration_value(identity_attrs, "email", nil) email_verified = registration_value(identity_attrs, "email_verified", false) display_name = registration_value(identity_attrs, "display_name", nil) + hosted_domain = registration_value(identity_attrs, "hosted_domain", nil) cond do email_verified != true -> @@ -610,7 +670,8 @@ defmodule WhoNeedHelp.Accounts do provider_uid: provider_uid, email: email |> String.trim() |> String.downcase(), email_verified: true, - display_name: display_name |> String.trim() |> String.slice(0, 80) + display_name: display_name |> String.trim() |> String.slice(0, 80), + hosted_domain: normalize_hosted_domain(hosted_domain) }} end end @@ -618,6 +679,18 @@ defmodule WhoNeedHelp.Accounts do defp normalize_google_identity(_identity_attrs), do: {:error, :invalid_provider_identity} + defp normalize_hosted_domain(domain) when is_binary(domain) do + case domain |> String.trim() |> String.downcase() do + "" -> nil + normalized -> String.slice(normalized, 0, 255) + end + end + + defp normalize_hosted_domain(_domain), do: nil + + defp normalized_email(email) when is_binary(email), + do: email |> String.trim() |> String.downcase() + defp registration_value(attrs, key, default) do case Map.fetch(attrs, key) do {:ok, value} -> diff --git a/lib/who_need_help/google_auth.ex b/lib/who_need_help/google_auth.ex index 76fc4d0..6d2554e 100644 --- a/lib/who_need_help/google_auth.ex +++ b/lib/who_need_help/google_auth.ex @@ -10,7 +10,8 @@ defmodule WhoNeedHelp.GoogleAuth do provider_uid: String.t(), email: String.t(), email_verified: true, - display_name: String.t() + display_name: String.t(), + hosted_domain: String.t() | nil } @callback enabled?() :: boolean() @@ -33,6 +34,36 @@ defmodule WhoNeedHelp.GoogleAuth do def verify_id_token(id_token, nonce), do: adapter().verify_id_token(id_token, nonce) + @doc """ + Returns whether Google is authoritative for the identity's current email. + + Gmail addresses are hosted by Google. A non-empty hosted-domain claim marks + a verified Google Workspace identity. Other third-party email addresses still + require the user to prove access to the existing local account before linking. + """ + def authoritative_email?( + %{ + email: email, + email_verified: true, + hosted_domain: hosted_domain + } = identity + ) + when is_binary(email) and is_binary(hosted_domain) do + String.trim(hosted_domain) != "" or authoritative_email?(Map.delete(identity, :hosted_domain)) + end + + def authoritative_email?(%{ + email: email, + email_verified: true + }) + when is_binary(email) do + normalized_email = email |> String.trim() |> String.downcase() + + String.ends_with?(normalized_email, "@gmail.com") + end + + def authoritative_email?(_identity), do: false + defp adapter do Application.get_env( :who_need_help, diff --git a/lib/who_need_help/google_auth/assent_adapter.ex b/lib/who_need_help/google_auth/assent_adapter.ex index bc4e411..a96a7d4 100644 --- a/lib/who_need_help/google_auth/assent_adapter.ex +++ b/lib/who_need_help/google_auth/assent_adapter.ex @@ -94,7 +94,8 @@ defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do provider_uid: provider_uid, email: normalized_email, email_verified: true, - display_name: display_name(normalized_email, Map.get(claims, "name")) + display_name: display_name(normalized_email, Map.get(claims, "name")), + hosted_domain: hosted_domain(Map.get(claims, "hd")) }} end @@ -132,6 +133,15 @@ defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do else: "Google user" end + defp hosted_domain(domain) when is_binary(domain) do + case domain |> String.trim() |> String.downcase() do + "" -> nil + normalized -> String.slice(normalized, 0, 255) + end + end + + defp hosted_domain(_domain), do: nil + defp random_url_token(length) do length |> :crypto.strong_rand_bytes() diff --git a/lib/who_need_help_web/controllers/google_auth_controller.ex b/lib/who_need_help_web/controllers/google_auth_controller.ex index e88a552..47c81cd 100644 --- a/lib/who_need_help_web/controllers/google_auth_controller.ex +++ b/lib/who_need_help_web/controllers/google_auth_controller.ex @@ -71,12 +71,15 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do |> GoogleAuthPending.delete() |> google_account_unavailable(~p"/users/log-in") - existing_account -> - render(conn, :complete, - pending: pending, - existing_account: not is_nil(existing_account), - page_title: gettext("Continue with Google") - ) + %Accounts.User{} = existing_account -> + if GoogleAuth.authoritative_email?(identity_attrs(pending)) do + connect_authoritative_google_and_log_in(conn, existing_account, pending) + else + render_google_completion(conn, pending, true) + end + + nil -> + render_google_completion(conn, pending, false) end {:error, _reason} -> @@ -88,17 +91,14 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do end def complete_registration(conn, %{"google_registration" => params}) when is_map(params) do - terms_accepted = params["terms_accepted"] in [true, "true", "on", "1"] - - with true <- terms_accepted, - {:ok, pending} <- GoogleAuthPending.fetch(conn), + with {:ok, pending} <- GoogleAuthPending.fetch(conn), nil <- Accounts.get_user_by_email(pending.email), {:ok, _limit} <- RateLimiter.check(:registration_email, pending.email), {:ok, {user, _identity}} <- - Accounts.register_user_by_google(identity_attrs(pending), %{ - "locale" => normalize_locale(params["locale"] || pending.locale), - "terms_accepted" => true - }) do + Accounts.register_user_by_google( + identity_attrs(pending), + normalize_google_registration_params(params, pending) + ) do _ = ProductAnalytics.increment_for_user(user, "account.registered", "google") conn @@ -106,13 +106,10 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do |> put_flash(:info, gettext("Your account was created with Google.")) |> UserAuth.log_in_user(user) else - false -> + {:error, %Ecto.Changeset{} = changeset} -> conn - |> put_flash( - :error, - gettext("Confirm that you are 18 or older and accept the safety rules first.") - ) - |> redirect(to: ~p"/auth/google/complete") + |> put_status(:unprocessable_entity) + |> render_google_completion_from_session(changeset) %Accounts.User{} -> conn @@ -354,10 +351,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do |> UserAuth.log_in_user(user) {:error, :not_linked} -> - case GoogleAuthPending.put(conn, identity_attrs, flow_locale(flow)) do - {:ok, conn} -> redirect(conn, to: ~p"/auth/google/complete") - {:error, _reason} -> google_account_unavailable(conn, ~p"/users/log-in") - end + continue_unlinked_google(conn, flow, identity_attrs, ~p"/users/log-in") {:error, _reason} -> google_account_unavailable(conn, ~p"/users/log-in") @@ -365,13 +359,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do end defp finish_flow(conn, "register", flow, identity_attrs) do - case GoogleAuthPending.put(conn, identity_attrs, flow_locale(flow)) do - {:ok, conn} -> - redirect(conn, to: ~p"/auth/google/complete") - - {:error, _reason} -> - google_account_unavailable(conn, ~p"/users/register") - end + continue_unlinked_google(conn, flow, identity_attrs, ~p"/users/register") end defp finish_flow(conn, "link", flow, identity_attrs) do @@ -421,6 +409,90 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do end) end + defp connect_authoritative_google_and_log_in(conn, existing_account, pending) do + result = + Repo.transact(fn -> + with {:ok, {user, identity}} <- + Accounts.link_google_identity_by_authoritative_email( + existing_account, + identity_attrs(pending) + ), + {:ok, _audit} <- + Trust.audit( + user.id, + "auth_identity.connected", + "auth_identity", + identity.id, + %{"provider" => "google", "method" => "authoritative_google_email"} + ) do + {:ok, user} + end + end) + + case result do + {:ok, user} -> + conn + |> GoogleAuthPending.delete() + |> put_flash(:info, gettext("Welcome back!")) + |> UserAuth.log_in_user(user) + + {:error, reason} -> + Logger.warning("Unable to connect authoritative Google identity (#{error_name(reason)})") + + conn + |> GoogleAuthPending.delete() + |> google_account_unavailable(~p"/users/log-in") + end + end + + defp continue_unlinked_google(conn, flow, identity_attrs, failure_path) do + case Accounts.get_user_by_email(identity_attrs.email) do + %Accounts.User{moderation_status: :suspended} -> + google_account_unavailable(conn, failure_path) + + %Accounts.User{} = existing_account -> + if GoogleAuth.authoritative_email?(identity_attrs) do + pending = Map.put(identity_attrs, :locale, flow_locale(flow)) + connect_authoritative_google_and_log_in(conn, existing_account, pending) + else + store_pending_google(conn, flow, identity_attrs, failure_path) + end + + nil -> + store_pending_google(conn, flow, identity_attrs, failure_path) + end + end + + defp store_pending_google(conn, flow, identity_attrs, failure_path) do + case GoogleAuthPending.put(conn, identity_attrs, flow_locale(flow)) do + {:ok, conn} -> redirect(conn, to: ~p"/auth/google/complete") + {:error, _reason} -> google_account_unavailable(conn, failure_path) + end + end + + defp render_google_completion_from_session(conn, changeset) do + case GoogleAuthPending.fetch(conn) do + {:ok, pending} -> render_google_completion(conn, pending, false, changeset) + {:error, _reason} -> expired_pending_redirect(conn) + end + end + + defp render_google_completion(conn, pending, existing_account, changeset \\ nil) do + changeset = + changeset || + Accounts.change_google_registration(identity_attrs(pending), %{ + "display_name" => pending.display_name, + "locale" => pending.locale + }) + + render(conn, :complete, + pending: pending, + existing_account: existing_account, + registration_form: Phoenix.Component.to_form(changeset, as: :google_registration), + page_title: gettext("Continue with Google") + ) + end + defp unlink_google_identity_and_audit(current_user) do Repo.transact(fn -> with {:ok, identity} <- Accounts.unlink_google_identity(current_user), @@ -474,10 +546,27 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do defp normalize_locale(locale) when locale in @supported_locales, do: locale defp normalize_locale(_locale), do: "en" + defp normalize_google_registration_params(params, pending) do + %{ + "display_name" => normalize_display_name(params["display_name"]), + "locale" => normalize_locale(params["locale"] || pending.locale), + "terms_accepted" => params["terms_accepted"] in [true, "true", "on", "1"] + } + end + + defp normalize_display_name(name) when is_binary(name), do: String.trim(name) + defp normalize_display_name(_name), do: "" + defp flow_locale(flow), do: normalize_locale(flow["locale"]) defp identity_attrs(pending) do - Map.take(pending, [:provider_uid, :email, :email_verified, :display_name]) + Map.take(pending, [ + :provider_uid, + :email, + :email_verified, + :display_name, + :hosted_domain + ]) end defp error_name(error) when is_atom(error), do: Atom.to_string(error) diff --git a/lib/who_need_help_web/controllers/google_auth_html/complete.html.heex b/lib/who_need_help_web/controllers/google_auth_html/complete.html.heex index 77ae194..da657b6 100644 --- a/lib/who_need_help_web/controllers/google_auth_html/complete.html.heex +++ b/lib/who_need_help_web/controllers/google_auth_html/complete.html.heex @@ -67,17 +67,37 @@ <.form - for={%{}} - as={:google_registration} + for={@registration_form} action={~p"/auth/google/complete-registration"} - class="space-y-3" + class="space-y-4" > - +
+ <.input + field={@registration_form[:display_name]} + type="text" + label={gettext("Display name")} + autocomplete="name" + minlength="2" + maxlength="80" + required + /> +

+ {gettext("Other people will see this name on requests, activities, and reviews.")} +

+
+ <.input + field={@registration_form[:locale]} + type="select" + label={gettext("Interface language")} + options={[{"English", "en"}, {"Українська", "uk"}, {"Русский", "ru"}]} + required + /> + + <.input + field={@registration_form[:terms_accepted]} type="checkbox" id="google_completion_terms" - name="google_registration[terms_accepted]" - value="false" label={gettext("I am 18 or older and accept the Terms and Safety Rules")} required /> diff --git a/lib/who_need_help_web/google_auth_pending.ex b/lib/who_need_help_web/google_auth_pending.ex index 7ee2260..64e921d 100644 --- a/lib/who_need_help_web/google_auth_pending.ex +++ b/lib/who_need_help_web/google_auth_pending.ex @@ -87,6 +87,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthPending do email = value(attrs, :email) email_verified = value(attrs, :email_verified) display_name = value(attrs, :display_name) + hosted_domain = normalize_hosted_domain(value(attrs, :hosted_domain)) cond do email_verified != true -> @@ -108,6 +109,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthPending do email: normalized_email(email), email_verified: true, display_name: display_name |> String.trim() |> String.slice(0, 80), + hosted_domain: hosted_domain, locale: normalize_locale(locale) }} end @@ -116,13 +118,28 @@ defmodule WhoNeedHelpWeb.GoogleAuthPending do defp normalize(_attrs, _locale), do: {:error, :invalid} defp identity_attrs(pending) do - Map.take(pending, [:provider_uid, :email, :email_verified, :display_name]) + Map.take(pending, [ + :provider_uid, + :email, + :email_verified, + :display_name, + :hosted_domain + ]) end defp value(attrs, key), do: Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key)) defp normalized_email(email), do: email |> String.trim() |> String.downcase() + defp normalize_hosted_domain(domain) when is_binary(domain) do + case domain |> String.trim() |> String.downcase() do + "" -> nil + normalized -> String.slice(normalized, 0, 255) + end + end + + defp normalize_hosted_domain(_domain), do: nil + defp normalize_locale(locale) when locale in @supported_locales, do: locale defp normalize_locale(_locale), do: "en" end diff --git a/test/support/google_auth_fake.ex b/test/support/google_auth_fake.ex index bb46af8..7a33010 100644 --- a/test/support/google_auth_fake.ex +++ b/test/support/google_auth_fake.ex @@ -39,7 +39,8 @@ defmodule WhoNeedHelp.GoogleAuthFake do provider_uid: params["uid"] || "google-user-42", email: email, email_verified: true, - display_name: params["name"] || "Google Neighbor" + display_name: params["name"] || "Google Neighbor", + hosted_domain: normalize_hosted_domain(params["hd"]) }} end end @@ -60,10 +61,20 @@ defmodule WhoNeedHelp.GoogleAuthFake do provider_uid: provider_uid, email: String.downcase(email), email_verified: true, - display_name: name + display_name: name, + hosted_domain: nil }} else _invalid_or_replayed -> {:error, :invalid_id_token} end end + + defp normalize_hosted_domain(domain) when is_binary(domain) do + case domain |> String.trim() |> String.downcase() do + "" -> nil + normalized -> normalized + end + end + + defp normalize_hosted_domain(_domain), do: nil end diff --git a/test/who_need_help/google_auth_test.exs b/test/who_need_help/google_auth_test.exs index d5372f6..550717d 100644 --- a/test/who_need_help/google_auth_test.exs +++ b/test/who_need_help/google_auth_test.exs @@ -1,6 +1,7 @@ defmodule WhoNeedHelp.GoogleAuthTest do use ExUnit.Case, async: false + alias WhoNeedHelp.GoogleAuth alias WhoNeedHelp.GoogleAuth.AssentAdapter defmodule GoogleJwksHTTPAdapter do @@ -66,13 +67,46 @@ defmodule WhoNeedHelp.GoogleAuthTest do provider_uid: "google-subject-123", email: "alice@example.com", email_verified: true, - display_name: "Alice Neighbor" + display_name: "Alice Neighbor", + hosted_domain: nil } refute Map.has_key?(identity, :access_token) refute Map.has_key?(identity, :id_token) end + test "normalizes the hosted-domain claim and applies Google's authoritative-email rules" do + assert {:ok, workspace_identity} = + AssentAdapter.normalize_identity(%{ + "sub" => "workspace-subject", + "email" => "Owner@Example.ORG", + "email_verified" => true, + "name" => "Workspace Owner", + "hd" => " Example.ORG " + }) + + assert workspace_identity.hosted_domain == "example.org" + assert GoogleAuth.authoritative_email?(workspace_identity) + + assert GoogleAuth.authoritative_email?(%{ + email: "OWNER@GMAIL.COM", + email_verified: true, + hosted_domain: nil + }) + + refute GoogleAuth.authoritative_email?(%{ + email: "owner@example.org", + email_verified: true, + hosted_domain: nil + }) + + refute GoogleAuth.authoritative_email?(%{ + email: "owner@gmail.com", + email_verified: false, + hosted_domain: "gmail.com" + }) + end + test "rejects an unverified or incomplete Google email" do assert {:error, :email_not_verified} = AssentAdapter.normalize_identity(%{ @@ -130,7 +164,8 @@ defmodule WhoNeedHelp.GoogleAuthTest do provider_uid: "native-subject", email: "native@example.com", email_verified: true, - display_name: "Native Neighbor" + display_name: "Native Neighbor", + hosted_domain: nil }} = AssentAdapter.verify_id_token(token, nonce) assert {:error, _reason} = AssentAdapter.verify_id_token(token, "different-nonce") @@ -203,7 +238,8 @@ defmodule WhoNeedHelp.GoogleAuthTest do provider_uid: "cross-client-subject", email: "crossclient@example.com", email_verified: true, - display_name: "Cross Client" + display_name: "Cross Client", + hosted_domain: nil }} = AssentAdapter.verify_id_token(token, nonce) unauthorized_token = diff --git a/test/who_need_help_web/controllers/google_auth_controller_test.exs b/test/who_need_help_web/controllers/google_auth_controller_test.exs index 1e95554..0175927 100644 --- a/test/who_need_help_web/controllers/google_auth_controller_test.exs +++ b/test/who_need_help_web/controllers/google_auth_controller_test.exs @@ -148,6 +148,11 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do assert html =~ "Create your Who Need Help account" assert html =~ email + assert html =~ ~s(value="Native Neighbor") + + assert Enum.count( + LazyHTML.query(LazyHTML.from_document(html), "option[value='ru'][selected]") + ) == 1 end test "native Google account linking still requires the signed-in sudo owner", %{conn: conn} do @@ -213,7 +218,11 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do ) |> recycle() |> post(~p"/auth/google/complete-registration", %{ - "google_registration" => %{"locale" => "ru", "terms_accepted" => "true"} + "google_registration" => %{ + "display_name" => "Chosen Helper", + "locale" => "ru", + "terms_accepted" => "true" + } }) assert redirected_to(conn) == ~p"/" @@ -224,7 +233,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do assert user.confirmed_at assert user.accepted_terms_at assert user.locale == "ru" - assert user.display_name == "Google Helper" + assert user.display_name == "Chosen Helper" assert is_nil(user.hashed_password) assert %AuthIdentity{ @@ -259,6 +268,98 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do assert html =~ "Email me a verification link" end + test "auto-links a verified Gmail owner and keeps the existing public name", %{conn: conn} do + email = "existing#{System.unique_integer([:positive])}@gmail.com" + user = unconfirmed_user_fixture(%{email: email, display_name: "Local Public Name"}) + + refute user.confirmed_at + + conn = + conn + |> post(~p"/auth/google/login") + |> recycle() + |> get( + ~p"/auth/google/callback?code=code&state=google-test-state&uid=gmail-owner&email=#{email}&name=Google%20Profile%20Name" + ) + + assert redirected_to(conn) == ~p"/" + assert get_session(conn, :user_token) + assert is_nil(get_session(conn, "pending_google_identity")) + + reloaded_user = Accounts.get_user_by_email(email) + assert reloaded_user.confirmed_at + assert reloaded_user.display_name == "Local Public Name" + + identity = + Repo.get_by!(AuthIdentity, provider: :google, provider_uid: "gmail-owner") + + assert identity.user_id == user.id + + assert %AuditEvent{actor_id: actor_id, metadata: metadata} = + Repo.get_by!(AuditEvent, + action: "auth_identity.connected", + target_id: identity.id + ) + + assert actor_id == user.id + assert metadata["method"] == "authoritative_google_email" + end + + test "auto-links a verified Google Workspace owner carrying the hosted-domain claim", %{ + conn: conn + } do + email = "owner#{System.unique_integer([:positive])}@example.org" + user = user_fixture(%{email: email, display_name: "Workspace Member"}) + + conn = + conn + |> post(~p"/auth/google/register", %{ + "google_registration" => %{"locale" => "uk"} + }) + |> recycle() + |> get( + ~p"/auth/google/callback?code=code&state=google-test-state&uid=workspace-owner&email=#{email}&hd=example.org" + ) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == nil + assert redirected_to(conn) == ~p"/" + assert get_session(conn, :user_token) + + assert %AuthIdentity{user_id: user_id} = + Repo.get_by!(AuthIdentity, provider: :google, provider_uid: "workspace-owner") + + assert user_id == user.id + end + + test "does not replace a different Google identity already connected to the Gmail account", %{ + conn: conn + } do + email = "protected#{System.unique_integer([:positive])}@gmail.com" + user = user_fixture(%{email: email}) + + assert {:ok, original_identity} = + Accounts.link_google_identity(user, %{ + provider_uid: "original-google-owner", + email: email, + email_verified: true, + display_name: user.display_name + }) + + conn = + conn + |> post(~p"/auth/google/login") + |> recycle() + |> get( + ~p"/auth/google/callback?code=code&state=google-test-state&uid=different-google-owner&email=#{email}" + ) + + assert redirected_to(conn) == ~p"/users/log-in" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "cannot be used" + refute get_session(conn, :user_token) + refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "different-google-owner") + assert Repo.get!(AuthIdentity, original_identity.id).provider_uid == "original-google-owner" + end + test "logs in only through an identity already linked to the local account", %{conn: conn} do user = user_fixture() @@ -314,7 +415,11 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do ) |> recycle() |> post(~p"/auth/google/complete-registration", %{ - "google_registration" => %{"locale" => "uk", "terms_accepted" => "true"} + "google_registration" => %{ + "display_name" => "Chosen Continued Helper", + "locale" => "uk", + "terms_accepted" => "true" + } }) assert redirected_to(conn) == ~p"/" @@ -324,7 +429,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do assert user.confirmed_at assert user.accepted_terms_at assert user.locale == "uk" - assert user.display_name == "Continued Helper" + assert user.display_name == "Chosen Continued Helper" assert %AuthIdentity{user_id: user_id} = Repo.get_by!(AuthIdentity, provider: :google, provider_uid: "continue-google") @@ -344,15 +449,45 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do ) |> recycle() |> post(~p"/auth/google/complete-registration", %{ - "google_registration" => %{"locale" => "en", "terms_accepted" => "false"} + "google_registration" => %{ + "display_name" => "No Terms Helper", + "locale" => "en", + "terms_accepted" => "false" + } }) - assert redirected_to(conn) == ~p"/auth/google/complete" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "18 or older" + html = html_response(conn, 422) + assert html =~ "you must confirm that you are 18+ and accept the rules" refute Accounts.get_user_by_email(email) refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "no-terms-google") end + test "keeps the Google completion form open when the public name is invalid", %{conn: conn} do + email = unique_user_email() + + conn = + conn + |> post(~p"/auth/google/login") + |> recycle() + |> get( + ~p"/auth/google/callback?code=code&state=google-test-state&uid=invalid-name-google&email=#{email}&name=Initial%20Name" + ) + |> recycle() + |> post(~p"/auth/google/complete-registration", %{ + "google_registration" => %{ + "display_name" => " ", + "locale" => "en", + "terms_accepted" => "true" + } + }) + + html = html_response(conn, 422) + assert html =~ "Create your Who Need Help account" + assert html =~ "can't be blank" + refute Accounts.get_user_by_email(email) + refute Repo.get_by(AuthIdentity, provider: :google, provider_uid: "invalid-name-google") + end + test "links an existing account after one magic-link verification", %{conn: conn} do user = user_fixture() assert_email_sent()