diff --git a/plain-admin/plain/admin/README.md b/plain-admin/plain/admin/README.md index 72a174400a..c28c684ed9 100644 --- a/plain-admin/plain/admin/README.md +++ b/plain-admin/plain/admin/README.md @@ -480,6 +480,8 @@ The impersonate feature lets admin users log in as another user to debug issues To start impersonating, visit a user's detail page in the admin and click the "Impersonate" link. The admin toolbar will show who you're impersonating and provide a link to stop. +Inside the admin itself, the account menu in the top right keeps showing *your* avatar while you impersonate — it acts on your account, not the impersonated user's — and it lists who you're impersonating with a "Stop impersonating" item. + By default, users with `is_admin=True` can impersonate other users. Admin users cannot be impersonated (for security). You can customize who can impersonate by defining `IMPERSONATE_ALLOWED` in your settings: ```python diff --git a/plain-admin/plain/admin/templates/admin/_header.html b/plain-admin/plain/admin/templates/admin/_header.html index 3505b71100..81e09ee2bd 100644 --- a/plain-admin/plain/admin/templates/admin/_header.html +++ b/plain-admin/plain/admin/templates/admin/_header.html @@ -55,11 +55,15 @@ aria-controls="user-menu-popover" class="flex items-center cursor-pointer" > - {% set avatar_url = user.get_avatar_url() if user.get_avatar_url is defined else None %} + {# While impersonating, this menu still acts on the real + account (App Settings, Log out), so it shows the + impersonator — not the user being impersonated. #} + {% set account_user = impersonator or user %} + {% set avatar_url = account_user.get_avatar_url() if account_user.get_avatar_url is defined else None %} {% if avatar_url %} {{ user }} {% else %} @@ -90,6 +94,16 @@
{% endif %}
+ {% if impersonator %} +
+ Impersonating {{ user }} +
+ + + Stop impersonating + +
+ {% endif %} {% include "admin/user_menu_items.html" ignore missing %} diff --git a/plain-admin/plain/admin/views/base.py b/plain-admin/plain/admin/views/base.py index f221ecad50..c07a07a823 100644 --- a/plain-admin/plain/admin/views/base.py +++ b/plain-admin/plain/admin/views/base.py @@ -11,6 +11,7 @@ from plain.urls import reverse from plain.utils import timezone +from ..impersonate import get_request_impersonator from ..models import PinnedNavItem from .registry import registry, track_recent_nav from .types import Img @@ -118,6 +119,9 @@ def get_template_context(self) -> dict[str, Any]: ) ) context["preflight_counts"] = get_check_counts() + # The real, logged-in user when impersonation is active (None otherwise). + # The header's account menu belongs to *them*, not the impersonated user. + context["impersonator"] = get_request_impersonator(self.request) context["admin_url"] = registry.get_url return context diff --git a/plain-admin/tests/app/users/models.py b/plain-admin/tests/app/users/models.py index b2b083d55b..4c3e03c883 100644 --- a/plain-admin/tests/app/users/models.py +++ b/plain-admin/tests/app/users/models.py @@ -12,6 +12,10 @@ class User(postgres.Model): query: postgres.QuerySet[User] = postgres.QuerySet() + def get_avatar_url(self) -> str: + """The admin header renders this when the user model provides it.""" + return f"https://avatars.example.com/{self.username}.png" + @property def username_upper(self) -> str: """A computed (non-column) field, to exercise in-memory sorting.""" diff --git a/plain-admin/tests/public/test_impersonate.py b/plain-admin/tests/public/test_impersonate.py index fe80a6ca8a..8e70ab9b65 100644 --- a/plain-admin/tests/public/test_impersonate.py +++ b/plain-admin/tests/public/test_impersonate.py @@ -72,3 +72,24 @@ def test_admin_users_cannot_be_impersonated(db): # After the refusal the marker is cleared, so normal requests resume. assert client.get("/whoami").user.id == admin.id + + +def test_admin_header_shows_the_impersonator_not_the_impersonated_user(db): + """The account menu acts on the real account, so it shows that avatar.""" + admin = User.query.create(username="admin", is_admin=True) + target = User.query.create(username="target", is_admin=False) + + client = Client() + client.force_login(admin) + client.get(f"/admin/impersonate/start/{target.id}") + + # Land on a real admin page (the index redirects to the first list view). + admin_page = client.get(client.get("/admin").url) + assert admin_page.status_code == 200 + + content = admin_page.content.decode() + assert "https://avatars.example.com/admin.png" in content + assert "https://avatars.example.com/target.png" not in content + + # ...and the menu says who is being impersonated, with a way out. + assert "Stop impersonating" in content