Update settings/admin UI and misc fixes

This commit is contained in:
2026-01-23 21:21:56 +01:00
parent 7f0092ff10
commit 97e17854b9
12 changed files with 567 additions and 25 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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()

View File

@@ -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")

View File

@@ -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])

View File

@@ -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,
)

View File

@@ -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():

View File

@@ -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,6 +48,17 @@ 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)
# 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) 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"

View File

@@ -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,6 +289,12 @@ 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)
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) 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"

View File

@@ -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">

View 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 %}

View File

@@ -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",