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:
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user