diff --git a/web/blueprints/auth.py b/web/blueprints/auth.py index 8f0d387..432c35d 100644 --- a/web/blueprints/auth.py +++ b/web/blueprints/auth.py @@ -3,8 +3,9 @@ MCLogger – Authentifizierung Getrennte Login-Seiten für Site-Admins und normale Nutzer/Gruppen-Admins. """ import json +from datetime import datetime from flask import Blueprint, render_template, request, redirect, url_for, session, flash -from panel_db import check_login, get_user_groups +from panel_db import accept_group_invite, check_login, get_invite_by_token, get_user_groups auth = Blueprint("auth", __name__) @@ -72,6 +73,42 @@ def switch_group(group_id): return redirect(url_for("panel.dashboard")) +@auth.route("/invite/", methods=["GET", "POST"]) +def accept_invite(token): + if session.get("user_id"): + return redirect(url_for("panel.dashboard")) + + invite = get_invite_by_token(token) + if not invite: + flash("Invitation not found.", "danger") + return redirect(url_for("auth.login")) + + is_expired = invite["expires_at"] <= datetime.utcnow() + is_invalid = bool(invite.get("accepted_at") or invite.get("revoked_at") or is_expired) + error = None + + if request.method == "POST" and not is_invalid: + password = request.form.get("password", "") + confirm_password = request.form.get("confirm_password", "") + + if len(password) < 8: + error = "Password must be at least 8 characters long." + elif password != confirm_password: + error = "Passwords do not match." + else: + result = accept_group_invite(token, password) + if result is None: + flash("Invitation is no longer valid.", "danger") + return redirect(url_for("auth.login")) + if result.get("error") == "username_or_email_taken": + error = "The invited username or email is already in use. Please contact your administrator." + else: + flash("Your account has been created. You can now sign in.", "success") + return redirect(url_for("auth.login")) + + return render_template("auth/accept_invite.html", invite=invite, is_invalid=is_invalid, is_expired=is_expired, error=error) + + def _set_user_session(user, groups): session["user_id"] = user["id"] session["username"] = user["username"] diff --git a/web/blueprints/group_admin.py b/web/blueprints/group_admin.py index 2f76bfe..4997278 100644 --- a/web/blueprints/group_admin.py +++ b/web/blueprints/group_admin.py @@ -63,11 +63,12 @@ def members(): group_id = session["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 and not u["is_site_admin"]] return render_template("group_admin/members.html", - group=group, members=members, non_members=non_members, + group=group, members=members, non_members=non_members, pending_invites=pending_invites, all_permissions=ALL_PERMISSIONS) @@ -83,6 +84,52 @@ def member_add(): return redirect(url_for("group_admin.members")) +@group_admin.route("/members/invite", methods=["POST"]) +@group_admin_required +def member_invite(): + group_id = session["group_id"] + username = request.form.get("username", "").strip() + email = request.form.get("email", "").strip() + role = request.form.get("role", "member") + + if not username or not email: + flash("Username and email are required.", "danger") + return redirect(url_for("group_admin.members")) + + if "@" not in email: + flash("Please provide a valid email address.", "danger") + return redirect(url_for("group_admin.members")) + + if role not in {"member", "admin"}: + flash("Invalid role selected.", "danger") + return redirect(url_for("group_admin.members")) + + if db.get_user_by_username(username): + flash("Username already exists.", "danger") + return redirect(url_for("group_admin.members")) + + if db.get_user_by_email(email): + flash("Email address is already in use.", "danger") + return redirect(url_for("group_admin.members")) + + if db.get_active_invite_by_email(group_id, email): + flash("There is already an active invitation for this email in the group.", "danger") + return redirect(url_for("group_admin.members")) + + token = db.create_group_invite(group_id, username, email, role, session["user_id"]) + invite_url = url_for("auth.accept_invite", token=token, _external=True) + flash(f"Invitation created for '{username}'. Share this link: {invite_url}", "success") + return redirect(url_for("group_admin.members")) + + +@group_admin.route("/invites//revoke", methods=["POST"]) +@group_admin_required +def revoke_invite(invite_id): + db.revoke_group_invite(invite_id, session["group_id"]) + flash("Invitation revoked.", "success") + return redirect(url_for("group_admin.members")) + + @group_admin.route("/members//edit", methods=["GET", "POST"]) @group_admin_required def member_edit(user_id): diff --git a/web/config.py b/web/config.py index 57c5851..31aed61 100644 --- a/web/config.py +++ b/web/config.py @@ -49,6 +49,8 @@ class Config: FERNET_KEY = os.getenv("FERNET_KEY", "") # ── Standard-Berechtigungen neuer Gruppenmitglieder ─────── + INVITE_EXPIRY_HOURS = int(os.getenv("INVITE_EXPIRY_HOURS") or "72") + DEFAULT_PERMISSIONS = { "view_dashboard": True, "view_players": True, diff --git a/web/panel_db.py b/web/panel_db.py index 0a5f1be..6e47f7b 100644 --- a/web/panel_db.py +++ b/web/panel_db.py @@ -4,6 +4,8 @@ Verwaltet Nutzer, Gruppen, Mitgliedschaften (PANEL_DB) und verschlüsselte MC-DB-Zugangsdaten (CREDS_DB). """ import json +import secrets +from datetime import datetime, timedelta import pymysql import pymysql.cursors from config import Config @@ -98,6 +100,23 @@ PANEL_SCHEMA = [ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", + + """CREATE TABLE IF NOT EXISTS group_invites ( + id INT AUTO_INCREMENT PRIMARY KEY, + group_id INT NOT NULL, + invited_username VARCHAR(50) NOT NULL, + invited_email VARCHAR(255) NOT NULL, + role ENUM('admin','member') DEFAULT 'member', + token VARCHAR(128) UNIQUE NOT NULL, + created_by_user_id INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + accepted_at DATETIME NULL, + revoked_at DATETIME NULL, + UNIQUE KEY uq_group_pending_invite_email (group_id, invited_email, revoked_at, accepted_at), + FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE, + FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", ] CREDS_SCHEMA = [ @@ -146,6 +165,130 @@ 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: + """Create a non-site-admin user and assign them to a group atomically.""" + permissions = Config.DEFAULT_PERMISSIONS + salt = generate_salt() + pw_hash = hash_password(password, salt) + + conn = get_panel_db() + conn.autocommit(False) + try: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO users (username, email, password_hash, salt, is_site_admin) VALUES (%s,%s,%s,%s,%s)", + (username, email, pw_hash, salt, 0), + ) + user_id = cur.lastrowid + cur.execute( + "INSERT INTO group_members (user_id, group_id, role, permissions) VALUES (%s,%s,%s,%s)", + (user_id, group_id, role, json.dumps(permissions)), + ) + conn.commit() + return user_id + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def create_group_invite(group_id: int, username: str, email: str, role: str, created_by_user_id: int) -> str: + expires_at = datetime.utcnow() + timedelta(hours=Config.INVITE_EXPIRY_HOURS) + token = secrets.token_urlsafe(32) + _panel_query( + "INSERT INTO group_invites (group_id, invited_username, invited_email, role, token, created_by_user_id, expires_at) " + "VALUES (%s,%s,%s,%s,%s,%s,%s)", + (group_id, username, email, role, token, created_by_user_id, expires_at), + write=True, + ) + return token + + +def list_active_group_invites(group_id: int): + return _panel_query( + "SELECT gi.*, u.username AS created_by_username " + "FROM group_invites gi " + "JOIN users u ON u.id = gi.created_by_user_id " + "WHERE gi.group_id=%s AND gi.accepted_at IS NULL AND gi.revoked_at IS NULL AND gi.expires_at > UTC_TIMESTAMP() " + "ORDER BY gi.created_at DESC", + (group_id,), + ) + + +def get_active_invite_by_email(group_id: int, email: str): + return _panel_query( + "SELECT * FROM group_invites WHERE group_id=%s AND invited_email=%s " + "AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP()", + (group_id, email), + fetchone=True, + ) + + +def get_invite_by_token(token: str): + return _panel_query( + "SELECT gi.*, g.name AS group_name, u.username AS created_by_username " + "FROM group_invites gi " + "JOIN user_groups g ON g.id = gi.group_id " + "JOIN users u ON u.id = gi.created_by_user_id " + "WHERE gi.token=%s", + (token,), + fetchone=True, + ) + + +def revoke_group_invite(invite_id: int, group_id: int): + _panel_query( + "UPDATE group_invites SET revoked_at=UTC_TIMESTAMP() WHERE id=%s AND group_id=%s AND accepted_at IS NULL AND revoked_at IS NULL", + (invite_id, group_id), + write=True, + ) + + +def accept_group_invite(token: str, password: str) -> dict | None: + invite = get_invite_by_token(token) + if not invite: + return None + if invite.get("accepted_at") or invite.get("revoked_at"): + return None + if invite["expires_at"] <= datetime.utcnow(): + return None + + permissions = Config.DEFAULT_PERMISSIONS + salt = generate_salt() + pw_hash = hash_password(password, salt) + + conn = get_panel_db() + conn.autocommit(False) + try: + with conn.cursor() as cur: + cur.execute("SELECT id FROM users WHERE username=%s OR email=%s", (invite["invited_username"], invite["invited_email"])) + if cur.fetchone(): + conn.rollback() + return {"error": "username_or_email_taken"} + + cur.execute( + "INSERT INTO users (username, email, password_hash, salt, is_site_admin) VALUES (%s,%s,%s,%s,%s)", + (invite["invited_username"], invite["invited_email"], pw_hash, salt, 0), + ) + user_id = cur.lastrowid + cur.execute( + "INSERT INTO group_members (user_id, group_id, role, permissions) VALUES (%s,%s,%s,%s)", + (user_id, invite["group_id"], invite["role"], json.dumps(permissions)), + ) + cur.execute( + "UPDATE group_invites SET accepted_at=UTC_TIMESTAMP() WHERE id=%s AND accepted_at IS NULL AND revoked_at IS NULL", + (invite["id"],), + ) + conn.commit() + return {"user_id": user_id, "group_id": invite["group_id"]} + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def get_user_by_username(username: str): return _panel_query("SELECT * FROM users WHERE username=%s", (username,), fetchone=True) diff --git a/web/templates/auth/accept_invite.html b/web/templates/auth/accept_invite.html new file mode 100644 index 0000000..76fdbd6 --- /dev/null +++ b/web/templates/auth/accept_invite.html @@ -0,0 +1,72 @@ + + + + + + Accept Invitation + + + + + +
+
+ +

Accept Invitation

+

Join {{ invite.group_name }} on MCLogger

+
+ +
+
+
+
Username: {{ invite.invited_username }}
+
Email: {{ invite.invited_email }}
+
Role: {{ invite.role|capitalize }}
+
Expires: {{ invite.expires_at | fmt_dt }}
+
+ + {% if error %} +
{{ error }}
+ {% endif %} + + {% if is_invalid %} +
+ {% if is_expired %} + This invitation has expired. + {% elif invite.revoked_at %} + This invitation has been revoked. + {% else %} + This invitation has already been used. + {% endif %} +
+ {% else %} +
+ +
+ + +
+
+ + +
+ +
+ {% endif %} +
+
+ + +
+ + + diff --git a/web/templates/group_admin/base.html b/web/templates/group_admin/base.html index 3744479..8946004 100644 --- a/web/templates/group_admin/base.html +++ b/web/templates/group_admin/base.html @@ -44,6 +44,7 @@ {% block content %}{% endblock %} + {% block scripts %}{% endblock %} diff --git a/web/templates/group_admin/members.html b/web/templates/group_admin/members.html index df78766..546311f 100644 --- a/web/templates/group_admin/members.html +++ b/web/templates/group_admin/members.html @@ -4,7 +4,7 @@

Members

- +
Current Members ({{ members|length }})
@@ -46,19 +46,109 @@
+ +
+
Pending Invitations ({{ pending_invites|length }})
+
+ + + + {% for invite in pending_invites %} + {% set invite_url = url_for('auth.accept_invite', token=invite.token, _external=True) %} + + + + + + + {% else %} + + {% endfor %} + +
UserRoleExpiresActions
+
{{ invite.invited_username }}
+ +
+ {% if invite.role == 'admin' %} + Admin + {% else %} + Member + {% endif %} + {{ invite.expires_at | fmt_dt }} + +
+ + +
+
{{ invite_url }}
+
No pending invitations
+
+
- +
-
-
Note
+
+
Add Existing User
-

- New members must be added by the Site Admin. -

-

- As group admin you can manage permissions of existing members and remove members. -

+ {% if non_members %} +
+ +
+ + +
+
+ + +
+ +
+ {% else %} +

No existing users are available to add.

+ {% endif %} +
+
+ +
+
Invite New User
+
+
+ +
+ + +
+
+ + +
The user will receive an invite link and set their own password.
+
+
+ + +
+ +