diff --git a/web/blueprints/site_admin.py b/web/blueprints/site_admin.py index 90ec6f9..a3c81d9 100644 --- a/web/blueprints/site_admin.py +++ b/web/blueprints/site_admin.py @@ -352,28 +352,139 @@ def group_invite_resend(group_id, invite_id): @site_admin.route("/users") @admin_required def users(): - return render_template("admin/users.html", users=db.list_all_users()) + return render_template( + "admin/users.html", + users=db.list_all_users(), + pending_invites=db.list_all_active_invites(), + ) @site_admin.route("/users/new", methods=["GET", "POST"]) @admin_required def user_new(): + groups = db.list_all_groups() if request.method == "POST": username = request.form.get("username", "").strip() email = request.form.get("email", "").strip() - password = request.form.get("password", "") is_site_admin = request.form.get("is_site_admin") == "1" - if not username or not email or not password: - flash("All fields are required.", "danger") + group_id_raw = request.form.get("group_id", "").strip() + role = request.form.get("role", "viewer") + group_id = int(group_id_raw) if group_id_raw else None + + error = None + if not username or not email: + error = "Username and email are required." elif db.get_user_by_username(username): - flash("Username already taken.", "danger") + error = "Username already taken." elif db.get_user_by_email(email): - flash("Email address already in use.", "danger") + error = "Email address already in use." + elif db.get_active_invite_by_username_global(username): + error = "There is already an active invitation for this username." + elif db.get_active_invite_by_email_global(email): + error = "There is already an active invitation for this email." + elif group_id and role not in GROUP_ROLE_SET: + error = "Invalid role selected." + + if error: + flash(error, "danger") + return render_template("admin/user_edit.html", user=None, groups=groups) + + effective_role = role if group_id else "member" + token = db.create_group_invite(group_id, username, email, effective_role, + session["user_id"], is_site_admin=is_site_admin) + invite_url = url_for("auth.accept_invite", token=token, _external=True) + mail_settings = db.get_site_mail_settings() + + if mail_settings: + if group_id: + group = db.get_group_by_id(group_id) + subject = f"Invitation to join {group['name']}" + body = ( + f"Hello {username},\n\n" + f"You have been invited to join the group '{group['name']}' on MCLogger" + f" as {role_label(effective_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" + ) + else: + subject = "You have been invited to MCLogger" + body = ( + f"Hello {username},\n\n" + f"You have been invited to create an account on MCLogger.\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, body) + invite = db.get_invite_by_token(token) + if invite: + db.mark_invite_sent_global(invite["id"]) + flash(f"Invitation email sent to '{email}'.", "success") + except Exception: + flash( + f"Invitation created, but email delivery failed. " + f"Share this link manually: {invite_url}", + "warning", + ) else: - db.create_user(username, email, password, is_site_admin) - flash(f"User '{username}' created.", "success") - return redirect(url_for("site_admin.users")) - return render_template("admin/user_edit.html", user=None) + flash(f"Invitation created for '{username}'. Share this link: {invite_url}", "success") + + return redirect(url_for("site_admin.users")) + return render_template("admin/user_edit.html", user=None, groups=groups) + + +@site_admin.route("/users/invites//revoke", methods=["POST"]) +@admin_required +def user_invite_revoke(invite_id): + db.revoke_invite_global(invite_id) + flash("Invitation revoked.", "success") + return redirect(url_for("site_admin.users")) + + +@site_admin.route("/users/invites//resend", methods=["POST"]) +@admin_required +def user_invite_resend(invite_id): + invite = db.get_invite_by_id_global(invite_id) + if not invite: + flash("Invitation not found.", "danger") + return redirect(url_for("site_admin.users")) + 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.users")) + 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.users")) + mail_settings = db.get_site_mail_settings() + if not mail_settings: + flash("No SMTP settings configured.", "danger") + return redirect(url_for("site_admin.users")) + invite_url = url_for("auth.accept_invite", token=invite["token"], _external=True) + if invite["group_id"]: + group = db.get_group_by_id(invite["group_id"]) + subject = f"Invitation to join {group['name']}" + body = ( + f"Hello {invite['invited_username']},\n\n" + f"You have been invited to join the group '{group['name']}' on MCLogger" + f" 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" + ) + else: + subject = "You have been invited to MCLogger" + body = ( + f"Hello {invite['invited_username']},\n\n" + f"You have been invited to create an account on MCLogger.\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, body) + db.mark_invite_sent_global(invite_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.users")) @site_admin.route("/users//edit", methods=["GET", "POST"]) diff --git a/web/panel_db.py b/web/panel_db.py index 246365b..5f270cc 100644 --- a/web/panel_db.py +++ b/web/panel_db.py @@ -103,7 +103,8 @@ PANEL_SCHEMA = [ """CREATE TABLE IF NOT EXISTS group_invites ( id INT AUTO_INCREMENT PRIMARY KEY, - group_id INT NOT NULL, + group_id INT NULL, + is_site_admin TINYINT(1) NOT NULL DEFAULT 0, invited_username VARCHAR(50) NOT NULL, invited_email VARCHAR(255) NOT NULL, role ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer', @@ -115,7 +116,6 @@ PANEL_SCHEMA = [ send_count INT NOT NULL DEFAULT 0, 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""", @@ -176,6 +176,16 @@ def init_databases(): cur.execute("ALTER TABLE group_invites ADD COLUMN send_count INT NOT NULL DEFAULT 0") except Exception: pass + try: + cur.execute("SET foreign_key_checks=0") + cur.execute("ALTER TABLE group_invites MODIFY group_id INT NULL") + cur.execute("SET foreign_key_checks=1") + except Exception: + pass + try: + cur.execute("ALTER TABLE group_invites ADD COLUMN is_site_admin TINYINT(1) NOT NULL DEFAULT 0") + except Exception: + pass finally: panel.close() @@ -229,13 +239,13 @@ def create_user_for_group(username: str, email: str, password: str, group_id: in conn.close() -def create_group_invite(group_id: int, username: str, email: str, role: str, created_by_user_id: int) -> str: +def create_group_invite(group_id, username: str, email: str, role: str, created_by_user_id: int, is_site_admin: bool = False) -> 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, last_sent_at, send_count) " - "VALUES (%s,%s,%s,%s,%s,%s,%s,NULL,0)", - (group_id, username, 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, is_site_admin) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,NULL,0,%s)", + (group_id, username, email, role, token, created_by_user_id, expires_at, int(is_site_admin)), write=True, ) return token @@ -291,7 +301,7 @@ 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 " + "LEFT 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,), @@ -307,6 +317,64 @@ def revoke_group_invite(invite_id: int, group_id: int): ) +def list_all_active_invites(): + """All pending invites across every group (for site admin users page).""" + return _panel_query( + "SELECT gi.*, g.name AS group_name, u.username AS created_by_username " + "FROM group_invites gi " + "LEFT JOIN user_groups g ON g.id = gi.group_id " + "JOIN users u ON u.id = gi.created_by_user_id " + "WHERE gi.accepted_at IS NULL AND gi.revoked_at IS NULL AND gi.expires_at > UTC_TIMESTAMP() " + "ORDER BY gi.created_at DESC" + ) + + +def get_invite_by_id_global(invite_id: int): + return _panel_query( + "SELECT gi.*, g.name AS group_name, u.username AS created_by_username " + "FROM group_invites gi " + "LEFT JOIN user_groups g ON g.id = gi.group_id " + "JOIN users u ON u.id = gi.created_by_user_id " + "WHERE gi.id=%s", + (invite_id,), + fetchone=True, + ) + + +def revoke_invite_global(invite_id: int): + _panel_query( + "UPDATE group_invites SET revoked_at=UTC_TIMESTAMP() WHERE id=%s AND accepted_at IS NULL AND revoked_at IS NULL", + (invite_id,), + write=True, + ) + + +def mark_invite_sent_global(invite_id: int): + _panel_query( + "UPDATE group_invites SET last_sent_at=UTC_TIMESTAMP(), send_count=send_count+1 WHERE id=%s", + (invite_id,), + write=True, + ) + + +def get_active_invite_by_email_global(email: str): + return _panel_query( + "SELECT * FROM group_invites WHERE invited_email=%s " + "AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP()", + (email,), + fetchone=True, + ) + + +def get_active_invite_by_username_global(username: str): + return _panel_query( + "SELECT * FROM group_invites WHERE invited_username=%s " + "AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP()", + (username,), + fetchone=True, + ) + + 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", @@ -342,10 +410,16 @@ def accept_group_invite(token: str, password: str) -> dict | None: (invite["invited_username"], invite["invited_email"], pw_hash, salt, 0), ) user_id = cur.lastrowid + site_admin_flag = int(bool(invite.get("is_site_admin"))) 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)), + "UPDATE users SET is_site_admin=%s WHERE id=%s", + (site_admin_flag, user_id), ) + if invite["group_id"] is not None: + 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"],), diff --git a/web/templates/admin/user_edit.html b/web/templates/admin/user_edit.html index 4a28938..ec2f2cd 100644 --- a/web/templates/admin/user_edit.html +++ b/web/templates/admin/user_edit.html @@ -1,15 +1,15 @@ {% extends "admin/base.html" %} -{% block title %}{{ 'Edit User' if user else 'New User' }}{% endblock %} +{% block title %}{{ 'Edit User' if user else 'Invite New User' }}{% endblock %} {% block content %}
-

{{ 'Edit User: ' ~ user.username if user else 'New User' }}

+

{{ 'Edit User: ' ~ user.username if user else 'Invite New User' }}

-
+
@@ -24,14 +24,42 @@
+ {% if user %}
- - - {% if not user %} -
Minimum 8 characters recommended.
- {% endif %} + +
+ {% else %} + {# ── Invite form: group + role (optional) ── #} +
+ + +
If selected, the user will be added to this group upon accepting the invite.
+
+ +
+ + The user will receive an email with a link to set their own password. +
+ {% endif %}
Cancel
@@ -53,4 +85,14 @@
+{% if not user %} + +{% endif %} {% endblock %} diff --git a/web/templates/admin/users.html b/web/templates/admin/users.html index 376dbaf..b852b51 100644 --- a/web/templates/admin/users.html +++ b/web/templates/admin/users.html @@ -4,11 +4,77 @@ +{# ── Pending Invitations ── #} +{% if pending_invites %} +
Pending Invitations
+
+
+ + + + + + + + + + + + + + {% for inv in pending_invites %} + + + + + + + + + + {% endfor %} + +
UsernameEmailGroupRoleExpiresSentActions
{{ inv.invited_username }}{{ inv.invited_email }} + {% if inv.group_name %} + {{ inv.group_name }} + {% else %} + + {% endif %} + {{ inv.role }}{{ inv.expires_at | fmt_dt }}{{ inv.send_count }}× + {# Copy link #} + {% set invite_url = url_for('auth.accept_invite', token=inv.token, _external=True) %} + + {# Resend #} + + + + + {# Revoke #} +
+ + +
+
+
+
+{% endif %} +
+
Registered Users
+