Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion posthog/admin/admins/user_admin.py
Original file line number Diff line number Diff line change
@@ -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 _

Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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 "–"
Expand Down
Empty file added posthog/admin/test/__init__.py
Empty file.
56 changes: 56 additions & 0 deletions posthog/admin/test/test_user_admin.py
Original file line number Diff line number Diff line change
@@ -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

Check failure on line 20 in posthog/admin/test/test_user_admin.py

View workflow job for this annotation

GitHub Actions / Python code quality checks

Property "is_superuser" defined in "User" is read-only
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
26 changes: 26 additions & 0 deletions posthog/templates/admin/posthog/user/change_form.html
Original file line number Diff line number Diff line change
@@ -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. #}
<script>
django.jQuery(function () {
var $ = django.jQuery;
var params = new URLSearchParams(window.location.search);
var reason = params.get('reason');
if (!reason) {
return;
}
var applyReason = function () {
$('#loginas-reason').val(reason);
};
applyReason();
$('#loginas-link').on('click', function () {
setTimeout(applyReason, 0);
});
});
</script>
{% endblock %}
Loading