modified: web/blueprints/group_admin.py

modified:   web/blueprints/site_admin.py
	modified:   web/roles.py
	modified:   web/templates/admin/group_members.html
This commit is contained in:
SimolZimol
2026-04-13 18:02:55 +02:00
parent be26484606
commit 31b45d4db4
4 changed files with 219 additions and 7 deletions

View File

@@ -9,10 +9,13 @@ from flask import Blueprint, render_template, request, redirect, url_for, sessio
from config import Config from config import Config
from mailer import send_mail from mailer import send_mail
import panel_db as db 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") 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 = [ ALL_PERMISSIONS = [
("view_dashboard", "Dashboard"), ("view_dashboard", "Dashboard"),
("view_players", "Players"), ("view_players", "Players"),
@@ -74,7 +77,7 @@ def members():
return render_template("group_admin/members.html", return render_template("group_admin/members.html",
group=group, members=members, non_members=non_members, pending_invites=pending_invites, group=group, members=members, non_members=non_members, pending_invites=pending_invites,
all_permissions=ALL_PERMISSIONS, all_permissions=ALL_PERMISSIONS,
role_options=GROUP_ROLE_OPTIONS, role_options=_NON_OWNER_ROLE_OPTIONS,
role_label=role_label) role_label=role_label)
@@ -84,6 +87,9 @@ def member_add():
group_id = session["group_id"] group_id = session["group_id"]
user_id = request.form.get("user_id", type=int) user_id = request.form.get("user_id", type=int)
role = request.form.get("role", "viewer") 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: if role not in GROUP_ROLE_SET:
flash("Invalid role selected.", "danger") flash("Invalid role selected.", "danger")
return redirect(url_for("group_admin.members")) return redirect(url_for("group_admin.members"))
@@ -113,6 +119,10 @@ def member_invite():
flash("Invalid role selected.", "danger") flash("Invalid role selected.", "danger")
return redirect(url_for("group_admin.members")) 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: 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") flash("Active invite limit reached for this group. Revoke old invites or wait for expiry.", "danger")
return redirect(url_for("group_admin.members")) return redirect(url_for("group_admin.members"))
@@ -222,6 +232,9 @@ def member_edit(user_id):
if request.method == "POST": if request.method == "POST":
role = request.form.get("role", "viewer") 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: if role not in GROUP_ROLE_SET:
flash("Invalid role selected.", "danger") flash("Invalid role selected.", "danger")
return redirect(url_for("group_admin.members")) return redirect(url_for("group_admin.members"))
@@ -233,7 +246,7 @@ def member_edit(user_id):
return render_template("group_admin/member_edit.html", return render_template("group_admin/member_edit.html",
group=group, user=user, member=member, group=group, user=user, member=member,
current_perms=current_perms, all_permissions=ALL_PERMISSIONS, current_perms=current_perms, all_permissions=ALL_PERMISSIONS,
role_options=GROUP_ROLE_OPTIONS, role_options=_NON_OWNER_ROLE_OPTIONS,
role_label=role_label) role_label=role_label)

View File

@@ -3,7 +3,9 @@ MCLogger Site-Admin-Bereich
Verwaltet alle Gruppen und Nutzer global. Verwaltet alle Gruppen und Nutzer global.
""" """
from functools import wraps from functools import wraps
from datetime import datetime, timedelta
from flask import Blueprint, render_template, request, redirect, url_for, session, flash from flask import Blueprint, render_template, request, redirect, url_for, session, flash
from config import Config
from mailer import send_mail from mailer import send_mail
import panel_db as db 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, role_label
@@ -187,11 +189,13 @@ def group_delete(group_id):
def group_members(group_id): def group_members(group_id):
group = db.get_group_by_id(group_id) group = db.get_group_by_id(group_id)
members = db.get_group_members(group_id) members = db.get_group_members(group_id)
pending_invites = db.list_active_group_invites(group_id)
all_users = db.list_all_users() all_users = db.list_all_users()
member_ids = {m["id"] for m in members} member_ids = {m["id"] for m in members}
non_members = [u for u in all_users if u["id"] not in member_ids] non_members = [u for u in all_users if u["id"] not in member_ids]
return render_template("admin/group_members.html", return render_template("admin/group_members.html",
group=group, members=members, non_members=non_members, group=group, members=members, non_members=non_members,
pending_invites=pending_invites,
role_options=GROUP_ROLE_OPTIONS, role_options=GROUP_ROLE_OPTIONS,
role_label=role_label, role_label=role_label,
management_roles=GROUP_MANAGEMENT_ROLES) 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)) return redirect(url_for("site_admin.group_members", group_id=group_id))
@site_admin.route("/groups/<int:group_id>/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/<int:group_id>/invites/<int:invite_id>/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/<int:group_id>/invites/<int:invite_id>/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 # Nutzer verwalten
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────

View File

@@ -20,6 +20,9 @@ GROUP_ROLE_OPTIONS = [
GROUP_ROLE_SET = {role for role, _ in GROUP_ROLE_OPTIONS} | {"admin", "member"} GROUP_ROLE_SET = {role for role, _ in GROUP_ROLE_OPTIONS} | {"admin", "member"}
GROUP_MANAGEMENT_ROLES = {"group_owner", "group_admin", "admin"} 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: def can_manage_group(role: str | None) -> bool:
return role in GROUP_MANAGEMENT_ROLES return role in GROUP_MANAGEMENT_ROLES

View File

@@ -11,7 +11,7 @@
<div class="row g-3"> <div class="row g-3">
<!-- Current members --> <!-- Current members -->
<div class="col-md-7"> <div class="col-md-7">
<div class="card border-secondary"> <div class="card border-secondary mb-3">
<div class="card-header"><i class="bi bi-people-fill me-2"></i>Current Members ({{ members|length }})</div> <div class="card-header"><i class="bi bi-people-fill me-2"></i>Current Members ({{ members|length }})</div>
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
@@ -55,12 +55,64 @@
</table> </table>
</div> </div>
</div> </div>
<!-- Pending invitations -->
<div class="card border-secondary">
<div class="card-header"><i class="bi bi-envelope-paper-fill me-2"></i>Pending Invitations ({{ pending_invites|length }})</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead><tr><th>User</th><th>Role</th><th>Expires</th><th class="text-end">Actions</th></tr></thead>
<tbody>
{% for invite in pending_invites %}
{% set invite_url = url_for('auth.accept_invite', token=invite.token, _external=True) %}
<tr>
<td>
<div>{{ invite.invited_username }}</div>
<div class="small text-muted">{{ invite.invited_email }}</div>
</td>
<td>
{% if invite.role in management_roles %}
<span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>{{ role_label(invite.role) }}</span>
{% else %}
<span class="badge bg-secondary">{{ role_label(invite.role) }}</span>
{% endif %}
<div class="small text-muted mt-1">Sent: {{ invite.send_count or 0 }}</div>
</td>
<td class="small text-muted">{{ invite.expires_at | fmt_dt }}</td>
<td class="text-end">
<div class="d-none" id="invite-url-{{ invite.id }}">{{ invite_url }}</div>
<button type="button" class="btn btn-sm btn-outline-primary copy-btn" data-target="#invite-url-{{ invite.id }}" title="Copy invite link">
<i class="bi bi-clipboard"></i>
</button>
<form method="post" action="{{ url_for('site_admin.group_invite_resend', group_id=group.id, invite_id=invite.id) }}" class="d-inline">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-info" title="Resend">
<i class="bi bi-send"></i>
</button>
</form>
<form method="post" action="{{ url_for('site_admin.group_invite_revoke', group_id=group.id, invite_id=invite.id) }}" class="d-inline"
onsubmit="return confirm('Revoke invitation for {{ invite.invited_username }}?')">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Revoke">
<i class="bi bi-x-lg"></i>
</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-muted text-center py-3">No pending invitations</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div> </div>
<!-- Add user --> <!-- Right column: add + invite -->
<div class="col-md-5"> <div class="col-md-5">
<div class="card border-secondary"> <!-- Add existing user -->
<div class="card-header"><i class="bi bi-person-plus-fill me-2"></i>Add User</div> <div class="card border-secondary mb-3">
<div class="card-header"><i class="bi bi-person-plus-fill me-2"></i>Add Existing User</div>
<div class="card-body"> <div class="card-body">
{% if non_members %} {% if non_members %}
<form method="post" action="{{ url_for('site_admin.group_member_add', group_id=group.id) }}"> <form method="post" action="{{ url_for('site_admin.group_member_add', group_id=group.id) }}">
@@ -90,6 +142,40 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- Invite new user (site admin can assign any role including group_owner) -->
<div class="card border-secondary">
<div class="card-header"><i class="bi bi-envelope-plus-fill me-2"></i>Invite New User</div>
<div class="card-body">
<form method="post" action="{{ url_for('site_admin.group_member_invite', group_id=group.id) }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" name="username" class="form-control" maxlength="50" required>
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-control" maxlength="255" required>
<div class="form-text">The user will receive a link and set their own password.</div>
</div>
<div class="mb-3">
<label class="form-label">Role</label>
<select name="role" class="form-select">
{% for role, label in role_options %}
<option value="{{ role }}" {{ 'selected' if role == 'viewer' }}>{{ label }}</option>
{% endfor %}
</select>
<div class="form-text text-warning">
<i class="bi bi-shield-fill me-1"></i>As Site Admin you can assign <strong>Group Owner</strong>.
</div>
</div>
<button type="submit" class="btn btn-success w-100">
<i class="bi bi-envelope-plus-fill me-1"></i>Create Invitation
</button>
</form>
</div>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}