Compare commits

..

2 Commits

Author SHA1 Message Date
simon
be26484606 modified: .gitignore 2026-04-13 11:45:06 +02:00
simon
d25536e9c4 modified: .gitignore 2026-04-13 11:44:30 +02:00
41 changed files with 250 additions and 69 deletions

2
.gitignore vendored
View File

@@ -61,3 +61,5 @@ logs/
# OS specific # OS specific
Thumbs.db Thumbs.db
.DS_Store .DS_Store
target/

View File

@@ -1,12 +1,12 @@
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\commands\MCLoggerCommand.java C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\commands\MCLoggerCommand.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\database\DatabaseManager.java C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\database\DatabaseManager.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\BlockListener.java C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\BlockListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\EntityListener.java C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\EntityListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\InventoryListener.java C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\InventoryListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\LuckPermsListener.java C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\LuckPermsListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerChatCommandListener.java C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerChatCommandListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerDeathListener.java C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerDeathListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerMiscListener.java C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerMiscListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerSessionListener.java C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerSessionListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\WorldListener.java C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\WorldListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\PaperLoggerPlugin.java C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\PaperLoggerPlugin.java

Binary file not shown.

View File

@@ -1,3 +1,3 @@
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\database\VelocityDatabaseManager.java C:\Users\Menuette\Documents\Programme\MClogger\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\database\VelocityDatabaseManager.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\listeners\VelocityEventListener.java C:\Users\Menuette\Documents\Programme\MClogger\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\listeners\VelocityEventListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\VelocityLoggerPlugin.java C:\Users\Menuette\Documents\Programme\MClogger\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\VelocityLoggerPlugin.java

View File

@@ -8,6 +8,7 @@ from datetime import datetime
from flask import Flask, abort, render_template, request, session, url_for from flask import Flask, abort, render_template, request, session, url_for
from config import Config from config import Config
from panel_db import init_databases, get_user_groups from panel_db import init_databases, get_user_groups
from roles import can_manage_group
from blueprints.auth import auth from blueprints.auth import auth
from blueprints.site_admin import site_admin from blueprints.site_admin import site_admin
@@ -89,7 +90,7 @@ def create_app() -> Flask:
links.append({"label": "Panel Dashboard", "href": url_for("panel.dashboard"), "btn": "btn-success"}) links.append({"label": "Panel Dashboard", "href": url_for("panel.dashboard"), "btn": "btn-success"})
if is_site_admin: if is_site_admin:
links.append({"label": "Site Admin", "href": url_for("site_admin.dashboard"), "btn": "btn-outline-danger"}) links.append({"label": "Site Admin", "href": url_for("site_admin.dashboard"), "btn": "btn-outline-danger"})
if role == "admin" and not is_site_admin: if can_manage_group(role) and not is_site_admin:
links.append({"label": "Group Admin", "href": url_for("group_admin.dashboard"), "btn": "btn-outline-warning"}) links.append({"label": "Group Admin", "href": url_for("group_admin.dashboard"), "btn": "btn-outline-warning"})
return render_template( return render_template(

View File

@@ -126,5 +126,5 @@ def _apply_group(group):
perms = {} perms = {}
session["group_id"] = group["id"] session["group_id"] = group["id"]
session["group_name"] = group["name"] session["group_name"] = group["name"]
session["role"] = group.get("role", "member") session["role"] = group.get("role", "viewer")
session["permissions"] = perms session["permissions"] = perms

View File

@@ -3,11 +3,13 @@ MCLogger Gruppen-Admin-Bereich
Gruppen-Admins können ihre Mitglieder und MC-DB-Verbindung verwalten. Gruppen-Admins können ihre Mitglieder und MC-DB-Verbindung verwalten.
""" """
import json import json
from datetime import datetime, timedelta
from functools import wraps from functools import wraps
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 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
group_admin = Blueprint("group_admin", __name__, url_prefix="/group-admin") group_admin = Blueprint("group_admin", __name__, url_prefix="/group-admin")
@@ -32,7 +34,7 @@ def group_admin_required(f):
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
if session.get("is_site_admin"): if session.get("is_site_admin"):
return redirect(url_for("site_admin.dashboard")) return redirect(url_for("site_admin.dashboard"))
if session.get("role") != "admin": if session.get("role") not in GROUP_MANAGEMENT_ROLES:
flash("You do not have group admin permission.", "danger") flash("You do not have group admin permission.", "danger")
return redirect(url_for("panel.dashboard")) return redirect(url_for("panel.dashboard"))
return f(*args, **kwargs) return f(*args, **kwargs)
@@ -48,7 +50,7 @@ def dashboard():
has_db = db.has_db_configured(group_id) has_db = db.has_db_configured(group_id)
stats = { stats = {
"member_count": len(members), "member_count": len(members),
"admin_count": sum(1 for m in members if m.get("role") == "admin"), "admin_count": sum(1 for m in members if m.get("role") in GROUP_MANAGEMENT_ROLES),
"db_configured": bool(has_db), "db_configured": bool(has_db),
} }
return render_template("group_admin/dashboard.html", return render_template("group_admin/dashboard.html",
@@ -71,7 +73,9 @@ def members():
non_members = [u for u in all_users if u["id"] not in member_ids and not u["is_site_admin"]] non_members = [u for u in all_users if u["id"] not in member_ids and not u["is_site_admin"]]
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_label=role_label)
@group_admin.route("/members/add", methods=["POST"]) @group_admin.route("/members/add", methods=["POST"])
@@ -79,7 +83,10 @@ def members():
def member_add(): 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", "member") role = request.form.get("role", "viewer")
if role not in GROUP_ROLE_SET:
flash("Invalid role selected.", "danger")
return redirect(url_for("group_admin.members"))
if user_id: if user_id:
db.add_group_member(user_id, group_id, role) db.add_group_member(user_id, group_id, role)
flash("Member added.", "success") flash("Member added.", "success")
@@ -92,7 +99,7 @@ def member_invite():
group_id = session["group_id"] group_id = session["group_id"]
username = request.form.get("username", "").strip() username = request.form.get("username", "").strip()
email = request.form.get("email", "").strip() email = request.form.get("email", "").strip()
role = request.form.get("role", "member") role = request.form.get("role", "viewer")
if not username or not email: if not username or not email:
flash("Username and email are required.", "danger") flash("Username and email are required.", "danger")
@@ -102,14 +109,22 @@ def member_invite():
flash("Please provide a valid email address.", "danger") flash("Please provide a valid email address.", "danger")
return redirect(url_for("group_admin.members")) return redirect(url_for("group_admin.members"))
if role not in {"member", "admin"}: 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"))
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"))
if db.get_user_by_username(username): if db.get_user_by_username(username):
flash("Username already exists.", "danger") flash("Username already exists.", "danger")
return redirect(url_for("group_admin.members")) return redirect(url_for("group_admin.members"))
if db.get_active_invite_by_username(group_id, username):
flash("There is already an active invitation for this username in the group.", "danger")
return redirect(url_for("group_admin.members"))
if db.get_user_by_email(email): if db.get_user_by_email(email):
flash("Email address is already in use.", "danger") flash("Email address is already in use.", "danger")
return redirect(url_for("group_admin.members")) return redirect(url_for("group_admin.members"))
@@ -119,6 +134,7 @@ def member_invite():
return redirect(url_for("group_admin.members")) return redirect(url_for("group_admin.members"))
token = db.create_group_invite(group_id, username, email, role, session["user_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) invite_url = url_for("auth.accept_invite", token=token, _external=True)
mail_settings = db.get_site_mail_settings() mail_settings = db.get_site_mail_settings()
@@ -126,12 +142,14 @@ def member_invite():
subject = f"Invitation to join {session.get('group_name', 'your group')}" subject = f"Invitation to join {session.get('group_name', 'your group')}"
text_body = ( text_body = (
f"Hello {username},\n\n" f"Hello {username},\n\n"
f"You have been invited to join the group '{session.get('group_name', 'your group')}' on MCLogger as {role}.\n" f"You have been invited to join the group '{session.get('group_name', 'your group')}' on MCLogger as {role_label(role)}.\n"
f"Open this link to create your account:\n\n{invite_url}\n\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" f"This invite expires in {Config.INVITE_EXPIRY_HOURS} hours.\n"
) )
try: try:
send_mail(mail_settings, email, subject, text_body) 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") flash(f"Invitation email sent to '{email}'.", "success")
except Exception: except Exception:
flash(f"Invitation created, but email delivery failed. Share this link manually: {invite_url}", "warning") flash(f"Invitation created, but email delivery failed. Share this link manually: {invite_url}", "warning")
@@ -140,6 +158,46 @@ def member_invite():
return redirect(url_for("group_admin.members")) return redirect(url_for("group_admin.members"))
@group_admin.route("/invites/<int:invite_id>/resend", methods=["POST"])
@group_admin_required
def resend_invite(invite_id):
group_id = session["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("group_admin.members"))
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("group_admin.members"))
last_sent_at = invite.get("last_sent_at")
if last_sent_at and (datetime.utcnow() - last_sent_at) < timedelta(seconds=Config.INVITE_RESEND_COOLDOWN_SECONDS):
flash("Please wait before resending this invite again.", "warning")
return redirect(url_for("group_admin.members"))
mail_settings = db.get_site_mail_settings()
if not mail_settings:
flash("No SMTP settings configured by Site Admin.", "danger")
return redirect(url_for("group_admin.members"))
invite_url = url_for("auth.accept_invite", token=invite["token"], _external=True)
subject = f"Invitation to join {session.get('group_name', 'your group')}"
text_body = (
f"Hello {invite['invited_username']},\n\n"
f"You have been invited to join the group '{session.get('group_name', 'your group')}' 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("group_admin.members"))
@group_admin.route("/invites/<int:invite_id>/revoke", methods=["POST"]) @group_admin.route("/invites/<int:invite_id>/revoke", methods=["POST"])
@group_admin_required @group_admin_required
def revoke_invite(invite_id): def revoke_invite(invite_id):
@@ -163,7 +221,10 @@ def member_edit(user_id):
current_perms = json.loads(raw_perms) if isinstance(raw_perms, str) else (raw_perms or {}) current_perms = json.loads(raw_perms) if isinstance(raw_perms, str) else (raw_perms or {})
if request.method == "POST": if request.method == "POST":
role = request.form.get("role", "member") role = request.form.get("role", "viewer")
if role not in GROUP_ROLE_SET:
flash("Invalid role selected.", "danger")
return redirect(url_for("group_admin.members"))
new_perms = {key: bool(request.form.get(f"perm_{key}")) for key, _ in ALL_PERMISSIONS} new_perms = {key: bool(request.form.get(f"perm_{key}")) for key, _ in ALL_PERMISSIONS}
db.update_member(user_id, group_id, role, new_perms) db.update_member(user_id, group_id, role, new_perms)
flash("Permissions updated.", "success") flash("Permissions updated.", "success")
@@ -171,7 +232,9 @@ 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_label=role_label)
@group_admin.route("/members/<int:user_id>/remove", methods=["POST"]) @group_admin.route("/members/<int:user_id>/remove", methods=["POST"])

View File

@@ -9,6 +9,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, sessio
import pymysql import pymysql
import pymysql.cursors import pymysql.cursors
import panel_db as pdb import panel_db as pdb
from roles import can_manage_group
panel = Blueprint("panel", __name__) panel = Blueprint("panel", __name__)
@@ -34,7 +35,7 @@ def perm_required(perm):
def decorator(f): def decorator(f):
@wraps(f) @wraps(f)
def wrapped(*args, **kwargs): def wrapped(*args, **kwargs):
if session.get("is_site_admin") or session.get("role") == "admin": if session.get("is_site_admin") or can_manage_group(session.get("role")):
return f(*args, **kwargs) return f(*args, **kwargs)
perms = session.get("permissions", {}) perms = session.get("permissions", {})
if not perms.get(perm, False): if not perms.get(perm, False):
@@ -191,7 +192,7 @@ def player_detail(uuid):
flash("Player not found.", "danger") flash("Player not found.", "danger")
return redirect(url_for("panel.players")) return redirect(url_for("panel.players"))
perms = session.get("permissions", {}) perms = session.get("permissions", {})
is_admin = session.get("is_site_admin") or session.get("role") == "admin" is_admin = session.get("is_site_admin") or can_manage_group(session.get("role"))
return render_template("panel/player_detail.html", return render_template("panel/player_detail.html",
player=player, player=player,
sessions = query("SELECT * FROM player_sessions WHERE player_uuid=%s ORDER BY login_time DESC LIMIT 20", (uuid,)), sessions = query("SELECT * FROM player_sessions WHERE player_uuid=%s ORDER BY login_time DESC LIMIT 20", (uuid,)),

View File

@@ -6,6 +6,7 @@ from functools import wraps
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 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
site_admin = Blueprint("site_admin", __name__, url_prefix="/admin") site_admin = Blueprint("site_admin", __name__, url_prefix="/admin")
@@ -190,14 +191,20 @@ def group_members(group_id):
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,
role_options=GROUP_ROLE_OPTIONS,
role_label=role_label,
management_roles=GROUP_MANAGEMENT_ROLES)
@site_admin.route("/groups/<int:group_id>/members/add", methods=["POST"]) @site_admin.route("/groups/<int:group_id>/members/add", methods=["POST"])
@admin_required @admin_required
def group_member_add(group_id): def group_member_add(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", "member") role = request.form.get("role", "viewer")
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 user_id: if user_id:
db.add_group_member(user_id, group_id, role) db.add_group_member(user_id, group_id, role)
flash("Member added.", "success") flash("Member added.", "success")
@@ -212,13 +219,16 @@ def group_member_remove(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/<int:user_id>/toggle-role", methods=["POST"]) @site_admin.route("/groups/<int:group_id>/members/<int:user_id>/set-role", methods=["POST"])
@admin_required @admin_required
def group_member_toggle_role(group_id, user_id): def group_member_set_role(group_id, user_id):
member = db.get_group_member(user_id, group_id) member = db.get_group_member(user_id, group_id)
if member: if member:
import json as _json import json as _json
new_role = "member" if member["role"] == "admin" else "admin" new_role = request.form.get("role", "viewer")
if new_role not in GROUP_ROLE_SET:
flash("Invalid role selected.", "danger")
return redirect(url_for("site_admin.group_members", group_id=group_id))
perms = member["permissions"] if isinstance(member["permissions"], dict) else (_json.loads(member["permissions"]) if member["permissions"] else {}) perms = member["permissions"] if isinstance(member["permissions"], dict) else (_json.loads(member["permissions"]) if member["permissions"] else {})
db.update_member(user_id, group_id, new_role, perms) db.update_member(user_id, group_id, new_role, perms)
flash(f"Role changed to '{new_role}'.", "success") flash(f"Role changed to '{new_role}'.", "success")
@@ -317,7 +327,7 @@ def view_group(group_id):
"view_proxy","view_server_events","view_perms"]} "view_proxy","view_server_events","view_perms"]}
session["group_id"] = group_id session["group_id"] = group_id
session["group_name"] = group["name"] session["group_name"] = group["name"]
session["role"] = "admin" session["role"] = "group_owner"
session["permissions"] = all_perms session["permissions"] = all_perms
session["admin_viewing"] = True session["admin_viewing"] = True
return redirect(url_for("panel.dashboard")) return redirect(url_for("panel.dashboard"))

View File

@@ -55,6 +55,8 @@ class Config:
# ── Standard-Berechtigungen neuer Gruppenmitglieder ─────── # ── Standard-Berechtigungen neuer Gruppenmitglieder ───────
INVITE_EXPIRY_HOURS = int(os.getenv("INVITE_EXPIRY_HOURS") or "72") INVITE_EXPIRY_HOURS = int(os.getenv("INVITE_EXPIRY_HOURS") or "72")
INVITE_MAX_ACTIVE_PER_GROUP = int(os.getenv("INVITE_MAX_ACTIVE_PER_GROUP") or "200")
INVITE_RESEND_COOLDOWN_SECONDS = int(os.getenv("INVITE_RESEND_COOLDOWN_SECONDS") or "120")
DEFAULT_PERMISSIONS = { DEFAULT_PERMISSIONS = {
"view_dashboard": True, "view_dashboard": True,

View File

@@ -93,7 +93,7 @@ PANEL_SCHEMA = [
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL, user_id INT NOT NULL,
group_id INT NOT NULL, group_id INT NOT NULL,
role ENUM('admin','member') DEFAULT 'member', role ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer',
permissions JSON, permissions JSON,
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_group (user_id, group_id), UNIQUE KEY uq_user_group (user_id, group_id),
@@ -106,11 +106,13 @@ PANEL_SCHEMA = [
group_id INT NOT NULL, group_id INT NOT NULL,
invited_username VARCHAR(50) NOT NULL, invited_username VARCHAR(50) NOT NULL,
invited_email VARCHAR(255) NOT NULL, invited_email VARCHAR(255) NOT NULL,
role ENUM('admin','member') DEFAULT 'member', role ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer',
token VARCHAR(128) UNIQUE NOT NULL, token VARCHAR(128) UNIQUE NOT NULL,
created_by_user_id INT NOT NULL, created_by_user_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL, expires_at DATETIME NOT NULL,
last_sent_at DATETIME NULL,
send_count INT NOT NULL DEFAULT 0,
accepted_at DATETIME NULL, accepted_at DATETIME NULL,
revoked_at DATETIME NULL, revoked_at DATETIME NULL,
UNIQUE KEY uq_group_pending_invite_email (group_id, invited_email, revoked_at, accepted_at), UNIQUE KEY uq_group_pending_invite_email (group_id, invited_email, revoked_at, accepted_at),
@@ -153,6 +155,27 @@ def init_databases():
with panel.cursor() as cur: with panel.cursor() as cur:
for stmt in PANEL_SCHEMA: for stmt in PANEL_SCHEMA:
cur.execute(stmt) cur.execute(stmt)
# Best-effort migrations for existing installs.
try:
cur.execute(
"ALTER TABLE group_members MODIFY role ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer'"
)
except Exception:
pass
try:
cur.execute(
"ALTER TABLE group_invites MODIFY role ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer'"
)
except Exception:
pass
try:
cur.execute("ALTER TABLE group_invites ADD COLUMN last_sent_at DATETIME NULL")
except Exception:
pass
try:
cur.execute("ALTER TABLE group_invites ADD COLUMN send_count INT NOT NULL DEFAULT 0")
except Exception:
pass
finally: finally:
panel.close() panel.close()
@@ -178,7 +201,7 @@ def create_user(username: str, email: str, password: str, is_site_admin: bool =
) )
def create_user_for_group(username: str, email: str, password: str, group_id: int, role: str = "member") -> int: def create_user_for_group(username: str, email: str, password: str, group_id: int, role: str = "viewer") -> int:
"""Create a non-site-admin user and assign them to a group atomically.""" """Create a non-site-admin user and assign them to a group atomically."""
permissions = Config.DEFAULT_PERMISSIONS permissions = Config.DEFAULT_PERMISSIONS
salt = generate_salt() salt = generate_salt()
@@ -210,8 +233,8 @@ def create_group_invite(group_id: int, username: str, email: str, role: str, cre
expires_at = datetime.utcnow() + timedelta(hours=Config.INVITE_EXPIRY_HOURS) expires_at = datetime.utcnow() + timedelta(hours=Config.INVITE_EXPIRY_HOURS)
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
_panel_query( _panel_query(
"INSERT INTO group_invites (group_id, invited_username, invited_email, role, token, created_by_user_id, expires_at) " "INSERT INTO group_invites (group_id, invited_username, invited_email, role, token, created_by_user_id, expires_at, last_sent_at, send_count) "
"VALUES (%s,%s,%s,%s,%s,%s,%s)", "VALUES (%s,%s,%s,%s,%s,%s,%s,NULL,0)",
(group_id, username, email, role, token, created_by_user_id, expires_at), (group_id, username, email, role, token, created_by_user_id, expires_at),
write=True, write=True,
) )
@@ -229,6 +252,15 @@ def list_active_group_invites(group_id: int):
) )
def count_active_group_invites(group_id: int) -> int:
row = _panel_query(
"SELECT COUNT(*) AS c FROM group_invites WHERE group_id=%s AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP()",
(group_id,),
fetchone=True,
)
return int(row["c"]) if row else 0
def get_active_invite_by_email(group_id: int, email: str): def get_active_invite_by_email(group_id: int, email: str):
return _panel_query( return _panel_query(
"SELECT * FROM group_invites WHERE group_id=%s AND invited_email=%s " "SELECT * FROM group_invites WHERE group_id=%s AND invited_email=%s "
@@ -238,6 +270,23 @@ def get_active_invite_by_email(group_id: int, email: str):
) )
def get_active_invite_by_username(group_id: int, username: str):
return _panel_query(
"SELECT * FROM group_invites WHERE group_id=%s AND invited_username=%s "
"AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP()",
(group_id, username),
fetchone=True,
)
def get_group_invite_by_id(invite_id: int, group_id: int):
return _panel_query(
"SELECT * FROM group_invites WHERE id=%s AND group_id=%s",
(invite_id, group_id),
fetchone=True,
)
def get_invite_by_token(token: str): def get_invite_by_token(token: str):
return _panel_query( return _panel_query(
"SELECT gi.*, g.name AS group_name, u.username AS created_by_username " "SELECT gi.*, g.name AS group_name, u.username AS created_by_username "
@@ -258,6 +307,14 @@ def revoke_group_invite(invite_id: int, group_id: int):
) )
def mark_group_invite_sent(invite_id: int, group_id: int):
_panel_query(
"UPDATE group_invites SET last_sent_at=UTC_TIMESTAMP(), send_count=send_count+1 WHERE id=%s AND group_id=%s",
(invite_id, group_id),
write=True,
)
def accept_group_invite(token: str, password: str) -> dict | None: def accept_group_invite(token: str, password: str) -> dict | None:
invite = get_invite_by_token(token) invite = get_invite_by_token(token)
if not invite: if not invite:
@@ -422,7 +479,7 @@ def get_group_members(group_id: int):
) )
def add_group_member(user_id: int, group_id: int, role: str = "member", permissions: dict = None): def add_group_member(user_id: int, group_id: int, role: str = "viewer", permissions: dict = None):
if permissions is None: if permissions is None:
permissions = Config.DEFAULT_PERMISSIONS permissions = Config.DEFAULT_PERMISSIONS
_panel_query( _panel_query(

29
web/roles.py Normal file
View File

@@ -0,0 +1,29 @@
# Legacy values (admin/member) are kept for backward compatibility.
GROUP_ROLE_LABELS = {
"group_owner": "Group Owner",
"group_admin": "Group Admin",
"moderator": "Moderator",
"viewer": "Viewer",
"auditor": "Auditor",
"admin": "Admin",
"member": "Member",
}
GROUP_ROLE_OPTIONS = [
("group_owner", GROUP_ROLE_LABELS["group_owner"]),
("group_admin", GROUP_ROLE_LABELS["group_admin"]),
("moderator", GROUP_ROLE_LABELS["moderator"]),
("viewer", GROUP_ROLE_LABELS["viewer"]),
("auditor", GROUP_ROLE_LABELS["auditor"]),
]
GROUP_ROLE_SET = {role for role, _ in GROUP_ROLE_OPTIONS} | {"admin", "member"}
GROUP_MANAGEMENT_ROLES = {"group_owner", "group_admin", "admin"}
def can_manage_group(role: str | None) -> bool:
return role in GROUP_MANAGEMENT_ROLES
def role_label(role: str | None) -> str:
return GROUP_ROLE_LABELS.get(role or "", "Unknown")

View File

@@ -57,7 +57,7 @@
<p class="mb-0 text-secondary">You are currently not signed in. Start from the login page.</p> <p class="mb-0 text-secondary">You are currently not signed in. Start from the login page.</p>
{% elif is_site_admin and not session.get('group_id') %} {% elif is_site_admin and not session.get('group_id') %}
<p class="mb-0 text-secondary">You are signed in as Site Admin. You can manage groups and users from there.</p> <p class="mb-0 text-secondary">You are signed in as Site Admin. You can manage groups and users from there.</p>
{% elif role == 'admin' %} {% elif role in ['group_owner', 'group_admin', 'admin'] %}
<p class="mb-0 text-secondary">You are a group admin. Use Panel or Group Admin to return to valid sections.</p> <p class="mb-0 text-secondary">You are a group admin. Use Panel or Group Admin to return to valid sections.</p>
{% else %} {% else %}
<p class="mb-0 text-secondary">Use the dashboard to navigate back to known sections.</p> <p class="mb-0 text-secondary">Use the dashboard to navigate back to known sections.</p>

View File

@@ -9,7 +9,7 @@
</div> </div>
<div class="row g-3"> <div class="row g-3">
<!-- Aktuelle Mitglieder --> <!-- Current members -->
<div class="col-md-7"> <div class="col-md-7">
<div class="card border-secondary"> <div class="card border-secondary">
<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>
@@ -21,17 +21,22 @@
<tr> <tr>
<td>{{ m.username }}</td> <td>{{ m.username }}</td>
<td> <td>
{% if m.role == 'admin' %} {% if m.role in management_roles %}
<span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>Admin</span> <span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>{{ role_label(m.role) }}</span>
{% else %} {% else %}
<span class="badge bg-secondary">Member</span> <span class="badge bg-secondary">{{ role_label(m.role) }}</span>
{% endif %} {% endif %}
</td> </td>
<td class="text-end"> <td class="text-end">
<form method="post" action="{{ url_for('site_admin.group_member_toggle_role', group_id=group.id, user_id=m.id) }}" class="d-inline"> <form method="post" action="{{ url_for('site_admin.group_member_set_role', group_id=group.id, user_id=m.id) }}" class="d-inline-flex align-items-center gap-1">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-warning" title="Toggle role"> <select name="role" class="form-select form-select-sm" style="width: 150px;">
<i class="bi bi-arrow-left-right"></i> {% for role, label in role_options %}
<option value="{{ role }}" {{ 'selected' if m.role == role }}>{{ label }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-sm btn-outline-warning" title="Set role">
<i class="bi bi-check2"></i>
</button> </button>
</form> </form>
<form method="post" action="{{ url_for('site_admin.group_member_remove', group_id=group.id, user_id=m.id) }}" class="d-inline" <form method="post" action="{{ url_for('site_admin.group_member_remove', group_id=group.id, user_id=m.id) }}" class="d-inline"
@@ -52,7 +57,7 @@
</div> </div>
</div> </div>
<!-- Benutzer hinzufügen --> <!-- Add user -->
<div class="col-md-5"> <div class="col-md-5">
<div class="card border-secondary"> <div class="card border-secondary">
<div class="card-header"><i class="bi bi-person-plus-fill me-2"></i>Add User</div> <div class="card-header"><i class="bi bi-person-plus-fill me-2"></i>Add User</div>
@@ -71,8 +76,9 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Role</label> <label class="form-label">Role</label>
<select name="role" class="form-select"> <select name="role" class="form-select">
<option value="member">Member</option> {% for role, label in role_options %}
<option value="admin">Admin</option> <option value="{{ role }}" {{ 'selected' if role == 'viewer' }}>{{ label }}</option>
{% endfor %}
</select> </select>
</div> </div>
<button type="submit" class="btn btn-success w-100"> <button type="submit" class="btn btn-success w-100">

View File

@@ -21,7 +21,7 @@
<td> <td>
{% for g in u.groups %} {% for g in u.groups %}
<span class="badge bg-secondary me-1">{{ g.name }} <span class="badge bg-secondary me-1">{{ g.name }}
{% if g.role == 'admin' %}<i class="bi bi-star-fill ms-1 text-warning"></i>{% endif %} {% if g.role in ['group_owner', 'group_admin', 'admin'] %}<i class="bi bi-star-fill ms-1 text-warning"></i>{% endif %}
</span> </span>
{% else %}<span class="text-muted small">None</span>{% endfor %} {% else %}<span class="text-muted small">None</span>{% endfor %}
</td> </td>

View File

@@ -21,7 +21,7 @@
</div> </div>
{% set perms = session.get('permissions', {}) %} {% set perms = session.get('permissions', {}) %}
{% set is_admin = session.get('is_site_admin') or session.get('role') == 'admin' %} {% set is_admin = session.get('is_site_admin') or session.get('role') in ['group_owner', 'group_admin', 'admin'] %}
<ul class="nav flex-column gap-1"> <ul class="nav flex-column gap-1">
{% if perms.get('view_dashboard', True) or is_admin %} {% if perms.get('view_dashboard', True) or is_admin %}
@@ -115,7 +115,7 @@
{% endif %} {% endif %}
<!-- Admin-Links --> <!-- Admin-Links -->
{% if session.get('role') == 'admin' and not session.get('is_site_admin') %} {% if session.get('role') in ['group_owner', 'group_admin', 'admin'] and not session.get('is_site_admin') %}
<a href="{{ url_for('group_admin.dashboard') }}" class="btn btn-outline-warning btn-sm mb-1"> <a href="{{ url_for('group_admin.dashboard') }}" class="btn btn-outline-warning btn-sm mb-1">
<i class="bi bi-gear-fill"></i> <span>Manage Group</span> <i class="bi bi-gear-fill"></i> <span>Manage Group</span>
</a> </a>

View File

@@ -20,10 +20,11 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Role</label> <label class="form-label">Role</label>
<select name="role" class="form-select"> <select name="role" class="form-select">
<option value="member" {{ 'selected' if member.role == 'member' }}>Member</option> {% for role, label in role_options %}
<option value="admin" {{ 'selected' if member.role == 'admin' }}>Admin</option> <option value="{{ role }}" {{ 'selected' if member.role == role }}>{{ label }}</option>
{% endfor %}
</select> </select>
<div class="form-text">Admins can manage members and the DB connection.</div> <div class="form-text">Group Owner and Group Admin can manage members and database settings.</div>
</div> </div>
<hr> <hr>

View File

@@ -16,10 +16,10 @@
<tr> <tr>
<td>{{ m.username }}</td> <td>{{ m.username }}</td>
<td> <td>
{% if m.role == 'admin' %} {% if m.role in ['group_owner', 'group_admin', 'admin'] %}
<span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>Admin</span> <span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>{{ role_label(m.role) }}</span>
{% else %} {% else %}
<span class="badge bg-secondary">Member</span> <span class="badge bg-secondary">{{ role_label(m.role) }}</span>
{% endif %} {% endif %}
</td> </td>
<td class="text-end"> <td class="text-end">
@@ -61,17 +61,24 @@
<div class="small text-muted" id="invite-link-{{ invite.id }}">{{ invite.invited_email }}</div> <div class="small text-muted" id="invite-link-{{ invite.id }}">{{ invite.invited_email }}</div>
</td> </td>
<td> <td>
{% if invite.role == 'admin' %} {% if invite.role in ['group_owner', 'group_admin', 'admin'] %}
<span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>Admin</span> <span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>{{ role_label(invite.role) }}</span>
{% else %} {% else %}
<span class="badge bg-secondary">Member</span> <span class="badge bg-secondary">{{ role_label(invite.role) }}</span>
{% endif %} {% endif %}
<div class="small text-muted mt-1">Sent: {{ invite.send_count or 0 }}</div>
</td> </td>
<td class="small text-muted">{{ invite.expires_at | fmt_dt }}</td> <td class="small text-muted">{{ invite.expires_at | fmt_dt }}</td>
<td class="text-end"> <td class="text-end">
<button type="button" class="btn btn-sm btn-outline-primary copy-btn" data-target="#invite-url-{{ invite.id }}" title="Copy invite link"> <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> <i class="bi bi-clipboard"></i>
</button> </button>
<form method="post" action="{{ url_for('group_admin.resend_invite', 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('group_admin.revoke_invite', invite_id=invite.id) }}" class="d-inline" <form method="post" action="{{ url_for('group_admin.revoke_invite', invite_id=invite.id) }}" class="d-inline"
onsubmit="return confirm('Revoke invitation for {{ invite.invited_username }}?')"> onsubmit="return confirm('Revoke invitation for {{ invite.invited_username }}?')">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
@@ -110,8 +117,9 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Role</label> <label class="form-label">Role</label>
<select name="role" class="form-select"> <select name="role" class="form-select">
<option value="member">Member</option> {% for role, label in role_options %}
<option value="admin">Admin</option> <option value="{{ role }}" {{ 'selected' if role == 'viewer' }}>{{ label }}</option>
{% endfor %}
</select> </select>
</div> </div>
<button type="submit" class="btn btn-outline-success w-100"> <button type="submit" class="btn btn-outline-success w-100">
@@ -141,8 +149,9 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Role</label> <label class="form-label">Role</label>
<select name="role" class="form-select"> <select name="role" class="form-select">
<option value="member">Member</option> {% for role, label in role_options %}
<option value="admin">Admin</option> <option value="{{ role }}" {{ 'selected' if role == 'viewer' }}>{{ label }}</option>
{% endfor %}
</select> </select>
</div> </div>
<button type="submit" class="btn btn-success w-100"> <button type="submit" class="btn btn-success w-100">

View File

@@ -8,13 +8,13 @@
<h3 class="mb-3">No database configured</h3> <h3 class="mb-3">No database configured</h3>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
No MC database has been set up for this group. No MC database has been set up for this group.
{% if session.get('role') == 'admin' %} {% if session.get('role') in ['group_owner', 'group_admin', 'admin'] %}
You can configure the connection as group admin. You can configure the connection as group admin.
{% else %} {% else %}
Please contact your group admin. Please contact your group admin.
{% endif %} {% endif %}
</p> </p>
{% if session.get('role') == 'admin' %} {% if session.get('role') in ['group_owner', 'group_admin', 'admin'] %}
<a href="{{ url_for('group_admin.database') }}" class="btn btn-success btn-lg"> <a href="{{ url_for('group_admin.database') }}" class="btn btn-success btn-lg">
<i class="bi bi-database-fill-gear me-2"></i>Configure Database <i class="bi bi-database-fill-gear me-2"></i>Configure Database
</a> </a>