feat: add admin user management

This commit is contained in:
2026-04-13 10:10:07 +02:00
parent 9a87ef9562
commit 3c99c3683e
7 changed files with 265 additions and 27 deletions

View File

@@ -8,6 +8,7 @@ from .cli import register_cli, seed_badges
from .extensions import csrf, db, login_manager from .extensions import csrf, db, login_manager
from .routes import auth, main, scoreboard, settings, tasks from .routes import auth, main, scoreboard, settings, tasks
from .routes.main import load_icon_svg from .routes.main import load_icon_svg
from .services.bootstrap import ensure_schema_and_admins
from .services.dates import MONTH_NAMES, local_now from .services.dates import MONTH_NAMES, local_now
from .services.monthly import archive_months_missing_up_to_previous from .services.monthly import archive_months_missing_up_to_previous
@@ -25,6 +26,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
ensure_schema_and_admins()
seed_badges() seed_badges()
register_cli(app) register_cli(app)

View File

@@ -98,3 +98,16 @@ class SettingsProfileForm(FlaskForm):
return return
if User.query.filter_by(email=value).first(): if User.query.filter_by(email=value).first():
raise ValidationError("Diese E-Mail-Adresse wird bereits verwendet.") raise ValidationError("Diese E-Mail-Adresse wird bereits verwendet.")
class AdminUserForm(FlaskForm):
name = StringField("Name", validators=[DataRequired(), Length(min=2, max=120)])
email = StringField("E-Mail", validators=[DataRequired(), EMAIL_LIKE, Length(max=255)])
password = PasswordField("Passwort", validators=[DataRequired(), Length(min=6, max=128)])
is_admin = BooleanField("Admin-Rechte")
submit = SubmitField("Nutzer anlegen")
def validate_email(self, field) -> None:
value = field.data.lower().strip()
if User.query.filter_by(email=value).first():
raise ValidationError("Diese E-Mail-Adresse wird bereits verwendet.")

View File

@@ -22,6 +22,7 @@ class User(UserMixin, TimestampMixin, db.Model):
name = db.Column(db.String(120), nullable=False) name = db.Column(db.String(120), nullable=False)
email = db.Column(db.String(255), nullable=False, unique=True, index=True) email = db.Column(db.String(255), nullable=False, unique=True, index=True)
password_hash = db.Column(db.String(255), nullable=False) password_hash = db.Column(db.String(255), nullable=False)
is_admin = db.Column(db.Boolean, nullable=False, default=False)
avatar_path = db.Column(db.String(255), nullable=True) avatar_path = db.Column(db.String(255), nullable=True)
notification_task_due_enabled = db.Column(db.Boolean, nullable=False, default=True) notification_task_due_enabled = db.Column(db.Boolean, nullable=False, default=True)
notification_monthly_winner_enabled = db.Column(db.Boolean, nullable=False, default=True) notification_monthly_winner_enabled = db.Column(db.Boolean, nullable=False, default=True)
@@ -164,4 +165,3 @@ class BadgeDefinition(TimestampMixin, db.Model):
threshold = db.Column(db.Integer, nullable=False, default=1) threshold = db.Column(db.Integer, nullable=False, default=1)
bonus_points = db.Column(db.Integer, nullable=False, default=0) bonus_points = db.Column(db.Integer, nullable=False, default=0)
active = db.Column(db.Boolean, nullable=False, default=True) active = db.Column(db.Boolean, nullable=False, default=True)

View File

@@ -8,14 +8,21 @@ from flask_login import current_user, login_required
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from ..extensions import csrf, db from ..extensions import csrf, db
from ..forms import SettingsProfileForm from ..forms import AdminUserForm, SettingsProfileForm
from ..models import BadgeDefinition, PushSubscription from ..models import BadgeDefinition, MonthlyScoreSnapshot, NotificationLog, PushSubscription, TaskInstance, TaskTemplate, User
from ..services.notifications import push_enabled from ..services.notifications import push_enabled
bp = Blueprint("settings", __name__, url_prefix="/settings") bp = Blueprint("settings", __name__, url_prefix="/settings")
def _require_admin():
if not current_user.is_admin:
flash("Dieser Bereich ist nur für Admins verfügbar.", "error")
return False
return True
def _save_avatar(file_storage) -> str: def _save_avatar(file_storage) -> str:
filename = secure_filename(file_storage.filename or "") filename = secure_filename(file_storage.filename or "")
ext = Path(filename).suffix.lower() or ".png" ext = Path(filename).suffix.lower() or ".png"
@@ -30,6 +37,7 @@ def _save_avatar(file_storage) -> str:
@login_required @login_required
def index(): def index():
form = SettingsProfileForm(original_email=current_user.email, obj=current_user) form = SettingsProfileForm(original_email=current_user.email, obj=current_user)
admin_form = AdminUserForm(prefix="admin")
if form.validate_on_submit(): if form.validate_on_submit():
current_user.name = form.name.data.strip() current_user.name = form.name.data.strip()
current_user.email = form.email.data.lower().strip() current_user.email = form.email.data.lower().strip()
@@ -48,7 +56,9 @@ def index():
return render_template( return render_template(
"settings/index.html", "settings/index.html",
form=form, form=form,
admin_form=admin_form,
badges=badges, badges=badges,
users=User.query.order_by(User.is_admin.desc(), User.name.asc()).all(),
push_ready=push_enabled(), push_ready=push_enabled(),
vapid_public_key=current_app.config["VAPID_PUBLIC_KEY"], vapid_public_key=current_app.config["VAPID_PUBLIC_KEY"],
has_subscription=bool(subscriptions), has_subscription=bool(subscriptions),
@@ -58,6 +68,8 @@ def index():
@bp.route("/badges/<int:badge_id>", methods=["POST"]) @bp.route("/badges/<int:badge_id>", methods=["POST"])
@login_required @login_required
def update_badge(badge_id: int): def update_badge(badge_id: int):
if not _require_admin():
return redirect(url_for("settings.index"))
badge = BadgeDefinition.query.get_or_404(badge_id) badge = BadgeDefinition.query.get_or_404(badge_id)
badge.threshold = max(1, request.form.get("threshold", type=int, default=badge.threshold)) badge.threshold = max(1, request.form.get("threshold", type=int, default=badge.threshold))
badge.bonus_points = max(0, request.form.get("bonus_points", type=int, default=badge.bonus_points)) badge.bonus_points = max(0, request.form.get("bonus_points", type=int, default=badge.bonus_points))
@@ -67,6 +79,83 @@ def update_badge(badge_id: int):
return redirect(url_for("settings.index")) return redirect(url_for("settings.index"))
@bp.route("/users", methods=["POST"])
@login_required
def create_user():
if not _require_admin():
return redirect(url_for("settings.index"))
form = AdminUserForm(prefix="admin")
if not form.validate_on_submit():
for field_errors in form.errors.values():
for error in field_errors:
flash(error, "error")
return redirect(url_for("settings.index"))
user = User(
name=form.name.data.strip(),
email=form.email.data.lower().strip(),
is_admin=form.is_admin.data,
)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash(f"Nutzer „{user.name}“ wurde angelegt.", "success")
return redirect(url_for("settings.index"))
@bp.route("/users/<int:user_id>/toggle-admin", methods=["POST"])
@login_required
def toggle_admin(user_id: int):
if not _require_admin():
return redirect(url_for("settings.index"))
user = User.query.get_or_404(user_id)
make_admin = request.form.get("make_admin") == "1"
if user.id == current_user.id and not make_admin:
flash("Du kannst dir die Admin-Rechte nicht selbst entziehen.", "error")
return redirect(url_for("settings.index"))
if not make_admin and User.query.filter_by(is_admin=True).count() <= 1:
flash("Mindestens ein Admin muss erhalten bleiben.", "error")
return redirect(url_for("settings.index"))
user.is_admin = make_admin
db.session.commit()
flash(f"Admin-Status für „{user.name}“ wurde aktualisiert.", "success")
return redirect(url_for("settings.index"))
@bp.route("/users/<int:user_id>/delete", methods=["POST"])
@login_required
def delete_user(user_id: int):
if not _require_admin():
return redirect(url_for("settings.index"))
user = User.query.get_or_404(user_id)
if user.id == current_user.id:
flash("Du kannst deinen aktuell eingeloggten Account nicht löschen.", "error")
return redirect(url_for("settings.index"))
if user.is_admin and User.query.filter_by(is_admin=True).count() <= 1:
flash("Der letzte Admin kann nicht gelöscht werden.", "error")
return redirect(url_for("settings.index"))
TaskTemplate.query.filter_by(default_assigned_user_id=user.id).update({"default_assigned_user_id": None})
TaskInstance.query.filter_by(assigned_user_id=user.id).update({"assigned_user_id": None})
TaskInstance.query.filter_by(completed_by_user_id=user.id).update({"completed_by_user_id": None})
MonthlyScoreSnapshot.query.filter_by(user_id=user.id).delete()
NotificationLog.query.filter_by(user_id=user.id).delete()
PushSubscription.query.filter_by(user_id=user.id).delete()
db.session.delete(user)
db.session.commit()
flash("Nutzer wurde entfernt.", "success")
return redirect(url_for("settings.index"))
@bp.route("/push/subscribe", methods=["POST"]) @bp.route("/push/subscribe", methods=["POST"])
@login_required @login_required
@csrf.exempt @csrf.exempt

33
app/services/bootstrap.py Normal file
View File

@@ -0,0 +1,33 @@
from __future__ import annotations
import os
from sqlalchemy import inspect, text
from ..extensions import db
from ..models import User
def ensure_schema_and_admins() -> None:
inspector = inspect(db.engine)
column_names = {column["name"] for column in inspector.get_columns("user")}
if "is_admin" not in column_names:
db.session.execute(text("ALTER TABLE user ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0"))
db.session.commit()
admin_exists = User.query.filter_by(is_admin=True).first()
if admin_exists:
return
preferred_admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "mail@hnz.io").lower().strip()
preferred_user = User.query.filter(User.email.ilike(preferred_admin_email)).first()
if preferred_user:
preferred_user.is_admin = True
db.session.commit()
return
first_user = User.query.order_by(User.id.asc()).first()
if first_user:
first_user.is_admin = True
db.session.commit()

View File

@@ -710,6 +710,33 @@ p {
gap: 18px; gap: 18px;
} }
.admin-user-list {
display: grid;
gap: 14px;
margin-top: 22px;
}
.admin-user-card {
display: grid;
gap: 14px;
padding: 18px;
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(132, 152, 190, 0.22);
}
.admin-user-card__identity,
.admin-user-card__actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.admin-user-card__actions form {
margin: 0;
}
.push-box__state { .push-box__state {
align-items: flex-start; align-items: flex-start;
padding: 16px; padding: 16px;

View File

@@ -72,29 +72,103 @@
<section class="panel"> <section class="panel">
<p class="eyebrow">Gamification</p> <p class="eyebrow">Gamification</p>
<h2>Badge-Regeln pflegen</h2> <h2>Badge-Regeln pflegen</h2>
<div class="badge-settings"> {% if current_user.is_admin %}
{% for badge in badges %} <div class="badge-settings">
<form method="post" action="{{ url_for('settings.update_badge', badge_id=badge.id) }}" class="badge-setting-card"> {% for badge in badges %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <form method="post" action="{{ url_for('settings.update_badge', badge_id=badge.id) }}" class="badge-setting-card">
<div> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<strong>{{ badge.name }}</strong> <div>
<p class="muted">{{ badge.description }}</p> <strong>{{ badge.name }}</strong>
</div> <p class="muted">{{ badge.description }}</p>
<div class="field field--compact"> </div>
<label>Schwelle</label> <div class="field field--compact">
<input type="number" name="threshold" min="1" value="{{ badge.threshold }}"> <label>Schwelle</label>
</div> <input type="number" name="threshold" min="1" value="{{ badge.threshold }}">
<div class="field field--compact"> </div>
<label>Bonus</label> <div class="field field--compact">
<input type="number" name="bonus_points" min="0" value="{{ badge.bonus_points }}"> <label>Bonus</label>
</div> <input type="number" name="bonus_points" min="0" value="{{ badge.bonus_points }}">
<label class="checkbox checkbox--compact"> </div>
<input type="checkbox" name="active" {% if badge.active %}checked{% endif %}> <label class="checkbox checkbox--compact">
<span>Aktiv</span> <input type="checkbox" name="active" {% if badge.active %}checked{% endif %}>
</label> <span>Aktiv</span>
<button type="submit" class="button button--secondary">Badge speichern</button> </label>
</form> <button type="submit" class="button button--secondary">Badge speichern</button>
{% endfor %} </form>
</div> {% endfor %}
</div>
{% else %}
<p class="muted">Badge-Regeln können nur von einem Admin geändert werden.</p>
{% endif %}
</section> </section>
{% if current_user.is_admin %}
<section class="panel">
<p class="eyebrow">Admin</p>
<h2>Nutzerverwaltung</h2>
<div class="badge-settings">
<form method="post" action="{{ url_for('settings.create_user') }}" class="badge-setting-card">
{{ admin_form.hidden_tag() }}
<div>
<strong>Neuen Nutzer anlegen</strong>
<p class="muted">Hier legst du weitere Personen kontrolliert an, ohne freie Registrierung.</p>
</div>
<div class="field">
{{ admin_form.name.label }}
{{ admin_form.name() }}
</div>
<div class="field">
{{ admin_form.email.label }}
{{ admin_form.email() }}
</div>
<div class="field">
{{ admin_form.password.label }}
{{ admin_form.password() }}
</div>
<label class="checkbox">
{{ admin_form.is_admin() }}
<span>Als Admin anlegen</span>
</label>
{{ admin_form.submit(class_='button button--secondary') }}
</form>
</div>
<div class="admin-user-list">
{% for user in users %}
<article class="admin-user-card">
<div class="admin-user-card__identity">
{{ avatar(user) }}
<div>
<strong>{{ user.name }}</strong>
<p class="muted">{{ user.email }}</p>
</div>
</div>
<div class="chip-row">
{% if user.is_admin %}
<span class="reward-chip">Admin</span>
{% endif %}
{% if user.id == current_user.id %}
<span class="point-pill">Du</span>
{% endif %}
</div>
<div class="admin-user-card__actions">
<form method="post" action="{{ url_for('settings.toggle_admin', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="make_admin" value="{{ 0 if user.is_admin else 1 }}">
<button type="submit" class="button button--ghost">
{% if user.is_admin %}Admin entziehen{% else %}Zum Admin machen{% endif %}
</button>
</form>
<form method="post" action="{{ url_for('settings.delete_user', user_id=user.id) }}" onsubmit="return confirm('Diesen Nutzer wirklich entfernen? Zugewiesene Aufgaben bleiben erhalten, aber ohne Person.')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="button button--secondary" {% if user.id == current_user.id %}disabled{% endif %}>
Nutzer löschen
</button>
</form>
</div>
</article>
{% endfor %}
</div>
</section>
{% endif %}
{% endblock %} {% endblock %}