Add profile management features

- Introduced profile update and password change functionalities in main.py.
- Added new ProfileUpdate and PasswordChange schemas in user.py.
- Updated base.html to include a profile link in the navigation and modified user chip for better accessibility.
- Enhanced CSS for user chip links to improve UI interaction.
This commit is contained in:
smueller
2026-07-07 15:26:29 +02:00
parent 9c75357150
commit 6b0402103a
5 changed files with 206 additions and 4 deletions

View File

@@ -16,7 +16,7 @@ from app.core.security import create_access_token, hash_password, verify_passwor
from app.db.session import Base, engine, get_db from app.db.session import Base, engine, get_db
from app.models.system import SendLog from app.models.system import SendLog
from app.models.user import User from app.models.user import User
from app.schemas.user import UserCreate from app.schemas.user import PasswordChange, ProfileUpdate, UserCreate
from app.services.auth import get_admin_user, get_current_user, get_optional_user, require_editor_or_admin from app.services.auth import get_admin_user, get_current_user, get_optional_user, require_editor_or_admin
from app.services.config_store import get_config, set_config from app.services.config_store import get_config, set_config
from app.services.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, generate_csrf_token, validate_csrf from app.services.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, generate_csrf_token, validate_csrf
@@ -123,6 +123,8 @@ def _base_context(request: Request, user: User, db: Session) -> dict:
active_nav = "dashboard" active_nav = "dashboard"
if path.startswith("/admin/users"): if path.startswith("/admin/users"):
active_nav = "users" active_nav = "users"
elif path.startswith("/profil"):
active_nav = "profile"
return { return {
"user": user, "user": user,
"csrf_token": ensure_csrf_cookie(request), "csrf_token": ensure_csrf_cookie(request),
@@ -533,3 +535,110 @@ def create_user(
db.add(user) db.add(user)
db.commit() db.commit()
return RedirectResponse(url="/admin/users?created=1", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/admin/users?created=1", status_code=status.HTTP_302_FOUND)
def _profile_context(request: Request, user: User, db: Session, **extra) -> dict:
context = _base_context(request, user, db)
context.setdefault("error_message", None)
context.setdefault("pw_error_message", None)
context.setdefault("success_message", None)
context.update(extra)
return context
@app.get("/profil", response_class=HTMLResponse)
def profile_page(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
success = None
if request.query_params.get("updated") == "1":
success = "Profil wurde aktualisiert."
elif request.query_params.get("pw") == "1":
success = "Passwort wurde geändert."
return _render(request, "profile.html", _profile_context(request, current_user, db, success_message=success))
@app.post("/profil", response_class=HTMLResponse)
def update_profile(
request: Request,
csrf_token: str = Form(...),
email: str = Form(...),
full_name: str = Form(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
try:
validate_csrf(request, csrf_token)
except HTTPException:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, error_message="Sitzung abgelaufen. Bitte Seite neu laden und erneut versuchen."),
)
try:
payload = ProfileUpdate(email=email.strip(), full_name=full_name.strip())
except ValidationError as exc:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, error_message=_format_validation_error(exc)),
)
clash = db.query(User).filter(User.email == payload.email, User.id != current_user.id).first()
if clash:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, error_message="Diese E-Mail-Adresse wird bereits verwendet."),
)
current_user.email = payload.email
current_user.full_name = payload.full_name
db.commit()
return RedirectResponse(url="/profil?updated=1", status_code=status.HTTP_302_FOUND)
@app.post("/profil/passwort", response_class=HTMLResponse)
def change_password(
request: Request,
csrf_token: str = Form(...),
current_password: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
try:
validate_csrf(request, csrf_token)
except HTTPException:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, pw_error_message="Sitzung abgelaufen. Bitte Seite neu laden und erneut versuchen."),
)
if not verify_password(current_password, current_user.password_hash):
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, pw_error_message="Das aktuelle Passwort ist nicht korrekt."),
)
if new_password != confirm_password:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, pw_error_message="Die neuen Passwörter stimmen nicht überein."),
)
try:
payload = PasswordChange(new_password=new_password)
except ValidationError as exc:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, pw_error_message=_format_validation_error(exc)),
)
current_user.password_hash = hash_password(payload.new_password)
db.commit()
return RedirectResponse(url="/profil?pw=1", status_code=status.HTTP_302_FOUND)

View File

@@ -8,6 +8,15 @@ class UserCreate(BaseModel):
role: str = Field(default="reader", pattern="^(admin|editor|reader)$") role: str = Field(default="reader", pattern="^(admin|editor|reader)$")
class ProfileUpdate(BaseModel):
email: EmailStr
full_name: str = Field(min_length=2, max_length=255)
class PasswordChange(BaseModel):
new_password: str = Field(min_length=10, max_length=255)
class UserOut(BaseModel): class UserOut(BaseModel):
id: int id: int
email: EmailStr email: EmailStr

View File

@@ -115,6 +115,15 @@ a:hover { color: var(--tk-orange-dark); text-decoration: underline; }
color: #CCCCCC; color: #CCCCCC;
} }
a.user-chip-link {
text-decoration: none;
cursor: pointer;
}
a.user-chip-link:hover {
color: #FFFFFF;
}
.topbar .btn-logout { .topbar .btn-logout {
background: transparent; background: transparent;
border: 1px solid rgba(255, 255, 255, 0.35); border: 1px solid rgba(255, 255, 255, 0.35);

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ title if title else "Wiki Newsletter Admin" }}{% endblock %} · Thomas-Krenn.AG</title> <title>{% block title %}{{ title if title else "Wiki Newsletter Admin" }}{% endblock %} · Thomas-Krenn.AG</title>
<link rel="stylesheet" href="/static/css/main.css?v=4"> <link rel="stylesheet" href="/static/css/main.css?v=5">
</head> </head>
<body class="{% block body_class %}{% endblock %}"> <body class="{% block body_class %}{% endblock %}">
<header class="topbar"> <header class="topbar">
@@ -22,11 +22,12 @@
{% if user.role == "admin" %} {% if user.role == "admin" %}
<a href="/admin/users" class="{% if active_nav == 'users' %}active{% endif %}">Benutzer</a> <a href="/admin/users" class="{% if active_nav == 'users' %}active{% endif %}">Benutzer</a>
{% endif %} {% endif %}
<a href="/profil" class="{% if active_nav == 'profile' %}active{% endif %}">Profil</a>
</nav> </nav>
<div class="user-chip"> <a href="/profil" class="user-chip user-chip-link">
<span>{{ user.full_name }}</span> <span>{{ user.full_name }}</span>
<span class="badge badge-{{ user.role }}">{{ user.role }}</span> <span class="badge badge-{{ user.role }}">{{ user.role }}</span>
</div> </a>
<form method="post" action="/logout"> <form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-logout">Abmelden</button> <button type="submit" class="btn-logout">Abmelden</button>

View File

@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}Mein Profil{% endblock %}
{% block content %}
<div class="page-header">
<h2>Mein Profil</h2>
<p class="page-lead"><a href="/dashboard">← Zurück zum Dashboard</a></p>
</div>
{% if success_message %}
<p class="hint" style="color:var(--tk-success);font-weight:600;">{{ success_message }}</p>
{% endif %}
<div class="layout-2col">
<div class="main-col">
<section class="card card-accent-orange">
<div class="card-header">
<h3>Profil bearbeiten</h3>
</div>
{% if error_message %}
<p class="error">{{ error_message }}</p>
{% endif %}
<form method="post" action="/profil" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-row">
<label>Vollständiger Name
<input type="text" name="full_name" required minlength="2" value="{{ user.full_name }}">
</label>
<label>E-Mail
<input type="email" name="email" required value="{{ user.email }}">
</label>
</div>
<button type="submit" class="btn-primary">Profil speichern</button>
</form>
</section>
<section class="card">
<div class="card-header">
<h3>Passwort ändern</h3>
</div>
{% if pw_error_message %}
<p class="error">{{ pw_error_message }}</p>
{% endif %}
<form method="post" action="/profil/passwort" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Aktuelles Passwort
<input type="password" name="current_password" required autocomplete="current-password">
</label>
<div class="form-row">
<label>Neues Passwort
<input type="password" name="new_password" required minlength="10" autocomplete="new-password" placeholder="Mindestens 10 Zeichen">
</label>
<label>Neues Passwort bestätigen
<input type="password" name="confirm_password" required minlength="10" autocomplete="new-password">
</label>
</div>
<button type="submit" class="btn-primary">Passwort ändern</button>
</form>
</section>
</div>
<aside class="sidebar-col">
<div class="sidebar-card">
<h4>Konto</h4>
<ul>
<li><strong>Rolle:</strong> {{ user.role }}</li>
<li><strong>Status:</strong> {{ "Aktiv" if user.is_active else "Inaktiv" }}</li>
</ul>
<p class="hint">Die Rolle kann nur ein Administrator ändern.</p>
</div>
</aside>
</div>
{% endblock %}