diff --git a/assets/js/app.js b/assets/js/app.js index e58dcf7..2032bf8 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -192,6 +192,26 @@ document.addEventListener("submit", event => { const action = new URL(form.action, window.location.origin) if (action.origin === window.location.origin && action.pathname === "/users/log-out") { + const deviceId = window.localStorage.getItem("wnh.push.server-device-id") + + if (deviceId) { + const input = document.createElement("input") + input.type = "hidden" + input.name = "push_device_id" + input.value = deviceId + form.append(input) + window.localStorage.removeItem("wnh.push.server-device-id") + } + + if (window.WhoNeedHelpAndroid?.postMessage) { + window.WhoNeedHelpAndroid.postMessage(JSON.stringify({action: "disable_push"})) + } else if ("serviceWorker" in navigator) { + navigator.serviceWorker.ready + .then(registration => registration.pushManager.getSubscription()) + .then(subscription => subscription?.unsubscribe()) + .catch(error => console.warn("Browser push unsubscribe on logout failed", error)) + } + window.WhoNeedHelpAndroid?.postMessage?.(JSON.stringify({action: "google_sign_out"})) } }) diff --git a/lib/who_need_help_web/controllers/user_session_controller.ex b/lib/who_need_help_web/controllers/user_session_controller.ex index 4ab7e2a..1ddda81 100644 --- a/lib/who_need_help_web/controllers/user_session_controller.ex +++ b/lib/who_need_help_web/controllers/user_session_controller.ex @@ -3,7 +3,8 @@ defmodule WhoNeedHelpWeb.UserSessionController do require Logger - alias WhoNeedHelp.{Accounts, GoogleAuth} + alias WhoNeedHelp.{Accounts, GoogleAuth, Notifications} + alias WhoNeedHelp.Accounts.Scope alias WhoNeedHelp.Trust.RateLimiter alias WhoNeedHelpWeb.{GoogleAuthPending, UserAuth} @@ -125,12 +126,26 @@ defmodule WhoNeedHelpWeb.UserSessionController do |> render(:new, form: Phoenix.Component.to_form(%{}, as: "user")) end - def delete(conn, _params) do + def delete(conn, params) do conn + |> maybe_disable_logout_push_device(params) |> put_flash(:info, gettext("Logged out successfully.")) |> UserAuth.log_out_user() end + defp maybe_disable_logout_push_device( + %{assigns: %{current_scope: %Scope{user: %Accounts.User{}} = scope}} = conn, + %{"push_device_id" => device_id} + ) + when is_binary(device_id) do + # The context scopes the lookup to the signed-in user. Missing, malformed, or + # another user's device IDs must never prevent the session from being closed. + _result = Notifications.disable_device(scope, device_id) + conn + end + + defp maybe_disable_logout_push_device(conn, _params), do: conn + defp invalid_password_response(conn, user_params) do # Keep the response identical for unknown accounts and incorrect passwords. conn diff --git a/test/who_need_help_web/controllers/user_session_controller_test.exs b/test/who_need_help_web/controllers/user_session_controller_test.exs index ab0af7a..39e0ec3 100644 --- a/test/who_need_help_web/controllers/user_session_controller_test.exs +++ b/test/who_need_help_web/controllers/user_session_controller_test.exs @@ -3,7 +3,7 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do import WhoNeedHelp.AccountsFixtures import Swoosh.TestAssertions - alias WhoNeedHelp.Accounts + alias WhoNeedHelp.{Accounts, Notifications} setup do %{unconfirmed_user: unconfirmed_user_fixture(), user: user_fixture()} @@ -273,5 +273,56 @@ defmodule WhoNeedHelpWeb.UserSessionControllerTest do refute get_session(conn, :user_token) assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" end + + test "disables only the current browser push device", %{conn: conn, user: user} do + scope = Accounts.Scope.for_user(user) + {:ok, current_device} = Notifications.register_device(scope, push_device_attrs()) + {:ok, remaining_device} = Notifications.register_device(scope, push_device_attrs()) + + conn = + conn + |> log_in_user(user) + |> delete(~p"/users/log-out", %{"push_device_id" => current_device.id}) + + assert redirected_to(conn) == ~p"/" + assert Enum.map(Notifications.list_devices(scope), & &1.id) == [remaining_device.id] + end + + test "cannot disable another user's push device during logout", %{conn: conn, user: user} do + other_user = user_fixture() + other_scope = Accounts.Scope.for_user(other_user) + {:ok, other_device} = Notifications.register_device(other_scope, push_device_attrs()) + + conn = + conn + |> log_in_user(user) + |> delete(~p"/users/log-out", %{"push_device_id" => other_device.id}) + + assert redirected_to(conn) == ~p"/" + assert [%{id: device_id}] = Notifications.list_devices(other_scope) + assert device_id == other_device.id + end + + test "malformed push device ID does not prevent logout", %{conn: conn, user: user} do + conn = + conn + |> log_in_user(user) + |> delete(~p"/users/log-out", %{"push_device_id" => "not-a-device-id"}) + + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + end + end + + defp push_device_attrs do + %{ + "platform" => "web", + "provider" => "web_push", + "token" => "https://push.example/subscriptions/#{Ecto.UUID.generate()}", + "installation_id" => Ecto.UUID.generate(), + "p256dh" => "test-public-key", + "auth_secret" => "test-auth-secret", + "device_label" => "Test browser" + } end end