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 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)

View File

@@ -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/<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
# ──────────────────────────────────────────────────────────────

View File

@@ -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

View File

@@ -11,7 +11,7 @@
<div class="row g-3">
<!-- Current members -->
<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-body p-0">
<table class="table table-hover mb-0">
@@ -55,12 +55,64 @@
</table>
</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>
<!-- Add user -->
<!-- Right column: add + invite -->
<div class="col-md-5">
<div class="card border-secondary">
<div class="card-header"><i class="bi bi-person-plus-fill me-2"></i>Add User</div>
<!-- Add existing user -->
<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">
{% if non_members %}
<form method="post" action="{{ url_for('site_admin.group_member_add', group_id=group.id) }}">
@@ -90,6 +142,40 @@
{% endif %}
</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>
{% endblock %}