Update settings/admin UI and misc fixes
This commit is contained in:
14
.flaskenv
14
.flaskenv
@@ -1,9 +1,9 @@
|
|||||||
FLASK_APP=app:create_app
|
FLASK_APP=app:create_app
|
||||||
FLASK_DEBUG=1
|
FLASK_DEBUG=1
|
||||||
SMTP_HOST=smtp.strato.de
|
##SMTP_HOST=smtp.strato.de
|
||||||
SMTP_PORT=465
|
##SMTP_PORT=465
|
||||||
SMTP_USERNAME=beheer@alphen.cloud
|
##SMTP_USERNAME=beheer@alphen.cloud
|
||||||
SMTP_PASSWORD=Fr@nkrijk2024!
|
##SMTP_PASSWORD=***
|
||||||
SMTP_FROM=beheer@alphen.cloud
|
##SMTP_FROM=beheer@alphen.cloud
|
||||||
SMTP_STARTTLS=1
|
##SMTP_STARTTLS=1
|
||||||
SMTP_DEBUG=1
|
##SMTP_DEBUG=1
|
||||||
17
README.md
17
README.md
@@ -56,6 +56,15 @@ The player keeps itself up-to-date automatically:
|
|||||||
|
|
||||||
This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables.
|
This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables.
|
||||||
|
|
||||||
|
You can also configure SMTP settings from the UI: **Admin → Settings**.
|
||||||
|
Environment variables still take precedence over the database settings.
|
||||||
|
|
||||||
|
### Public domain for emails
|
||||||
|
|
||||||
|
If your app runs behind a reverse proxy (or the internal hostname differs from the public hostname),
|
||||||
|
set **Admin → Settings → Public domain** to e.g. `signage.example.com` so links in password reset
|
||||||
|
emails point to the correct address.
|
||||||
|
|
||||||
Recommended: put these in a local `.env` file in the repo root. Flask (via `python-dotenv`) will auto-load it on startup. `.env` is already gitignored.
|
Recommended: put these in a local `.env` file in the repo root. Flask (via `python-dotenv`) will auto-load it on startup. `.env` is already gitignored.
|
||||||
|
|
||||||
You can start from `.env.example`:
|
You can start from `.env.example`:
|
||||||
@@ -81,6 +90,11 @@ REM Option B: put the same keys/values in a .env file instead
|
|||||||
|
|
||||||
Security note: do **not** commit SMTP passwords to the repo. Prefer secrets management and rotate leaked credentials.
|
Security note: do **not** commit SMTP passwords to the repo. Prefer secrets management and rotate leaked credentials.
|
||||||
|
|
||||||
|
Note on the "From" address: some SMTP providers enforce that the authenticated mailbox
|
||||||
|
(`SMTP_USERNAME`) is used as the actual sender (envelope-from), even if a different
|
||||||
|
`SMTP_FROM` is provided. In that case the app sets a `Reply-To` header so replies still
|
||||||
|
go to `SMTP_FROM`, but the provider may still show the username address as the sender.
|
||||||
|
|
||||||
### Troubleshooting mail delivery
|
### Troubleshooting mail delivery
|
||||||
|
|
||||||
If the reset email is not received:
|
If the reset email is not received:
|
||||||
@@ -104,3 +118,6 @@ If the reset email is not received:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from flask import Flask, jsonify, request
|
|||||||
from werkzeug.exceptions import RequestEntityTooLarge
|
from werkzeug.exceptions import RequestEntityTooLarge
|
||||||
|
|
||||||
from .extensions import db, login_manager
|
from .extensions import db, login_manager
|
||||||
from .models import User
|
from .models import AppSettings, User
|
||||||
from .cli import init_db_command
|
from .cli import init_db_command
|
||||||
|
|
||||||
|
|
||||||
@@ -64,6 +64,19 @@ def create_app():
|
|||||||
if "storage_max_bytes" not in company_cols:
|
if "storage_max_bytes" not in company_cols:
|
||||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT"))
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT"))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# AppSettings: create settings table if missing.
|
||||||
|
# (PRAGMA returns empty if the table doesn't exist.)
|
||||||
|
settings_cols = [
|
||||||
|
r[1] for r in db.session.execute(db.text("PRAGMA table_info(app_settings)")).fetchall()
|
||||||
|
]
|
||||||
|
if not settings_cols:
|
||||||
|
AppSettings.__table__.create(db.engine, checkfirst=True)
|
||||||
|
|
||||||
|
# AppSettings: add public_domain column if missing.
|
||||||
|
if settings_cols and "public_domain" not in settings_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE app_settings ADD COLUMN public_domain VARCHAR(255)"))
|
||||||
|
db.session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
|
|
||||||
|
|||||||
12
app/cli.py
12
app/cli.py
@@ -2,7 +2,7 @@ import click
|
|||||||
from flask.cli import with_appcontext
|
from flask.cli import with_appcontext
|
||||||
|
|
||||||
from .extensions import db
|
from .extensions import db
|
||||||
from .models import User
|
from .models import AppSettings, User
|
||||||
|
|
||||||
|
|
||||||
@click.command("init-db")
|
@click.command("init-db")
|
||||||
@@ -36,10 +36,20 @@ def init_db_command(admin_email: str, admin_pass: str):
|
|||||||
if "storage_max_bytes" not in company_cols:
|
if "storage_max_bytes" not in company_cols:
|
||||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT"))
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT"))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
settings_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(app_settings)")).fetchall()]
|
||||||
|
if settings_cols and "public_domain" not in settings_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE app_settings ADD COLUMN public_domain VARCHAR(255)"))
|
||||||
|
db.session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
# Best-effort; if it fails we continue so fresh DBs still work.
|
# Best-effort; if it fails we continue so fresh DBs still work.
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
|
|
||||||
|
# Ensure AppSettings row exists.
|
||||||
|
if not db.session.get(AppSettings, 1):
|
||||||
|
db.session.add(AppSettings(id=1))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
admin_email = (admin_email or "").strip().lower()
|
admin_email = (admin_email or "").strip().lower()
|
||||||
if not admin_email:
|
if not admin_email:
|
||||||
raise click.UsageError("--admin-email is required")
|
raise click.UsageError("--admin-email is required")
|
||||||
|
|||||||
@@ -3,8 +3,17 @@ import smtplib
|
|||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
|
||||||
|
def _truthy(v: str | None) -> bool:
|
||||||
|
if v is None:
|
||||||
|
return False
|
||||||
|
return v.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
def send_email(*, to_email: str, subject: str, body_text: str):
|
def send_email(*, to_email: str, subject: str, body_text: str):
|
||||||
"""Send a plain-text email using SMTP settings from environment variables.
|
"""Send a plain-text email using SMTP settings from:
|
||||||
|
|
||||||
|
1) Environment variables (highest priority)
|
||||||
|
2) Admin-configured settings stored in the database (AppSettings)
|
||||||
|
|
||||||
Required env vars:
|
Required env vars:
|
||||||
- SMTP_HOST
|
- SMTP_HOST
|
||||||
@@ -19,14 +28,46 @@ def send_email(*, to_email: str, subject: str, body_text: str):
|
|||||||
- SMTP_DEBUG (default: "0") - set to 1 to print SMTP conversation to console
|
- SMTP_DEBUG (default: "0") - set to 1 to print SMTP conversation to console
|
||||||
"""
|
"""
|
||||||
|
|
||||||
host = os.environ.get("SMTP_HOST")
|
# Pull optional defaults from DB (if available). This keeps backwards compatibility:
|
||||||
port = int(os.environ.get("SMTP_PORT", "587"))
|
# existing deployments that only use env vars keep working unchanged.
|
||||||
username = os.environ.get("SMTP_USERNAME")
|
db_defaults = {}
|
||||||
password = os.environ.get("SMTP_PASSWORD")
|
try:
|
||||||
from_email = os.environ.get("SMTP_FROM") or username
|
# Local import to avoid import cycles and to keep this module lightweight.
|
||||||
starttls = os.environ.get("SMTP_STARTTLS", "1").lower() in ("1", "true", "yes", "on")
|
from flask import current_app
|
||||||
timeout = float(os.environ.get("SMTP_TIMEOUT_SECONDS", "10"))
|
|
||||||
debug = os.environ.get("SMTP_DEBUG", "0").lower() in ("1", "true", "yes", "on")
|
from .extensions import db
|
||||||
|
from .models import AppSettings
|
||||||
|
|
||||||
|
# Only try if app context exists (send_email is called inside requests/CLI normally).
|
||||||
|
_ = current_app # noqa: F841
|
||||||
|
s = db.session.get(AppSettings, 1)
|
||||||
|
if s:
|
||||||
|
db_defaults = {
|
||||||
|
"host": s.smtp_host,
|
||||||
|
"port": s.smtp_port,
|
||||||
|
"username": s.smtp_username,
|
||||||
|
"password": s.smtp_password,
|
||||||
|
"from_email": s.smtp_from,
|
||||||
|
"starttls": s.smtp_starttls,
|
||||||
|
"timeout": s.smtp_timeout_seconds,
|
||||||
|
"debug": s.smtp_debug,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
# Best-effort; if DB isn't ready we fall back to env vars only.
|
||||||
|
db_defaults = {}
|
||||||
|
|
||||||
|
host = os.environ.get("SMTP_HOST") or db_defaults.get("host")
|
||||||
|
port = int(os.environ.get("SMTP_PORT") or db_defaults.get("port") or 587)
|
||||||
|
username = os.environ.get("SMTP_USERNAME") or db_defaults.get("username")
|
||||||
|
password = os.environ.get("SMTP_PASSWORD") or db_defaults.get("password")
|
||||||
|
from_email = os.environ.get("SMTP_FROM") or db_defaults.get("from_email") or username
|
||||||
|
starttls = _truthy(os.environ.get("SMTP_STARTTLS")) if os.environ.get("SMTP_STARTTLS") is not None else bool(
|
||||||
|
db_defaults.get("starttls") if db_defaults.get("starttls") is not None else True
|
||||||
|
)
|
||||||
|
timeout = float(os.environ.get("SMTP_TIMEOUT_SECONDS") or db_defaults.get("timeout") or 10)
|
||||||
|
debug = _truthy(os.environ.get("SMTP_DEBUG")) if os.environ.get("SMTP_DEBUG") is not None else bool(
|
||||||
|
db_defaults.get("debug") or False
|
||||||
|
)
|
||||||
|
|
||||||
missing = []
|
missing = []
|
||||||
if not host:
|
if not host:
|
||||||
@@ -41,13 +82,17 @@ def send_email(*, to_email: str, subject: str, body_text: str):
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Missing SMTP configuration: "
|
"Missing SMTP configuration: "
|
||||||
+ ", ".join(missing)
|
+ ", ".join(missing)
|
||||||
+ ". Set them as environment variables (or in a local .env file)."
|
+ ". Set them as environment variables (or configure them in Admin → Settings)."
|
||||||
)
|
)
|
||||||
|
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
msg["From"] = from_email
|
msg["From"] = from_email
|
||||||
msg["To"] = to_email
|
msg["To"] = to_email
|
||||||
msg["Subject"] = subject
|
msg["Subject"] = subject
|
||||||
|
# Helps when SMTP providers force the authenticated mailbox as envelope sender,
|
||||||
|
# but still allow replies to go to the desired address.
|
||||||
|
if from_email and username and from_email != username:
|
||||||
|
msg["Reply-To"] = from_email
|
||||||
msg.set_content(body_text)
|
msg.set_content(body_text)
|
||||||
|
|
||||||
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
|
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
|
||||||
@@ -58,4 +103,7 @@ def send_email(*, to_email: str, subject: str, body_text: str):
|
|||||||
smtp.starttls()
|
smtp.starttls()
|
||||||
smtp.ehlo()
|
smtp.ehlo()
|
||||||
smtp.login(username, password)
|
smtp.login(username, password)
|
||||||
smtp.send_message(msg)
|
|
||||||
|
# Pass explicit envelope-from to avoid falling back to the authenticated user.
|
||||||
|
# Note: some SMTP providers will still override this for anti-spoofing.
|
||||||
|
smtp.send_message(msg, from_addr=from_email, to_addrs=[to_email])
|
||||||
|
|||||||
@@ -115,3 +115,38 @@ class DisplaySession(db.Model):
|
|||||||
display = db.relationship("Display")
|
display = db.relationship("Display")
|
||||||
|
|
||||||
__table_args__ = (db.UniqueConstraint("display_id", "sid", name="uq_display_session_display_sid"),)
|
__table_args__ = (db.UniqueConstraint("display_id", "sid", name="uq_display_session_display_sid"),)
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettings(db.Model):
|
||||||
|
"""Singleton-ish app-wide settings.
|
||||||
|
|
||||||
|
For this small project we avoid Alembic migrations; this table can be created via
|
||||||
|
`flask init-db` (db.create_all) and is also created best-effort on app startup.
|
||||||
|
|
||||||
|
NOTE: SMTP password is stored in plaintext in the database.
|
||||||
|
Prefer environment variables / secrets management in production when possible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
||||||
|
smtp_host = db.Column(db.String(255), nullable=True)
|
||||||
|
smtp_port = db.Column(db.Integer, nullable=True)
|
||||||
|
smtp_username = db.Column(db.String(255), nullable=True)
|
||||||
|
smtp_password = db.Column(db.String(255), nullable=True)
|
||||||
|
smtp_from = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
|
smtp_starttls = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
smtp_timeout_seconds = db.Column(db.Float, default=10.0, nullable=False)
|
||||||
|
smtp_debug = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
# Public domain for generating absolute links in emails.
|
||||||
|
# Example: "signage.example.com" (no scheme)
|
||||||
|
public_domain = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
updated_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
default=datetime.utcnow,
|
||||||
|
onupdate=datetime.utcnow,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from flask_login import current_user, login_required, login_user
|
|||||||
|
|
||||||
from ..extensions import db
|
from ..extensions import db
|
||||||
from ..uploads import abs_upload_path, ensure_company_upload_dir, get_company_upload_bytes, is_valid_upload_relpath
|
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")
|
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||||
|
|
||||||
@@ -17,6 +18,18 @@ def admin_required():
|
|||||||
abort(403)
|
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):
|
def _try_delete_upload(file_path: str | None, upload_folder: str):
|
||||||
"""Best-effort delete of an uploaded media file."""
|
"""Best-effort delete of an uploaded media file."""
|
||||||
if not file_path:
|
if not file_path:
|
||||||
@@ -43,6 +56,188 @@ def dashboard():
|
|||||||
return render_template("admin/dashboard.html", companies=companies)
|
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")
|
@bp.post("/companies")
|
||||||
@login_required
|
@login_required
|
||||||
def create_company():
|
def create_company():
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
|||||||
|
|
||||||
from ..extensions import db
|
from ..extensions import db
|
||||||
from ..email_utils import send_email
|
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
|
from ..auth_tokens import load_password_reset_user_id, make_password_reset_token
|
||||||
|
|
||||||
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||||
@@ -48,7 +48,18 @@ def forgot_password_post():
|
|||||||
user = User.query.filter_by(email=email).first()
|
user = User.query.filter_by(email=email).first()
|
||||||
if user:
|
if user:
|
||||||
token = _make_reset_token(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 = (
|
body = (
|
||||||
"Someone requested a password reset for your account.\n\n"
|
"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"
|
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,
|
get_company_upload_bytes,
|
||||||
is_valid_upload_relpath,
|
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 ..email_utils import send_email
|
||||||
from ..auth_tokens import make_password_reset_token
|
from ..auth_tokens import make_password_reset_token
|
||||||
|
|
||||||
@@ -289,7 +289,13 @@ def invite_user():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
token = make_password_reset_token(secret_key=current_app.config["SECRET_KEY"], user_id=u.id)
|
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 = (
|
body = (
|
||||||
f"You have been invited to {company.name} on Signage.\n\n"
|
f"You have been invited to {company.name} on Signage.\n\n"
|
||||||
"Set your password using this link (valid for 30 minutes):\n"
|
"Set your password using this link (valid for 30 minutes):\n"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<h1 class="page-title">Admin</h1>
|
<h1 class="page-title">Admin</h1>
|
||||||
|
<a class="btn btn-outline-ink" href="{{ url_for('admin.settings') }}">Settings</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
|
|||||||
205
app/templates/admin/settings.html
Normal file
205
app/templates/admin/settings.html
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h1 class="page-title">Admin settings</h1>
|
||||||
|
<a class="btn btn-outline-ink" href="{{ url_for('admin.dashboard') }}">Back</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-4 g-3">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card card-elevated">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">SMTP settings</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="{{ url_for('admin.update_smtp_settings') }}" class="vstack gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Host</label>
|
||||||
|
<input class="form-control" name="smtp_host" value="{{ settings.smtp_host or '' }}" placeholder="smtp.example.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Port</label>
|
||||||
|
<input class="form-control" name="smtp_port" value="{{ settings.smtp_port or '' }}" placeholder="587" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label">From address (optional)</label>
|
||||||
|
<input class="form-control" name="smtp_from" value="{{ settings.smtp_from or '' }}" placeholder="no-reply@example.com" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
<input class="form-control" name="smtp_username" value="{{ settings.smtp_username or '' }}" placeholder="user@example.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
name="smtp_password"
|
||||||
|
type="password"
|
||||||
|
value=""
|
||||||
|
placeholder="Leave empty to keep current password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<div class="form-text">
|
||||||
|
For security, the current password is never shown. Leave empty to keep it unchanged.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Timeout (seconds)</label>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
name="smtp_timeout_seconds"
|
||||||
|
value="{{ settings.smtp_timeout_seconds }}"
|
||||||
|
placeholder="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-flex align-items-end">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
name="smtp_starttls"
|
||||||
|
value="1"
|
||||||
|
id="smtp_starttls"
|
||||||
|
{% if settings.smtp_starttls %}checked{% endif %}
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="smtp_starttls">Use STARTTLS</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
name="smtp_debug"
|
||||||
|
value="1"
|
||||||
|
id="smtp_debug"
|
||||||
|
{% if settings.smtp_debug %}checked{% endif %}
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="smtp_debug">Enable SMTP debug logging</label>
|
||||||
|
<div class="form-text">Prints SMTP conversation to the Flask console (useful for troubleshooting).</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button class="btn btn-brand" type="submit">Save SMTP settings</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-elevated mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Send test email</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="{{ url_for('admin.send_test_email') }}" class="vstack gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Recipient email</label>
|
||||||
|
<input class="form-control" name="to_email" placeholder="you@example.com" required />
|
||||||
|
<div class="form-text">Sends a short test email using the current SMTP configuration.</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button class="btn btn-outline-ink" type="submit">Send test email</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-elevated mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Public domain</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="{{ url_for('admin.update_public_domain') }}" class="vstack gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Domain used in emails</label>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
name="public_domain"
|
||||||
|
value="{{ settings.public_domain or '' }}"
|
||||||
|
placeholder="signage.example.com"
|
||||||
|
/>
|
||||||
|
<div class="form-text">
|
||||||
|
Used to generate absolute links (like password reset). Do not include <code>http(s)://</code>.
|
||||||
|
Leave empty to use the current request host.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button class="btn btn-brand" type="submit">Save domain</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card card-elevated">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Admin users</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="{{ url_for('admin.create_admin_user') }}" class="vstack gap-2 mb-3">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<input class="form-control" name="email" placeholder="Email" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<input class="form-control" name="password" type="password" placeholder="Password (min 8 chars)" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button class="btn btn-brand" type="submit">Add admin</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th class="text-end">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in admins %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ u.email }}</strong>
|
||||||
|
{% if u.id == current_user.id %}
|
||||||
|
<span class="badge text-bg-secondary ms-2">You</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
{% if u.id != current_user.id %}
|
||||||
|
<form method="post" action="{{ url_for('admin.demote_admin_user', user_id=u.id) }}" class="d-inline">
|
||||||
|
<button class="btn btn-outline-danger btn-sm" type="submit">Demote</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">(cannot demote yourself)</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="text-muted">No admin users found.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-text mt-2">
|
||||||
|
Safety: the last remaining admin cannot be demoted.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -21,6 +21,7 @@ def main():
|
|||||||
required = {
|
required = {
|
||||||
"/admin/companies/<int:company_id>/delete",
|
"/admin/companies/<int:company_id>/delete",
|
||||||
"/admin/displays/<int:display_id>/name",
|
"/admin/displays/<int:display_id>/name",
|
||||||
|
"/admin/settings",
|
||||||
"/company/displays/<int:display_id>",
|
"/company/displays/<int:display_id>",
|
||||||
"/company/items/<int:item_id>/duration",
|
"/company/items/<int:item_id>/duration",
|
||||||
"/company/playlists/<int:playlist_id>/items/reorder",
|
"/company/playlists/<int:playlist_id>/items/reorder",
|
||||||
|
|||||||
Reference in New Issue
Block a user