Skip to content
Draft
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
2 changes: 2 additions & 0 deletions plain-admin/plain/admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions plain-admin/plain/admin/templates/admin/_header.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
<img
src="{{ avatar_url }}"
alt="{{ user }}"
alt="{{ account_user }}"
class="w-7 h-7 rounded-full"
/>
{% else %}
Expand Down Expand Up @@ -90,6 +94,16 @@
<div role="separator"></div>
{% endif %}
<div role="menu">
{% if impersonator %}
<div class="px-2 py-1.5 text-xs text-admin-muted-foreground">
Impersonating <span class="font-medium text-admin-foreground">{{ user }}</span>
</div>
<a role="menuitem" href="{{ url('admin:impersonate:stop') }}">
<admin.Icon name="x-octagon" />
Stop impersonating
</a>
<div role="separator"></div>
{% endif %}
{% include "admin/user_menu_items.html" ignore missing %}
<a role="menuitem" href="{{ url('admin:settings') }}">
<admin.Icon name="gear" />
Expand Down
4 changes: 4 additions & 0 deletions plain-admin/plain/admin/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions plain-admin/tests/app/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
21 changes: 21 additions & 0 deletions plain-admin/tests/public/test_impersonate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading