diff --git a/posthog/admin/admins/user_admin.py b/posthog/admin/admins/user_admin.py index 5a16ed14016a..66ea4000cd1d 100644 --- a/posthog/admin/admins/user_admin.py +++ b/posthog/admin/admins/user_admin.py @@ -1,5 +1,9 @@ +from urllib.parse import urlencode + from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin from django.contrib.auth.forms import UserChangeForm as DjangoUserChangeForm +from django.shortcuts import redirect +from django.urls import reverse from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ @@ -30,7 +34,8 @@ class UserAdmin(DjangoUserAdmin): form = UserChangeForm change_password_form = None # This view is not exposed in our subclass of UserChangeForm - change_form_template = "loginas/change_form.html" + # Extends loginas/change_form.html to also pre-fill the "Log in as" reason from a ?reason= param. + change_form_template = "admin/posthog/user/change_form.html" inlines = [OrganizationMemberInline, TOTPDeviceInline] fieldsets = ( @@ -70,6 +75,23 @@ class UserAdmin(DjangoUserAdmin): readonly_fields = ["id", "current_team", "current_organization"] ordering = ("email",) + def changelist_view(self, request, extra_context=None): + # Deep-link straight to a single user's change page — where the loginas "Log in as" + # button and its reason field live — from tools that only know the customer's email, + # not their user ID (e.g. our support desk). When an impersonation `reason` is passed + # alongside a search `q` that resolves to exactly one user by email, redirect to that + # user and carry the reason through so it can be pre-filled on the change page. + # `email` is unique, so an exact match identifies the user unambiguously. Without a + # `reason`, the normal changelist (search results) is shown as before. + reason = request.GET.get("reason") + query = request.GET.get("q") + if reason and query and self.has_view_or_change_permission(request): + match = User.objects.filter(email__iexact=query.strip()).first() + if match is not None: + change_url = reverse("admin:posthog_user_change", args=[match.pk]) + return redirect(f"{change_url}?{urlencode({'reason': reason})}") + return super().changelist_view(request, extra_context=extra_context) + def current_team_link(self, user: User): if not user.team: return "–" diff --git a/posthog/admin/test/__init__.py b/posthog/admin/test/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/posthog/admin/test/test_user_admin.py b/posthog/admin/test/test_user_admin.py new file mode 100644 index 000000000000..23aa45c7a2c8 --- /dev/null +++ b/posthog/admin/test/test_user_admin.py @@ -0,0 +1,56 @@ +from django.contrib import admin +from django.test import Client, override_settings +from django.urls import include, path + +from posthog.test.base import BaseTest + +# The admin URLs are only routed when ADMIN_PORTAL_ENABLED (DEBUG/DEMO), so give +# these tests their own urlconf that always mounts the admin + loginas routes. +urlpatterns = [ + path("admin/", include("loginas.urls")), + path("admin/", admin.site.urls), +] + + +@override_settings(ROOT_URLCONF="posthog.admin.test.test_user_admin") +class TestUserAdminImpersonationDeepLink(BaseTest): + def setUp(self): + super().setUp() + self.user.is_staff = True + self.user.is_superuser = True + self.user.save() + self.client = Client() + self.client.force_login(self.user) + + def test_email_search_with_reason_redirects_to_user_change_page(self): + target = self._create_user("customer@example.com") + + response = self.client.get( + "/admin/posthog/user/", {"q": "customer@example.com", "reason": "Support ticket #1234"} + ) + + assert response.status_code == 302 + assert response.headers["Location"] == ( + f"/admin/posthog/user/{target.pk}/change/?reason=Support+ticket+%231234" + ) + + def test_email_search_is_case_insensitive(self): + target = self._create_user("customer@example.com") + + response = self.client.get("/admin/posthog/user/", {"q": "Customer@Example.com", "reason": "ticket #1"}) + + assert response.status_code == 302 + assert response.headers["Location"].startswith(f"/admin/posthog/user/{target.pk}/change/") + + def test_search_without_reason_shows_changelist(self): + self._create_user("customer@example.com") + + response = self.client.get("/admin/posthog/user/", {"q": "customer@example.com"}) + + # No reason → normal changelist, no redirect to a specific user. + assert response.status_code == 200 + + def test_reason_without_matching_user_shows_changelist(self): + response = self.client.get("/admin/posthog/user/", {"q": "nobody@example.com", "reason": "ticket #1"}) + + assert response.status_code == 200 diff --git a/posthog/templates/admin/posthog/user/change_form.html b/posthog/templates/admin/posthog/user/change_form.html new file mode 100644 index 000000000000..918dfefa5db0 --- /dev/null +++ b/posthog/templates/admin/posthog/user/change_form.html @@ -0,0 +1,26 @@ +{% extends "loginas/change_form.html" %} + +{% block footer %} + {{ block.super }} + {# Pre-fill the "Log in as" reason from a ?reason= query param, so support tools can + deep-link an agent straight into impersonation with the ticket reference already set. + loginas clears the textarea when the modal opens, so we re-apply on link click too — + this handler binds after loginas's, and the setTimeout guarantees it runs last. #} + +{% endblock %}