Impersonation system implementation

This commit is contained in:
JakeBreath
2026-08-03 21:33:31 -05:00
parent 87b0d46232
commit 95acc9b8d2
12 changed files with 545 additions and 8 deletions
+33
View File
@@ -15,3 +15,36 @@ def app_version(request):
'OS_NAME': os_name, 'OS_NAME': os_name,
'PAGE_GEN_TIME': page_gen_time, 'PAGE_GEN_TIME': page_gen_time,
} }
def impersonation(request):
"""Expose the impersonation state for the UI (user-bar chip + menu).
Reads the impersonator id stored in the session when a staff member is
acting as another user.
"""
from django.contrib.auth import get_user_model
from django.core.exceptions import ObjectDoesNotExist
from django.urls import reverse
impersonator_id = request.session.get('impersonator_id')
impersonator = None
avatar_url = None
if impersonator_id:
impersonator = (
get_user_model().objects.filter(pk=impersonator_id).first()
)
if impersonator is None:
request.session.pop('impersonator_id', None)
else:
try:
profile = impersonator.userprofile
except ObjectDoesNotExist:
profile = None
if profile is not None and profile.avatar_id:
avatar_url = reverse('library:file_request', args=[profile.avatar.uuid])
return {
'impersonating': impersonator is not None,
'impersonator': impersonator,
'impersonator_avatar_url': avatar_url,
}
+1
View File
@@ -71,6 +71,7 @@ TEMPLATES = [
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'common.context_processors.app_version', 'common.context_processors.app_version',
'common.context_processors.impersonation',
], ],
}, },
}, },
+18 -1
View File
@@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from .models import ApiToken, UserProfile from .models import ApiToken, ImpersonationLog, UserProfile
@admin.register(UserProfile) @admin.register(UserProfile)
@@ -13,3 +13,20 @@ class UserProfileAdmin(admin.ModelAdmin):
class ApiTokenAdmin(admin.ModelAdmin): class ApiTokenAdmin(admin.ModelAdmin):
list_display = ('user', 'label', 'key_prefix', 'created_at', 'last_used') list_display = ('user', 'label', 'key_prefix', 'created_at', 'last_used')
search_fields = ('user__username', 'label') search_fields = ('user__username', 'label')
@admin.register(ImpersonationLog)
class ImpersonationLogAdmin(admin.ModelAdmin):
list_display = ('impersonator', 'target', 'reason', 'started_at', 'ended_at')
list_filter = ('started_at', 'ended_at')
search_fields = ('impersonator__username', 'target__username', 'reason')
readonly_fields = ('impersonator', 'target', 'reason', 'started_at', 'ended_at')
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
@@ -0,0 +1,30 @@
# Generated by Django 6.0.3 on 2026-08-04 02:06
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('profiles', '0004_alter_userprofile_bio'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ImpersonationLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reason', models.CharField(blank=True, default='', max_length=255)),
('started_at', models.DateTimeField(auto_now_add=True)),
('ended_at', models.DateTimeField(blank=True, null=True)),
('impersonator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='impersonation_sessions', to=settings.AUTH_USER_MODEL)),
('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='impersonated_sessions', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-started_at'],
},
),
]
+23
View File
@@ -54,3 +54,26 @@ class ApiToken(models.Model):
def __str__(self): def __str__(self):
return f'{self.user.username} - {self.label} ({self.key_prefix}...)' return f'{self.user.username} - {self.label} ({self.key_prefix}...)'
class ImpersonationLog(models.Model):
"""Audit trail of staff impersonation sessions (reviewed in Django admin)."""
impersonator = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='impersonation_sessions',
)
target = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='impersonated_sessions',
)
reason = models.CharField(max_length=255, blank=True, default='')
started_at = models.DateTimeField(auto_now_add=True)
ended_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-started_at']
def __str__(self):
return f'{self.impersonator.username}{self.target.username}'
+151 -1
View File
@@ -10,7 +10,7 @@ from django.urls import reverse
from library.models import FileIndex from library.models import FileIndex
from .models import BIO_MAX_LENGTH, ApiToken, UserProfile from .models import BIO_MAX_LENGTH, ApiToken, ImpersonationLog, UserProfile
def _read_media(stored_path): def _read_media(stored_path):
@@ -423,3 +423,153 @@ class FileRequestTests(MediaTestCase, GatedTestCase):
self.gate() self.gate()
resp = self.client.get(self.url) resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 404) self.assertEqual(resp.status_code, 404)
class ImpersonationTests(GatedTestCase):
"""Staff impersonation: switch users without logging out, PK-based
staff-to-staff rule, superusers off-limits, gate state preserved."""
def setUp(self):
User = get_user_model()
# Created in this order → increasing PKs: boss(1) < junior(2) < normal(3) < top(4).
self.boss = User.objects.create_user(username='Boss', password='pw', is_staff=True)
UserProfile.objects.get_or_create(user=self.boss)
self.junior = User.objects.create_user(username='JuniorStaff', password='pw', is_staff=True)
UserProfile.objects.get_or_create(user=self.junior)
self.normal = User.objects.create_user(username='NormalUser', password='pw')
UserProfile.objects.get_or_create(user=self.normal)
self.top = User.objects.create_superuser(username='TopSuper', password='pw')
UserProfile.objects.get_or_create(user=self.top)
def _login(self, user):
self.gate()
self.client.login(username=user.username, password='pw')
def _start(self, user, **extra):
return self.client.post(
reverse('profiles:impersonate_start', args=[user.pk]), data=extra,
)
@property
def active_user_id(self):
value = self.client.session.get('_auth_user_id')
return int(value) if value else None
def test_users_page_gated(self):
resp = self.client.get(reverse('profiles:user_list'))
self.assertEqual(resp.status_code, 302)
def test_users_page_lists_all(self):
self.gate()
resp = self.client.get(reverse('profiles:user_list'))
self.assertEqual(resp.status_code, 200)
for name in ('Boss', 'JuniorStaff', 'NormalUser', 'TopSuper'):
self.assertContains(resp, name)
def test_users_page_search(self):
self.gate()
resp = self.client.get(reverse('profiles:user_list'), {'q': 'Normal'})
self.assertContains(resp, 'NormalUser')
self.assertNotContains(resp, 'Boss')
def test_impersonate_requires_post(self):
self._login(self.boss)
resp = self.client.get(reverse('profiles:impersonate_start', args=[self.normal.pk]))
self.assertEqual(resp.status_code, 405)
def test_non_staff_cannot_impersonate(self):
self._login(self.normal)
resp = self._start(self.normal)
self.assertEqual(resp.status_code, 403)
self.assertEqual(ImpersonationLog.objects.count(), 0)
def test_staff_impersonates_normal_user(self):
self._login(self.boss)
resp = self._start(self.normal, reason='moderation review')
self.assertRedirects(resp, reverse('profiles:user_profile', args=['NormalUser']))
self.assertEqual(self.active_user_id, self.normal.pk)
self.assertEqual(self.client.session.get('impersonator_id'), self.boss.pk)
self.assertTrue(self.client.session.get('authorized'))
log = ImpersonationLog.objects.get()
self.assertEqual(log.impersonator, self.boss)
self.assertEqual(log.target, self.normal)
self.assertEqual(log.reason, 'moderation review')
self.assertIsNone(log.ended_at)
def test_gate_preserved_while_impersonating(self):
self._login(self.boss)
self._start(self.normal)
# Follow-up page loads render (200) instead of bouncing to the gate.
resp = self.client.get(reverse('profiles:user_profile', args=['NormalUser']))
self.assertEqual(resp.status_code, 200)
def test_cannot_impersonate_self(self):
self._login(self.boss)
resp = self._start(self.boss)
self.assertEqual(resp.status_code, 302)
self.assertEqual(self.active_user_id, self.boss.pk)
self.assertNotIn('impersonator_id', self.client.session)
self.assertEqual(ImpersonationLog.objects.count(), 0)
def test_older_staff_can_impersonate_newer_staff(self):
self._login(self.boss)
self._start(self.junior)
self.assertEqual(self.active_user_id, self.junior.pk)
def test_newer_staff_cannot_impersonate_older_staff(self):
self._login(self.junior)
self._start(self.boss)
self.assertEqual(self.active_user_id, self.junior.pk)
self.assertNotIn('impersonator_id', self.client.session)
self.assertEqual(ImpersonationLog.objects.count(), 0)
def test_cannot_impersonate_superuser(self):
self._login(self.boss)
self._start(self.top)
self.assertEqual(self.active_user_id, self.boss.pk)
self.assertEqual(ImpersonationLog.objects.count(), 0)
def test_stop_impersonation_restores_original(self):
self._login(self.boss)
self._start(self.normal)
resp = self.client.post(reverse('profiles:impersonate_stop'))
self.assertRedirects(resp, reverse('landing:home'))
self.assertEqual(self.active_user_id, self.boss.pk)
self.assertNotIn('impersonator_id', self.client.session)
self.assertTrue(self.client.session.get('authorized'))
log = ImpersonationLog.objects.get()
self.assertIsNotNone(log.ended_at)
def test_logout_while_impersonating_stops_instead(self):
self._login(self.boss)
self._start(self.normal)
resp = self.client.post(reverse('profiles:logout'))
self.assertRedirects(resp, reverse('landing:home'))
# Back on the real account, still logged in, gate still open.
self.assertEqual(self.active_user_id, self.boss.pk)
self.assertNotIn('impersonator_id', self.client.session)
self.assertTrue(self.client.session.get('authorized'))
self.assertTrue(ImpersonationLog.objects.get().ended_at)
# A second logout is a real logout.
self.client.post(reverse('profiles:logout'))
self.assertIsNone(self.active_user_id)
def test_impersonate_button_visibility(self):
self._login(self.boss)
resp = self.client.get(reverse('profiles:user_profile', args=['NormalUser']))
self.assertContains(resp, 'Impersonate')
# Self → no button.
resp = self.client.get(reverse('profiles:user_profile', args=['Boss']))
self.assertNotContains(resp, 'Impersonate')
def test_impersonate_button_hidden_for_non_staff(self):
self._login(self.normal)
resp = self.client.get(reverse('profiles:user_profile', args=['NormalUser']))
self.assertNotContains(resp, 'Impersonate')
def test_impersonate_button_hidden_while_impersonating(self):
self._login(self.boss)
self._start(self.junior) # acting as a staff account now
# Even though the impersonated account is staff, no impersonate button.
resp = self.client.get(reverse('profiles:user_profile', args=['Boss']))
self.assertNotContains(resp, 'Impersonate')
+3
View File
@@ -14,4 +14,7 @@ urlpatterns = [
path('settings/tokens/<int:token_id>/delete/', views.token_delete, name='token_delete'), path('settings/tokens/<int:token_id>/delete/', views.token_delete, name='token_delete'),
path('profiles/<str:username>/', views.user_profile, name='user_profile'), path('profiles/<str:username>/', views.user_profile, name='user_profile'),
path('settings/account/bio/preview/', views.bio_preview, name='bio_preview'), path('settings/account/bio/preview/', views.bio_preview, name='bio_preview'),
path('users/', views.user_list, name='user_list'),
path('impersonate/stop/', views.impersonate_stop, name='impersonate_stop'),
path('impersonate/<int:user_id>/', views.impersonate_start, name='impersonate_start'),
] ]
+96 -2
View File
@@ -8,14 +8,16 @@ from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.http import JsonResponse from django.core.paginator import Paginator
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from common.markdown import render_markdown from common.markdown import render_markdown
from library.models import FileIndex from library.models import FileIndex
from .forms import AvatarForm, BioForm, EmailForm, UsernameForm from .forms import AvatarForm, BioForm, EmailForm, UsernameForm
from .models import ApiToken, UserProfile from .models import ApiToken, ImpersonationLog, UserProfile
def register(request): def register(request):
@@ -47,6 +49,10 @@ def user_login(request):
def user_logout(request): def user_logout(request):
if request.method == 'POST': if request.method == 'POST':
was_authorized = request.session.get('authorized', False) was_authorized = request.session.get('authorized', False)
# While impersonating, "logout" returns to the real account instead of
# ending the session entirely.
if _stop_impersonation(request):
return redirect('landing:home')
logout(request) logout(request)
# logout() flushes the session (wiping the gate flag). The gate is a # logout() flushes the session (wiping the gate flag). The gate is a
# site-wide master password, separate from user accounts — preserve it. # site-wide master password, separate from user accounts — preserve it.
@@ -56,6 +62,85 @@ def user_logout(request):
return redirect('landing:home') return redirect('landing:home')
# ---------- Impersonation (staff only) ----------
def _stop_impersonation(request):
"""Return the session to the original (impersonator) account. Shared by
impersonate_stop and user_logout. login() flushes the session, so the gate
flag must be restored afterwards."""
impersonator_id = request.session.pop('impersonator_id', None)
if not impersonator_id:
return False
original = get_user_model().objects.filter(pk=impersonator_id).first()
if original is None:
return False
ImpersonationLog.objects.filter(
impersonator=original, ended_at__isnull=True,
).update(ended_at=timezone.now())
login(request, original, backend='django.contrib.auth.backends.ModelBackend')
request.session['authorized'] = True
return True
def user_list(request):
"""Browse-style directory of every account on the site."""
q = request.GET.get('q', '').strip()
users = get_user_model().objects.select_related('userprofile').order_by('username')
if q:
users = users.filter(username__icontains=q)
paginator = Paginator(users, 24)
page_obj = paginator.get_page(request.GET.get('page'))
return render(request, 'profiles/users.html', {
'users': page_obj,
'q': q,
})
@login_required
def impersonate_start(request, user_id):
if request.method != 'POST':
return HttpResponse(status=405)
if not request.user.is_staff:
return HttpResponseForbidden('Only staff can impersonate users.')
if request.session.get('impersonator_id'):
messages.error(request, 'Stop the current impersonation first.')
return redirect('profiles:user_profile', username=request.user.username)
actor = request.user
target = get_object_or_404(get_user_model(), pk=user_id)
if target.pk == actor.pk:
messages.error(request, 'You cannot impersonate yourself.')
return redirect('profiles:user_profile', username=target.username)
if target.is_superuser:
messages.error(request, 'Superusers cannot be impersonated.')
return redirect('profiles:user_profile', username=target.username)
if target.is_staff and actor.pk >= target.pk:
# Staff-to-staff: only an older (lower PK) account may impersonate.
messages.error(request, 'You cannot impersonate a more senior staff member.')
return redirect('profiles:user_profile', username=target.username)
reason = request.POST.get('reason', '')[:255]
ImpersonationLog.objects.create(impersonator=actor, target=target, reason=reason)
login(request, target, backend='django.contrib.auth.backends.ModelBackend')
request.session['authorized'] = True
request.session['impersonator_id'] = actor.pk
messages.success(request, f'Now acting as {target.username}.')
return redirect('profiles:user_profile', username=target.username)
@login_required
def impersonate_stop(request):
if request.method != 'POST':
return HttpResponse(status=405)
_stop_impersonation(request)
messages.success(request, 'Impersonation stopped.')
return redirect('landing:home')
@login_required @login_required
def settings_page(request): def settings_page(request):
tokens = request.user.api_tokens.all() tokens = request.user.api_tokens.all()
@@ -186,6 +271,14 @@ def user_profile(request, username):
user = get_object_or_404(User, username__iexact=username) user = get_object_or_404(User, username__iexact=username)
profile, created = UserProfile.objects.get_or_create(user=user) profile, created = UserProfile.objects.get_or_create(user=user)
is_owner = request.user.is_authenticated and request.user.pk == user.pk is_owner = request.user.is_authenticated and request.user.pk == user.pk
can_impersonate = (
request.user.is_authenticated
and request.user.is_staff
and not request.session.get('impersonator_id')
and request.user.pk != user.pk
and not user.is_superuser
and (not user.is_staff or request.user.pk < user.pk)
)
bio_form = None bio_form = None
if is_owner: if is_owner:
if request.method == 'POST' and request.POST.get('action') == 'bio': if request.method == 'POST' and request.POST.get('action') == 'bio':
@@ -201,6 +294,7 @@ def user_profile(request, username):
'profile': profile, 'profile': profile,
'profile_user': user, 'profile_user': user,
'is_owner': is_owner, 'is_owner': is_owner,
'can_impersonate': can_impersonate,
'bio_form': bio_form, 'bio_form': bio_form,
'projects': user.projects.select_related('owner', 'thumbnail').prefetch_related( 'projects': user.projects.select_related('owner', 'thumbnail').prefetch_related(
'tag_links__tag__category', 'versions', 'tag_links__tag__category', 'versions',
+85
View File
@@ -2625,3 +2625,88 @@ a.deletelink {
.autosave-status.saved { color: var(--md-sys-color-primary); } .autosave-status.saved { color: var(--md-sys-color-primary); }
.autosave-status.error { color: var(--md-sys-color-error); } .autosave-status.error { color: var(--md-sys-color-error); }
.autosave-status.idle { color: var(--md-sys-color-on-surface-variant); } .autosave-status.idle { color: var(--md-sys-color-on-surface-variant); }
/* ========== Impersonation ========== */
.impersonate-chip {
display: inline-flex;
align-items: center;
gap: 6px;
margin-right: 8px;
padding: 3px 10px 3px 4px;
border-radius: 999px;
background: var(--md-sys-color-surface-variant);
border: 1px solid var(--md-sys-color-outline);
color: var(--md-sys-color-on-surface);
font-size: 0.72rem;
font-weight: 500;
text-decoration: none;
vertical-align: middle;
}
.impersonate-chip:hover {
border-color: var(--md-sys-color-primary);
color: var(--md-sys-color-primary);
}
.impersonate-chip img {
width: 22px;
height: 22px;
border-radius: 50%;
object-fit: cover;
}
.impersonate-chip .default-avatar.mini,
.default-avatar.mini {
width: 22px;
height: 22px;
font-size: 0.65rem;
flex: none;
}
.user-info {
padding: 4px 12px;
font-size: 0.75rem;
color: var(--md-sys-color-on-surface-variant);
}
.user-info a {
color: var(--md-sys-color-primary);
}
/* Users directory */
.user-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.user-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 12px;
background: var(--md-sys-color-surface);
border: 1px solid var(--md-sys-color-outline-variant);
text-decoration: none;
color: inherit;
}
.user-card:hover {
border-color: var(--md-sys-color-primary);
}
.user-card-avatar {
width: 48px;
height: 48px;
flex: none;
border-radius: 50%;
overflow: hidden;
background: var(--md-sys-color-surface-variant);
}
.user-card-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-card-body h3 {
margin: 0 0 4px;
font-size: 0.95rem;
}
.user-card-meta {
margin: 0;
font-size: 0.75rem;
color: var(--md-sys-color-on-surface-variant);
}
+25 -2
View File
@@ -41,6 +41,9 @@
<a href="{% url 'library:browse' %}"> <a href="{% url 'library:browse' %}">
<i class="fas fa-compass"></i><span class="nav-text"> Browse</span> <i class="fas fa-compass"></i><span class="nav-text"> Browse</span>
</a> </a>
<a href="{% url 'profiles:user_list' %}">
<i class="fas fa-users"></i><span class="nav-text"> Users</span>
</a>
</nav> </nav>
</div> </div>
<main class="main-content"> <main class="main-content">
@@ -49,6 +52,17 @@
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div class="user-bar"> <div class="user-bar">
{% if impersonating %}
<a class="impersonate-chip" href="{% url 'profiles:user_profile' impersonator.username %}"
title="Impersonator: {{ impersonator.username }}">
{% if impersonator_avatar_url %}
<img src="{{ impersonator_avatar_url }}" alt="">
{% else %}
<div class="default-avatar mini">{{ impersonator.username|first|upper }}</div>
{% endif %}
<span>Impersonator: {{ impersonator.username }}</span>
</a>
{% endif %}
<div class="user-avatar" onclick="toggleUserMenu()"> <div class="user-avatar" onclick="toggleUserMenu()">
{% if user.userprofile.avatar %} {% if user.userprofile.avatar %}
<img src="{{ user.userprofile.avatar_url }}" alt="Avatar"> <img src="{{ user.userprofile.avatar_url }}" alt="Avatar">
@@ -60,7 +74,12 @@
<a href="{% url 'profiles:user_profile' request.user.username %}" class="user-edit"> <a href="{% url 'profiles:user_profile' request.user.username %}" class="user-edit">
<i class="fas fa-user-circle"></i> {{ user.username }} <i class="fas fa-user-circle"></i> {{ user.username }}
</a> </a>
<div class="user-info"></div> <div class="user-info">
{% if impersonating %}
<i class="fas fa-mask"></i> acting from
<a href="{% url 'profiles:user_profile' impersonator.username %}"><strong>{{ impersonator.username }}</strong></a>
{% endif %}
</div>
<a href="{% url 'profiles:account' %}"> <a href="{% url 'profiles:account' %}">
<i class="fas fa-user-cog"></i> Account Settings <i class="fas fa-user-cog"></i> Account Settings
</a> </a>
@@ -70,7 +89,11 @@
<form method="post" action="{% url 'profiles:logout' %}" style="display: inline;"> <form method="post" action="{% url 'profiles:logout' %}" style="display: inline;">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="logout-button"> <button type="submit" class="logout-button">
<i class="fas fa-sign-out-alt"></i> Logout {% if impersonating %}
<i class="fas fa-undo-alt"></i> Stop impersonating
{% else %}
<i class="fas fa-sign-out-alt"></i> Logout
{% endif %}
</button> </button>
</form> </form>
</div> </div>
+30
View File
@@ -16,6 +16,14 @@
<div class="profile-info"> <div class="profile-info">
<h1>{{ profile_user.username }}</h1> <h1>{{ profile_user.username }}</h1>
<p class="profile-joined"><i class="fas fa-calendar"></i> Joined {{ profile.joined|date:"F j, Y" }}</p> <p class="profile-joined"><i class="fas fa-calendar"></i> Joined {{ profile.joined|date:"F j, Y" }}</p>
{% if can_impersonate %}
<form method="post" action="{% url 'profiles:impersonate_start' profile_user.pk %}" class="impersonate-form">
{% csrf_token %}
<button type="submit" class="btn btn-secondary" id="impersonate-btn">
<i class="fas fa-mask"></i> Impersonate
</button>
</form>
{% endif %}
{% if is_owner %} {% if is_owner %}
<p><a href="{% url 'profiles:account' %}" class="btn btn-secondary"><i class="fas fa-cog"></i> Account Settings</a></p> <p><a href="{% url 'profiles:account' %}" class="btn btn-secondary"><i class="fas fa-cog"></i> Account Settings</a></p>
{% endif %} {% endif %}
@@ -78,6 +86,28 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
{% if can_impersonate %}
<script>
(function () {
const btn = document.getElementById('impersonate-btn');
if (!btn) return;
btn.addEventListener('click', (e) => {
e.preventDefault();
const reason = prompt('Reason for impersonation (optional):', '');
if (reason === null) return;
const form = btn.closest('form');
if (reason.trim()) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'reason';
input.value = reason.trim();
form.appendChild(input);
}
form.submit();
});
})();
</script>
{% endif %}
{% if is_owner %} {% if is_owner %}
<script> <script>
(function () { (function () {
+48
View File
@@ -0,0 +1,48 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Users - Packs Site{% endblock %}
{% block content %}
<h1><i class="fas fa-users"></i> Users</h1>
<form method="get" class="browse-search-form">
<div class="browse-search-row">
<input type="text" name="q" value="{{ q }}" placeholder="Search users…" class="browse-search-input">
<button type="submit" class="btn btn-primary"><i class="fas fa-search"></i></button>
</div>
</form>
<div class="user-grid">
{% for u in users %}
<a href="{% url 'profiles:user_profile' u.username %}" class="user-card">
<div class="user-card-avatar">
{% if u.userprofile.avatar %}
<img src="{{ u.userprofile.avatar_url }}" alt="{{ u.username }}">
{% else %}
<div class="default-avatar">{{ u.username|first|upper }}</div>
{% endif %}
</div>
<div class="user-card-body">
<h3>{{ u.username }}</h3>
<p class="user-card-meta"><i class="fas fa-calendar"></i> Joined {{ u.userprofile.joined|date:"M j, Y" }}</p>
<p class="user-card-meta"><i class="fas fa-box-open"></i> {{ u.projects.count }} pack{{ u.projects.count|pluralize }}</p>
</div>
</a>
{% empty %}
<p class="empty-hint">No users found.</p>
{% endfor %}
</div>
{% if users.has_other_pages %}
<div class="pagination">
{% if users.has_previous %}
<a href="?{% if q %}q={{ q|urlencode }}&{% endif %}page={{ users.previous_page_number }}">&laquo;</a>
{% endif %}
<span>Page {{ users.number }} of {{ users.paginator.num_pages }}</span>
{% if users.has_next %}
<a href="?{% if q %}q={{ q|urlencode }}&{% endif %}page={{ users.next_page_number }}">&raquo;</a>
{% endif %}
</div>
{% endif %}
{% endblock %}