From 31b45d4db47e923159835daa1e77d08bc93b0806 Mon Sep 17 00:00:00 2001 From: SimolZimol <70102430+SimolZimol@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:02:55 +0200 Subject: [PATCH] modified: web/blueprints/group_admin.py modified: web/blueprints/site_admin.py modified: web/roles.py modified: web/templates/admin/group_members.html --- web/blueprints/group_admin.py | 19 ++++- web/blueprints/site_admin.py | 110 +++++++++++++++++++++++++ web/roles.py | 3 + web/templates/admin/group_members.html | 94 ++++++++++++++++++++- 4 files changed, 219 insertions(+), 7 deletions(-) diff --git a/web/blueprints/group_admin.py b/web/blueprints/group_admin.py index d2555e1..f290926 100644 --- a/web/blueprints/group_admin.py +++ b/web/blueprints/group_admin.py @@ -9,10 +9,13 @@ from flask import Blueprint, render_template, request, redirect, url_for, sessio from config import Config from mailer import send_mail import panel_db as db -from roles import GROUP_MANAGEMENT_ROLES, GROUP_ROLE_OPTIONS, GROUP_ROLE_SET, role_label +from roles import GROUP_MANAGEMENT_ROLES, GROUP_ROLE_OPTIONS, GROUP_ROLE_SET, OWNER_ONLY_ROLES, role_label group_admin = Blueprint("group_admin", __name__, url_prefix="/group-admin") +# Role options that group admins are allowed to assign (owner excluded) +_NON_OWNER_ROLE_OPTIONS = [(r, l) for r, l in GROUP_ROLE_OPTIONS if r not in OWNER_ONLY_ROLES] + ALL_PERMISSIONS = [ ("view_dashboard", "Dashboard"), ("view_players", "Players"), @@ -74,7 +77,7 @@ def members(): return render_template("group_admin/members.html", group=group, members=members, non_members=non_members, pending_invites=pending_invites, all_permissions=ALL_PERMISSIONS, - role_options=GROUP_ROLE_OPTIONS, + role_options=_NON_OWNER_ROLE_OPTIONS, role_label=role_label) @@ -84,6 +87,9 @@ def member_add(): group_id = session["group_id"] user_id = request.form.get("user_id", type=int) role = request.form.get("role", "viewer") + if role in OWNER_ONLY_ROLES: + flash("The Group Owner role can only be assigned by a Site Admin.", "danger") + return redirect(url_for("group_admin.members")) if role not in GROUP_ROLE_SET: flash("Invalid role selected.", "danger") return redirect(url_for("group_admin.members")) @@ -113,6 +119,10 @@ def member_invite(): flash("Invalid role selected.", "danger") return redirect(url_for("group_admin.members")) + if role in OWNER_ONLY_ROLES: + flash("The Group Owner role can only be assigned by a Site Admin.", "danger") + return redirect(url_for("group_admin.members")) + if db.count_active_group_invites(group_id) >= Config.INVITE_MAX_ACTIVE_PER_GROUP: flash("Active invite limit reached for this group. Revoke old invites or wait for expiry.", "danger") return redirect(url_for("group_admin.members")) @@ -222,6 +232,9 @@ def member_edit(user_id): if request.method == "POST": role = request.form.get("role", "viewer") + if role in OWNER_ONLY_ROLES: + flash("The Group Owner role can only be assigned by a Site Admin.", "danger") + return redirect(url_for("group_admin.members")) if role not in GROUP_ROLE_SET: flash("Invalid role selected.", "danger") return redirect(url_for("group_admin.members")) @@ -233,7 +246,7 @@ def member_edit(user_id): return render_template("group_admin/member_edit.html", group=group, user=user, member=member, current_perms=current_perms, all_permissions=ALL_PERMISSIONS, - role_options=GROUP_ROLE_OPTIONS, + role_options=_NON_OWNER_ROLE_OPTIONS, role_label=role_label) diff --git a/web/blueprints/site_admin.py b/web/blueprints/site_admin.py index b2a5d9e..90ec6f9 100644 --- a/web/blueprints/site_admin.py +++ b/web/blueprints/site_admin.py @@ -3,7 +3,9 @@ MCLogger – Site-Admin-Bereich Verwaltet alle Gruppen und Nutzer global. """ from functools import wraps +from datetime import datetime, timedelta from flask import Blueprint, render_template, request, redirect, url_for, session, flash +from config import Config from mailer import send_mail import panel_db as db from roles import GROUP_MANAGEMENT_ROLES, GROUP_ROLE_OPTIONS, GROUP_ROLE_SET, role_label @@ -187,11 +189,13 @@ def group_delete(group_id): def group_members(group_id): group = db.get_group_by_id(group_id) members = db.get_group_members(group_id) + pending_invites = db.list_active_group_invites(group_id) all_users = db.list_all_users() member_ids = {m["id"] for m in members} non_members = [u for u in all_users if u["id"] not in member_ids] return render_template("admin/group_members.html", group=group, members=members, non_members=non_members, + pending_invites=pending_invites, role_options=GROUP_ROLE_OPTIONS, role_label=role_label, management_roles=GROUP_MANAGEMENT_ROLES) @@ -235,6 +239,112 @@ def group_member_set_role(group_id, user_id): return redirect(url_for("site_admin.group_members", group_id=group_id)) +@site_admin.route("/groups//members/invite", methods=["POST"]) +@admin_required +def group_member_invite(group_id): + group = db.get_group_by_id(group_id) + if not group: + flash("Group not found.", "danger") + return redirect(url_for("site_admin.groups")) + + username = request.form.get("username", "").strip() + email = request.form.get("email", "").strip() + role = request.form.get("role", "viewer") + + if not username or not email: + flash("Username and email are required.", "danger") + return redirect(url_for("site_admin.group_members", group_id=group_id)) + if "@" not in email: + flash("Please provide a valid email address.", "danger") + return redirect(url_for("site_admin.group_members", group_id=group_id)) + if role not in GROUP_ROLE_SET: + flash("Invalid role selected.", "danger") + return redirect(url_for("site_admin.group_members", group_id=group_id)) + if db.count_active_group_invites(group_id) >= Config.INVITE_MAX_ACTIVE_PER_GROUP: + flash("Active invite limit reached for this group.", "danger") + return redirect(url_for("site_admin.group_members", group_id=group_id)) + if db.get_user_by_username(username): + flash("Username already exists.", "danger") + return redirect(url_for("site_admin.group_members", group_id=group_id)) + if db.get_active_invite_by_username(group_id, username): + flash("There is already an active invitation for this username.", "danger") + return redirect(url_for("site_admin.group_members", group_id=group_id)) + if db.get_user_by_email(email): + flash("Email address is already in use.", "danger") + return redirect(url_for("site_admin.group_members", group_id=group_id)) + if db.get_active_invite_by_email(group_id, email): + flash("There is already an active invitation for this email.", "danger") + return redirect(url_for("site_admin.group_members", group_id=group_id)) + + token = db.create_group_invite(group_id, username, email, role, session["user_id"]) + invite = db.get_invite_by_token(token) + invite_url = url_for("auth.accept_invite", token=token, _external=True) + mail_settings = db.get_site_mail_settings() + + if mail_settings: + subject = f"Invitation to join {group['name']}" + text_body = ( + f"Hello {username},\n\n" + f"You have been invited to join the group '{group['name']}' on MCLogger as {role_label(role)}.\n" + f"Open this link to create your account:\n\n{invite_url}\n\n" + f"This invite expires in {Config.INVITE_EXPIRY_HOURS} hours.\n" + ) + try: + send_mail(mail_settings, email, subject, text_body) + if invite: + db.mark_group_invite_sent(invite["id"], group_id) + flash(f"Invitation email sent to '{email}'.", "success") + except Exception: + flash(f"Invitation created, but email delivery failed. Share this link manually: {invite_url}", "warning") + else: + flash(f"Invitation created for '{username}'. Share this link: {invite_url}", "success") + return redirect(url_for("site_admin.group_members", group_id=group_id)) + + +@site_admin.route("/groups//invites//revoke", methods=["POST"]) +@admin_required +def group_invite_revoke(group_id, invite_id): + db.revoke_group_invite(invite_id, group_id) + flash("Invitation revoked.", "success") + return redirect(url_for("site_admin.group_members", group_id=group_id)) + + +@site_admin.route("/groups//invites//resend", methods=["POST"]) +@admin_required +def group_invite_resend(group_id, invite_id): + group = db.get_group_by_id(group_id) + invite = db.get_group_invite_by_id(invite_id, group_id) + if not invite: + flash("Invitation not found.", "danger") + return redirect(url_for("site_admin.group_members", group_id=group_id)) + if invite.get("accepted_at") or invite.get("revoked_at") or invite["expires_at"] <= datetime.utcnow(): + flash("Invitation is no longer active.", "danger") + return redirect(url_for("site_admin.group_members", group_id=group_id)) + last_sent = invite.get("last_sent_at") + if last_sent and (datetime.utcnow() - last_sent) < timedelta(seconds=Config.INVITE_RESEND_COOLDOWN_SECONDS): + flash("Please wait before resending this invite again.", "warning") + return redirect(url_for("site_admin.group_members", group_id=group_id)) + mail_settings = db.get_site_mail_settings() + if not mail_settings: + flash("No SMTP settings configured.", "danger") + return redirect(url_for("site_admin.group_members", group_id=group_id)) + invite_url = url_for("auth.accept_invite", token=invite["token"], _external=True) + subject = f"Invitation to join {group['name']}" + text_body = ( + f"Hello {invite['invited_username']},\n\n" + f"You have been invited to join the group '{group['name']}' on MCLogger as {role_label(invite['role'])}.\n" + f"Open this link to create your account:\n\n{invite_url}\n\n" + f"This invite expires on {invite['expires_at']}.\n" + ) + try: + send_mail(mail_settings, invite["invited_email"], subject, text_body) + db.mark_group_invite_sent(invite_id, group_id) + flash("Invitation email resent.", "success") + except Exception: + flash("Resend failed. Please verify SMTP settings and try again.", "danger") + return redirect(url_for("site_admin.group_members", group_id=group_id)) + + # ────────────────────────────────────────────────────────────── # Nutzer verwalten # ────────────────────────────────────────────────────────────── diff --git a/web/roles.py b/web/roles.py index 6dfb9c8..a3d1f41 100644 --- a/web/roles.py +++ b/web/roles.py @@ -20,6 +20,9 @@ GROUP_ROLE_OPTIONS = [ GROUP_ROLE_SET = {role for role, _ in GROUP_ROLE_OPTIONS} | {"admin", "member"} GROUP_MANAGEMENT_ROLES = {"group_owner", "group_admin", "admin"} +# Roles that only site admins may assign or revoke +OWNER_ONLY_ROLES = {"group_owner"} + def can_manage_group(role: str | None) -> bool: return role in GROUP_MANAGEMENT_ROLES diff --git a/web/templates/admin/group_members.html b/web/templates/admin/group_members.html index 165840a..4e0346e 100644 --- a/web/templates/admin/group_members.html +++ b/web/templates/admin/group_members.html @@ -11,7 +11,7 @@
-
+
Current Members ({{ members|length }})
@@ -55,12 +55,64 @@
+ + +
+
Pending Invitations ({{ pending_invites|length }})
+
+ + + + {% for invite in pending_invites %} + {% set invite_url = url_for('auth.accept_invite', token=invite.token, _external=True) %} + + + + + + + {% else %} + + {% endfor %} + +
UserRoleExpiresActions
+
{{ invite.invited_username }}
+
{{ invite.invited_email }}
+
+ {% if invite.role in management_roles %} + {{ role_label(invite.role) }} + {% else %} + {{ role_label(invite.role) }} + {% endif %} +
Sent: {{ invite.send_count or 0 }}
+
{{ invite.expires_at | fmt_dt }} +
{{ invite_url }}
+ +
+ + +
+
+ + +
+
No pending invitations
+
+
- +
-
-
Add User
+ +
+
Add Existing User
{% if non_members %}
@@ -90,6 +142,40 @@ {% endif %}
+ + +
+
Invite New User
+
+ + +
+ + +
+
+ + +
The user will receive a link and set their own password.
+
+
+ + +
+ As Site Admin you can assign Group Owner. +
+
+ + +
+
{% endblock %} +