modified: web/blueprints/site_admin.py
modified: web/panel_db.py modified: web/templates/admin/user_edit.html modified: web/templates/admin/users.html
This commit is contained in:
@@ -352,28 +352,139 @@ def group_invite_resend(group_id, invite_id):
|
|||||||
@site_admin.route("/users")
|
@site_admin.route("/users")
|
||||||
@admin_required
|
@admin_required
|
||||||
def users():
|
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"])
|
@site_admin.route("/users/new", methods=["GET", "POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def user_new():
|
def user_new():
|
||||||
|
groups = db.list_all_groups()
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username", "").strip()
|
username = request.form.get("username", "").strip()
|
||||||
email = request.form.get("email", "").strip()
|
email = request.form.get("email", "").strip()
|
||||||
password = request.form.get("password", "")
|
|
||||||
is_site_admin = request.form.get("is_site_admin") == "1"
|
is_site_admin = request.form.get("is_site_admin") == "1"
|
||||||
if not username or not email or not password:
|
group_id_raw = request.form.get("group_id", "").strip()
|
||||||
flash("All fields are required.", "danger")
|
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):
|
elif db.get_user_by_username(username):
|
||||||
flash("Username already taken.", "danger")
|
error = "Username already taken."
|
||||||
elif db.get_user_by_email(email):
|
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:
|
else:
|
||||||
db.create_user(username, email, password, is_site_admin)
|
subject = "You have been invited to MCLogger"
|
||||||
flash(f"User '{username}' created.", "success")
|
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:
|
||||||
|
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/<int:invite_id>/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/<int:invite_id>/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"))
|
return redirect(url_for("site_admin.users"))
|
||||||
return render_template("admin/user_edit.html", user=None)
|
|
||||||
|
|
||||||
|
|
||||||
@site_admin.route("/users/<int:user_id>/edit", methods=["GET", "POST"])
|
@site_admin.route("/users/<int:user_id>/edit", methods=["GET", "POST"])
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ PANEL_SCHEMA = [
|
|||||||
|
|
||||||
"""CREATE TABLE IF NOT EXISTS group_invites (
|
"""CREATE TABLE IF NOT EXISTS group_invites (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
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_username VARCHAR(50) NOT NULL,
|
||||||
invited_email VARCHAR(255) NOT NULL,
|
invited_email VARCHAR(255) NOT NULL,
|
||||||
role ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer',
|
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,
|
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),
|
|
||||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
) 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")
|
cur.execute("ALTER TABLE group_invites ADD COLUMN send_count INT NOT NULL DEFAULT 0")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
finally:
|
||||||
panel.close()
|
panel.close()
|
||||||
|
|
||||||
@@ -229,13 +239,13 @@ def create_user_for_group(username: str, email: str, password: str, group_id: in
|
|||||||
conn.close()
|
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)
|
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, last_sent_at, send_count) "
|
"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)",
|
"VALUES (%s,%s,%s,%s,%s,%s,%s,NULL,0,%s)",
|
||||||
(group_id, username, email, role, token, created_by_user_id, expires_at),
|
(group_id, username, email, role, token, created_by_user_id, expires_at, int(is_site_admin)),
|
||||||
write=True,
|
write=True,
|
||||||
)
|
)
|
||||||
return token
|
return token
|
||||||
@@ -291,7 +301,7 @@ 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 "
|
||||||
"FROM group_invites gi "
|
"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 "
|
"JOIN users u ON u.id = gi.created_by_user_id "
|
||||||
"WHERE gi.token=%s",
|
"WHERE gi.token=%s",
|
||||||
(token,),
|
(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):
|
def mark_group_invite_sent(invite_id: int, group_id: int):
|
||||||
_panel_query(
|
_panel_query(
|
||||||
"UPDATE group_invites SET last_sent_at=UTC_TIMESTAMP(), send_count=send_count+1 WHERE id=%s AND group_id=%s",
|
"UPDATE group_invites SET last_sent_at=UTC_TIMESTAMP(), send_count=send_count+1 WHERE id=%s AND group_id=%s",
|
||||||
@@ -342,6 +410,12 @@ def accept_group_invite(token: str, password: str) -> dict | None:
|
|||||||
(invite["invited_username"], invite["invited_email"], pw_hash, salt, 0),
|
(invite["invited_username"], invite["invited_email"], pw_hash, salt, 0),
|
||||||
)
|
)
|
||||||
user_id = cur.lastrowid
|
user_id = cur.lastrowid
|
||||||
|
site_admin_flag = int(bool(invite.get("is_site_admin")))
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE users SET is_site_admin=%s WHERE id=%s",
|
||||||
|
(site_admin_flag, user_id),
|
||||||
|
)
|
||||||
|
if invite["group_id"] is not None:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT INTO group_members (user_id, group_id, role, permissions) VALUES (%s,%s,%s,%s)",
|
"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)),
|
(user_id, invite["group_id"], invite["role"], json.dumps(permissions)),
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
{% extends "admin/base.html" %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="d-flex align-items-center gap-2 mb-4">
|
<div class="d-flex align-items-center gap-2 mb-4">
|
||||||
<a href="{{ url_for('site_admin.users') }}" class="btn btn-sm btn-outline-secondary">
|
<a href="{{ url_for('site_admin.users') }}" class="btn btn-sm btn-outline-secondary">
|
||||||
<i class="bi bi-arrow-left"></i>
|
<i class="bi bi-arrow-left"></i>
|
||||||
</a>
|
</a>
|
||||||
<h2 class="mb-0">{{ 'Edit User: ' ~ user.username if user else 'New User' }}</h2>
|
<h2 class="mb-0">{{ 'Edit User: ' ~ user.username if user else 'Invite New User' }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-7">
|
||||||
<div class="card border-secondary">
|
<div class="card border-secondary">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
@@ -24,14 +24,42 @@
|
|||||||
<input type="email" name="email" class="form-control" required
|
<input type="email" name="email" class="form-control" required
|
||||||
value="{{ user.email if user else request.form.get('email', '') }}">
|
value="{{ user.email if user else request.form.get('email', '') }}">
|
||||||
</div>
|
</div>
|
||||||
|
{% if user %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">{{ 'New Password (leave blank = unchanged)' if user else 'Password *' }}</label>
|
<label class="form-label">New Password <span class="text-muted">(leave blank = unchanged)</span></label>
|
||||||
<input type="password" name="{{ 'new_password' if user else 'password' }}" class="form-control"
|
<input type="password" name="new_password" class="form-control">
|
||||||
{{ '' if user else 'required' }}>
|
|
||||||
{% if not user %}
|
|
||||||
<div class="form-text">Minimum 8 characters recommended.</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{# ── Invite form: group + role (optional) ── #}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Group <span class="text-muted">(optional)</span></label>
|
||||||
|
<select name="group_id" id="invite_group" class="form-select" onchange="toggleRoleField()">
|
||||||
|
<option value="">— No group —</option>
|
||||||
|
{% for g in (groups or []) %}
|
||||||
|
<option value="{{ g.id }}"
|
||||||
|
{% if request.form.get('group_id')|string == g.id|string %}selected{% endif %}>
|
||||||
|
{{ g.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">If selected, the user will be added to this group upon accepting the invite.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" id="role_field" style="display:none;">
|
||||||
|
<label class="form-label">Group Role</label>
|
||||||
|
<select name="role" class="form-select">
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
<option value="auditor">Auditor</option>
|
||||||
|
<option value="member">Member</option>
|
||||||
|
<option value="moderator">Moderator</option>
|
||||||
|
<option value="group_admin">Group Admin</option>
|
||||||
|
<option value="group_owner">Group Owner</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info py-2 mb-3">
|
||||||
|
<i class="bi bi-envelope-check me-1"></i>
|
||||||
|
The user will receive an email with a link to set their own password.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox" name="is_site_admin" id="is_site_admin" class="form-check-input"
|
<input type="checkbox" name="is_site_admin" id="is_site_admin" class="form-check-input"
|
||||||
@@ -44,7 +72,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-success">
|
<button type="submit" class="btn btn-success">
|
||||||
<i class="bi bi-check-lg me-1"></i>{{ 'Save' if user else 'Create' }}
|
{% if user %}
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Save
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-envelope-fill me-1"></i>Send Invitation
|
||||||
|
{% endif %}
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('site_admin.users') }}" class="btn btn-outline-secondary">Cancel</a>
|
<a href="{{ url_for('site_admin.users') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,4 +85,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if not user %}
|
||||||
|
<script>
|
||||||
|
function toggleRoleField() {
|
||||||
|
var gid = document.getElementById('invite_group').value;
|
||||||
|
document.getElementById('role_field').style.display = gid ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
// Show role field if a group was pre-selected (e.g. after validation error)
|
||||||
|
document.addEventListener('DOMContentLoaded', toggleRoleField);
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,10 +4,76 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2><i class="bi bi-people-fill me-2"></i>Users</h2>
|
<h2><i class="bi bi-people-fill me-2"></i>Users</h2>
|
||||||
<a href="{{ url_for('site_admin.user_new') }}" class="btn btn-success">
|
<a href="{{ url_for('site_admin.user_new') }}" class="btn btn-success">
|
||||||
<i class="bi bi-person-plus-fill me-1"></i>New User
|
<i class="bi bi-envelope-plus-fill me-1"></i>Invite User
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ── Pending Invitations ── #}
|
||||||
|
{% if pending_invites %}
|
||||||
|
<h5 class="text-muted mb-2"><i class="bi bi-envelope-open me-1"></i>Pending Invitations</h5>
|
||||||
|
<div class="card border-warning mb-4">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-hover mb-0 small">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Group</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Sent</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for inv in pending_invites %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">{{ inv.invited_username }}</td>
|
||||||
|
<td class="text-muted">{{ inv.invited_email }}</td>
|
||||||
|
<td>
|
||||||
|
{% if inv.group_name %}
|
||||||
|
<span class="badge bg-secondary">{{ inv.group_name }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ inv.role }}</td>
|
||||||
|
<td class="text-muted">{{ inv.expires_at | fmt_dt }}</td>
|
||||||
|
<td class="text-muted">{{ inv.send_count }}×</td>
|
||||||
|
<td class="text-end">
|
||||||
|
{# Copy link #}
|
||||||
|
{% set invite_url = url_for('auth.accept_invite', token=inv.token, _external=True) %}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
title="Copy invite link"
|
||||||
|
onclick="navigator.clipboard.writeText('{{ invite_url }}').then(()=>this.title='Copied!')">
|
||||||
|
<i class="bi bi-clipboard"></i>
|
||||||
|
</button>
|
||||||
|
{# Resend #}
|
||||||
|
<form method="post" action="{{ url_for('site_admin.user_invite_resend', invite_id=inv.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 email">
|
||||||
|
<i class="bi bi-send"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{# Revoke #}
|
||||||
|
<form method="post" action="{{ url_for('site_admin.user_invite_revoke', invite_id=inv.id) }}" class="d-inline"
|
||||||
|
onsubmit="return confirm('Revoke invitation for {{ inv.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-circle"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card border-secondary">
|
||||||
|
<h5 class="text-muted mb-2"><i class="bi bi-people me-1"></i>Registered Users</h5>
|
||||||
<div class="card border-secondary">
|
<div class="card border-secondary">
|
||||||
<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">
|
||||||
|
|||||||
Reference in New Issue
Block a user