Files
Kochbuch/accounts/admin_views.py
T
Kyle DammannandClaude Opus 5.5 ef9deaeff7 Add phase 1: Docker setup, auth with invitations and roles
- Django 5.2 + PostgreSQL 17 via docker compose (app + db, volumes,
  healthcheck on /healthz, test profile running pytest against Postgres)
- Custom e-mail user model with user/admin roles
- Invitation-only registration with single-use, hashed, expiring links
- Admin area: invitations, role changes, deactivation, password reset
  via one-time link or temporary password (forced change on login)
- First admin created from ADMIN_EMAIL/ADMIN_PASSWORD on startup
- Login rate limiting per e-mail and IP (DB-backed, worker-safe)
- Proxy middleware honouring X-Forwarded-* only from TRUSTED_PROXIES,
  optional CLIENT_IP_HEADER (e.g. CF-Connecting-IP)
- Security headers (CSP, HSTS, Permissions-Policy), secure __Host- cookies
- Mobile-first German UI, CLAUDE.md, README, init-env script
- 65 tests for auth, permissions, proxy handling and rate limiting

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:24:59 +01:00

138 lines
5.6 KiB
Python

"""User and invitation management for admins (/verwaltung/)."""
import secrets
from functools import wraps
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_POST
from .forms import InvitationForm
from .models import Invitation, PasswordResetToken, User
def admin_required(view):
@wraps(view)
def wrapper(request, *args, **kwargs):
if not request.user.is_admin:
raise PermissionDenied
return view(request, *args, **kwargs)
return wrapper
def _show_once(request, title, value, hint):
"""Render a secret (link or temporary password) that is not stored in plain text."""
return render(request, "accounts/admin/show_once.html", {"title": title, "value": value, "hint": hint})
@admin_required
@never_cache
def user_list(request):
users = User.objects.order_by("-is_active", "display_name")
return render(request, "accounts/admin/users.html", {"users": users})
USER_ACTIONS = {"make_admin", "make_user", "deactivate", "activate", "reset_link", "temp_password"}
@admin_required
@require_POST
@never_cache
def user_action(request, pk, action):
if action not in USER_ACTIONS:
raise PermissionDenied
with transaction.atomic():
target = get_object_or_404(User.objects.select_for_update(), pk=pk)
if action in {"make_user", "deactivate"}:
if target == request.user:
messages.error(request, "Du kannst dir selbst die Admin-Rechte nicht entziehen oder dich deaktivieren.")
return redirect("accounts:admin_users")
other_admins = User.objects.filter(role=User.Role.ADMIN, is_active=True).exclude(pk=target.pk)
if target.is_admin and not other_admins.exists():
messages.error(request, "Es muss mindestens einen aktiven Admin geben.")
return redirect("accounts:admin_users")
if action == "make_admin":
target.role = User.Role.ADMIN
target.save(update_fields=["role"])
messages.success(request, f"{target} ist jetzt Admin.")
elif action == "make_user":
target.role = User.Role.USER
target.save(update_fields=["role"])
messages.success(request, f"{target} ist jetzt Nutzer.")
elif action == "deactivate":
target.is_active = False
target.save(update_fields=["is_active"])
PasswordResetToken.objects.usable().filter(user=target).update(revoked_at=timezone.now())
messages.success(request, f"{target} wurde deaktiviert. Die Rezepte bleiben erhalten.")
elif action == "activate":
target.is_active = True
target.save(update_fields=["is_active"])
messages.success(request, f"{target} wurde wieder aktiviert.")
elif action == "reset_link":
if not target.is_active:
messages.error(request, "Deaktivierte Nutzer können ihr Passwort nicht zurücksetzen.")
return redirect("accounts:admin_users")
_, link = PasswordResetToken.issue(target, created_by=request.user)
return _show_once(
request,
f"Link zum Zurücksetzen für {target}",
link,
"Der Link ist 24 Stunden gültig und nur einmal verwendbar. Ältere Links für diesen Nutzer sind ungültig.",
)
elif action == "temp_password":
if not target.is_active:
messages.error(request, "Deaktivierte Nutzer können sich nicht anmelden.")
return redirect("accounts:admin_users")
password = secrets.token_urlsafe(12)
target.set_password(password)
target.must_change_password = True
target.save(update_fields=["password", "must_change_password"])
return _show_once(
request,
f"Temporäres Passwort für {target}",
password,
"Nach der Anmeldung muss sofort ein eigenes Passwort gesetzt werden. Bestehende Sitzungen wurden beendet.",
)
return redirect("accounts:admin_users")
@admin_required
@never_cache
def invitation_list(request):
form = InvitationForm(request.POST or None)
if request.method == "POST" and form.is_valid():
_, link = Invitation.issue(
created_by=request.user,
email=form.cleaned_data["email"],
role=form.cleaned_data["role"],
note=form.cleaned_data["note"],
valid_days=form.cleaned_data["valid_days"],
)
return _show_once(
request,
"Neue Einladung",
link,
f"Schicke diesen Link an die eingeladene Person. Er ist {form.cleaned_data['valid_days']} Tage gültig "
"und nur einmal verwendbar. Aus Sicherheitsgründen wird er nicht gespeichert und später nicht mehr angezeigt.",
)
invitations = Invitation.objects.select_related("created_by", "used_by")[:100]
return render(request, "accounts/admin/invitations.html", {"form": form, "invitations": invitations})
@admin_required
@require_POST
def invitation_revoke(request, pk):
invitation = get_object_or_404(Invitation, pk=pk)
if invitation.status == "open":
invitation.revoked_at = timezone.now()
invitation.save(update_fields=["revoked_at"])
messages.success(request, "Einladung widerrufen.")
return redirect("accounts:admin_invitations")