Update settings/admin UI and misc fixes
This commit is contained in:
@@ -7,7 +7,8 @@ from flask_login import current_user, login_required, login_user
|
||||
|
||||
from ..extensions import db
|
||||
from ..uploads import abs_upload_path, ensure_company_upload_dir, get_company_upload_bytes, is_valid_upload_relpath
|
||||
from ..models import Company, Display, DisplaySession, Playlist, PlaylistItem, User
|
||||
from ..models import AppSettings, Company, Display, DisplaySession, Playlist, PlaylistItem, User
|
||||
from ..email_utils import send_email
|
||||
|
||||
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
|
||||
@@ -17,6 +18,18 @@ def admin_required():
|
||||
abort(403)
|
||||
|
||||
|
||||
def _get_app_settings() -> AppSettings:
|
||||
"""Get the singleton-ish AppSettings row, creating it if needed."""
|
||||
|
||||
s = db.session.get(AppSettings, 1)
|
||||
if s:
|
||||
return s
|
||||
s = AppSettings(id=1)
|
||||
db.session.add(s)
|
||||
db.session.commit()
|
||||
return s
|
||||
|
||||
|
||||
def _try_delete_upload(file_path: str | None, upload_folder: str):
|
||||
"""Best-effort delete of an uploaded media file."""
|
||||
if not file_path:
|
||||
@@ -43,6 +56,188 @@ def dashboard():
|
||||
return render_template("admin/dashboard.html", companies=companies)
|
||||
|
||||
|
||||
@bp.get("/settings")
|
||||
@login_required
|
||||
def settings():
|
||||
admin_required()
|
||||
settings = _get_app_settings()
|
||||
admins = User.query.filter_by(is_admin=True).order_by(User.email.asc()).all()
|
||||
return render_template("admin/settings.html", settings=settings, admins=admins)
|
||||
|
||||
|
||||
@bp.post("/settings/smtp")
|
||||
@login_required
|
||||
def update_smtp_settings():
|
||||
admin_required()
|
||||
s = _get_app_settings()
|
||||
|
||||
smtp_host = (request.form.get("smtp_host") or "").strip() or None
|
||||
smtp_port_raw = (request.form.get("smtp_port") or "").strip()
|
||||
smtp_username = (request.form.get("smtp_username") or "").strip() or None
|
||||
smtp_password = request.form.get("smtp_password")
|
||||
smtp_from = (request.form.get("smtp_from") or "").strip() or None
|
||||
smtp_starttls = (request.form.get("smtp_starttls") or "").lower() in ("1", "true", "yes", "on")
|
||||
smtp_debug = (request.form.get("smtp_debug") or "").lower() in ("1", "true", "yes", "on")
|
||||
smtp_timeout_raw = (request.form.get("smtp_timeout_seconds") or "").strip()
|
||||
|
||||
smtp_port: int | None = None
|
||||
if smtp_port_raw:
|
||||
try:
|
||||
smtp_port = int(smtp_port_raw)
|
||||
except ValueError:
|
||||
flash("SMTP port must be a number", "danger")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
smtp_timeout: float = 10.0
|
||||
if smtp_timeout_raw:
|
||||
try:
|
||||
smtp_timeout = float(smtp_timeout_raw)
|
||||
except ValueError:
|
||||
flash("SMTP timeout must be a number (seconds)", "danger")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
if smtp_port is not None and (smtp_port <= 0 or smtp_port > 65535):
|
||||
flash("SMTP port must be between 1 and 65535", "danger")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
if smtp_timeout <= 0:
|
||||
flash("SMTP timeout must be > 0", "danger")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
s.smtp_host = smtp_host
|
||||
s.smtp_port = smtp_port
|
||||
s.smtp_username = smtp_username
|
||||
|
||||
# Only overwrite password if a value was submitted.
|
||||
# This allows editing other SMTP fields without having to re-enter the password.
|
||||
if smtp_password is not None and smtp_password.strip() != "":
|
||||
s.smtp_password = smtp_password
|
||||
|
||||
s.smtp_from = smtp_from
|
||||
s.smtp_starttls = smtp_starttls
|
||||
s.smtp_timeout_seconds = smtp_timeout
|
||||
s.smtp_debug = smtp_debug
|
||||
db.session.commit()
|
||||
|
||||
flash("SMTP settings saved.", "success")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
|
||||
@bp.post("/settings/domain")
|
||||
@login_required
|
||||
def update_public_domain():
|
||||
admin_required()
|
||||
s = _get_app_settings()
|
||||
raw = (request.form.get("public_domain") or "").strip().lower()
|
||||
if raw:
|
||||
# Normalize: user asked for domain-only (no scheme). Strip possible scheme anyway.
|
||||
raw = raw.replace("http://", "").replace("https://", "")
|
||||
raw = raw.strip().strip("/")
|
||||
if "/" in raw:
|
||||
flash("Public domain must not contain a path. Example: signage.example.com", "danger")
|
||||
return redirect(url_for("admin.settings"))
|
||||
if " " in raw:
|
||||
flash("Public domain must not contain spaces", "danger")
|
||||
return redirect(url_for("admin.settings"))
|
||||
s.public_domain = raw
|
||||
else:
|
||||
s.public_domain = None
|
||||
db.session.commit()
|
||||
flash("Public domain saved.", "success")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
|
||||
@bp.post("/settings/test-email")
|
||||
@login_required
|
||||
def send_test_email():
|
||||
admin_required()
|
||||
to_email = (request.form.get("to_email") or "").strip().lower()
|
||||
if not to_email:
|
||||
flash("Test email recipient is required", "danger")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
try:
|
||||
send_email(
|
||||
to_email=to_email,
|
||||
subject="Signage SMTP test",
|
||||
body_text="This is a test email from Signage (Admin → Settings).",
|
||||
)
|
||||
except Exception as e:
|
||||
# Show a short error to the admin (they requested a test).
|
||||
flash(f"Failed to send test email: {e}", "danger")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
flash(f"Test email sent to {to_email}", "success")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
|
||||
@bp.post("/settings/admins")
|
||||
@login_required
|
||||
def create_admin_user():
|
||||
admin_required()
|
||||
email = (request.form.get("email") or "").strip().lower()
|
||||
password = request.form.get("password") or ""
|
||||
|
||||
if not email or not password:
|
||||
flash("Email and password are required", "danger")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
if len(password) < 8:
|
||||
flash("Password must be at least 8 characters", "danger")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
existing = User.query.filter_by(email=email).first()
|
||||
if existing:
|
||||
if existing.is_admin:
|
||||
flash("That user is already an admin", "warning")
|
||||
return redirect(url_for("admin.settings"))
|
||||
# Promote existing user to admin
|
||||
existing.is_admin = True
|
||||
existing.set_password(password)
|
||||
existing.email = email
|
||||
existing.username = email
|
||||
existing.company_id = None
|
||||
db.session.commit()
|
||||
flash("User promoted to admin.", "success")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
u = User(is_admin=True)
|
||||
u.email = email
|
||||
u.username = email
|
||||
u.set_password(password)
|
||||
db.session.add(u)
|
||||
db.session.commit()
|
||||
flash("Admin user created.", "success")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
|
||||
@bp.post("/settings/admins/<int:user_id>/demote")
|
||||
@login_required
|
||||
def demote_admin_user(user_id: int):
|
||||
admin_required()
|
||||
if current_user.id == user_id:
|
||||
flash("You cannot demote yourself", "danger")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
u = db.session.get(User, user_id)
|
||||
if not u:
|
||||
abort(404)
|
||||
if not u.is_admin:
|
||||
flash("That user is not an admin", "warning")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
# Ensure we always keep at least one admin.
|
||||
admin_count = User.query.filter_by(is_admin=True).count()
|
||||
if admin_count <= 1:
|
||||
flash("Cannot demote the last remaining admin", "danger")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
u.is_admin = False
|
||||
db.session.commit()
|
||||
flash("Admin user demoted.", "success")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
|
||||
@bp.post("/companies")
|
||||
@login_required
|
||||
def create_company():
|
||||
|
||||
@@ -7,7 +7,7 @@ from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
from ..extensions import db
|
||||
from ..email_utils import send_email
|
||||
from ..models import User
|
||||
from ..models import AppSettings, User
|
||||
from ..auth_tokens import load_password_reset_user_id, make_password_reset_token
|
||||
|
||||
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||
@@ -48,7 +48,18 @@ def forgot_password_post():
|
||||
user = User.query.filter_by(email=email).first()
|
||||
if user:
|
||||
token = _make_reset_token(user)
|
||||
reset_url = url_for("auth.reset_password", token=token, _external=True)
|
||||
|
||||
# By default Flask uses the request host when building _external URLs.
|
||||
# For deployments behind proxies or where the public host differs, allow
|
||||
# admins to configure a public domain used in email links.
|
||||
settings = db.session.get(AppSettings, 1)
|
||||
if settings and settings.public_domain:
|
||||
# Flask's url_for doesn't support overriding the host per-call.
|
||||
# We generate the relative path and prefix it with the configured domain.
|
||||
path = url_for("auth.reset_password", token=token, _external=False)
|
||||
reset_url = f"https://{settings.public_domain}{path}"
|
||||
else:
|
||||
reset_url = url_for("auth.reset_password", token=token, _external=True)
|
||||
body = (
|
||||
"Someone requested a password reset for your account.\n\n"
|
||||
f"Reset your password using this link (valid for 30 minutes):\n{reset_url}\n\n"
|
||||
|
||||
@@ -18,7 +18,7 @@ from ..uploads import (
|
||||
get_company_upload_bytes,
|
||||
is_valid_upload_relpath,
|
||||
)
|
||||
from ..models import Company, Display, DisplaySession, Playlist, PlaylistItem, User
|
||||
from ..models import AppSettings, Company, Display, DisplaySession, Playlist, PlaylistItem, User
|
||||
from ..email_utils import send_email
|
||||
from ..auth_tokens import make_password_reset_token
|
||||
|
||||
@@ -289,7 +289,13 @@ def invite_user():
|
||||
db.session.commit()
|
||||
|
||||
token = make_password_reset_token(secret_key=current_app.config["SECRET_KEY"], user_id=u.id)
|
||||
reset_url = url_for("auth.reset_password", token=token, _external=True)
|
||||
|
||||
settings = db.session.get(AppSettings, 1)
|
||||
if settings and settings.public_domain:
|
||||
path = url_for("auth.reset_password", token=token, _external=False)
|
||||
reset_url = f"https://{settings.public_domain}{path}"
|
||||
else:
|
||||
reset_url = url_for("auth.reset_password", token=token, _external=True)
|
||||
body = (
|
||||
f"You have been invited to {company.name} on Signage.\n\n"
|
||||
"Set your password using this link (valid for 30 minutes):\n"
|
||||
|
||||
Reference in New Issue
Block a user