This commit is contained in:
jpmvaz
2026-09-13 19:59:54 +01:00
commit d86f1ba099
46 changed files with 4138 additions and 0 deletions
+278
View File
@@ -0,0 +1,278 @@
"""Application factory.
Builds a single unified Flask app served on ONE port:
- frontoffice at the root: / (View) and /alerts (Add/Edit/Remove)
- backoffice under /backoffice: users, mail config, settings, audit log,
mail log — all restricted to admins.
Everything shares the same database, models, and login system. The ``variant``
argument is retained for backwards compatibility with existing WSGI entry
points but no longer changes what is registered — both areas are always
available on the same app.
"""
import os
import logging
from flask import Flask, redirect, url_for, render_template, request, session
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
from sqlalchemy import inspect, text
from sqlalchemy.exc import IntegrityError, OperationalError
from .config import Config
from .models import db, User, AppSetting
from .scheduler import init_scheduler
APP_VERSION = "1.5"
login_manager = LoginManager()
csrf = CSRFProtect()
def _setup_logging():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
def _run_migrations(app: Flask):
"""Lightweight, additive schema migrations so an existing DB can be
upgraded in place (unzip-over-the-top) without any data loss.
Only ADDs missing tables/columns — never drops or alters existing data.
Every step is idempotent and tolerant of a concurrent process having
already applied it (both gunicorn apps boot against the same file).
"""
with app.app_context():
# 1. Create any brand-new tables (e.g. mail_log) and missing tables.
try:
db.create_all()
except OperationalError as e:
# Another booting process created a table at the same moment.
app.logger.info("create_all raced (ignored): %s", e)
db.session.rollback()
# 2. Add any missing columns on the users table.
inspector = inspect(db.engine)
existing_cols = {c["name"] for c in inspector.get_columns("users")}
add_column = {
"avatar_data": "ALTER TABLE users ADD COLUMN avatar_data BLOB",
"avatar_mime": "ALTER TABLE users ADD COLUMN avatar_mime VARCHAR(64)",
}
for col, ddl in add_column.items():
if col in existing_cols:
continue
try:
with db.engine.begin() as conn:
conn.execute(text(ddl))
app.logger.info("Migration: added users.%s", col)
except OperationalError as e:
# "duplicate column name" — another process won the race. Fine.
app.logger.info("Add column %s raced (ignored): %s", col, e)
def _seed_settings(app: Flask):
"""Seed default settings (mail config, IMAP, reminders) when rows are
missing. Never overwrites values an admin has already set — so upgrades
keep existing configuration. New keys (e.g. IMAP) are seeded on upgrade.
Uses an INSERT that tolerates a concurrent process seeding the same key.
"""
with app.app_context():
defaults = {
# ---- SMTP (outgoing) — Purelymail defaults ----
"smtp_host": app.config.get("SMTP_HOST", "smtp.purelymail.com"),
"smtp_port": str(app.config.get("SMTP_PORT", 465)),
"smtp_username": app.config.get("SMTP_USERNAME", ""),
"smtp_password": app.config.get("SMTP_PASSWORD", ""),
"smtp_encryption": app.config.get("SMTP_ENCRYPTION", "ssl"),
"smtp_from_address": app.config.get("SMTP_FROM_ADDRESS", ""),
"smtp_from_name": app.config.get("SMTP_FROM_NAME", "AlertHub"),
# ---- IMAP (Sent-folder copy) — Purelymail defaults ----
"imap_enabled": "true",
"imap_host": app.config.get("IMAP_HOST", "imap.purelymail.com"),
"imap_port": str(app.config.get("IMAP_PORT", 993)),
"imap_username": app.config.get("IMAP_USERNAME", ""),
"imap_password": app.config.get("IMAP_PASSWORD", ""),
"imap_sent_folder": app.config.get("IMAP_SENT_FOLDER", "Sent"),
# ---- Reminders ----
"default_reminder_days": app.config.get("DEFAULT_REMINDER_DAYS", "30,14,7,1"),
}
for k, v in defaults.items():
if AppSetting.get(k) is not None:
continue
try:
row = AppSetting(key=k, value=v)
db.session.add(row)
db.session.commit()
except IntegrityError:
# A concurrent boot inserted this key first — that's fine.
db.session.rollback()
def _initialise_database(app: Flask):
"""Run migrations + seeding exactly once at a time across processes.
The container starts several worker processes that all import the app and
hit the shared SQLite file simultaneously. A cross-process file lock
serialises initialisation so concurrent DDL/seeding can't collide.
"""
import fcntl # POSIX-only; the container is Linux.
db_dir = os.path.dirname(app.config["DATABASE_PATH"]) or "."
os.makedirs(db_dir, exist_ok=True)
lock_path = os.path.join(db_dir, ".alerthub-init.lock")
try:
lock_file = open(lock_path, "w")
except OSError:
# If we somehow can't open the lock file, fall back to unlocked init;
# the per-step tolerance above still guards against most races.
_run_migrations(app)
_seed_settings(app)
return
with lock_file:
try:
fcntl.flock(lock_file, fcntl.LOCK_EX)
except OSError:
pass # advisory lock unavailable — proceed; steps are tolerant.
try:
_run_migrations(app)
_seed_settings(app)
finally:
try:
fcntl.flock(lock_file, fcntl.LOCK_UN)
except OSError:
pass
def create_app(variant: str = "frontoffice") -> Flask:
"""Build the unified Flask app (frontoffice + /backoffice on one port).
``variant`` is accepted for backwards compatibility with the existing WSGI
entry points but no longer restricts which blueprints are registered.
"""
if variant not in ("frontoffice", "backoffice"):
variant = "frontoffice"
_setup_logging()
app = Flask(
__name__,
template_folder="templates",
static_folder="static",
)
app.config.from_object(Config)
app.config["APP_VARIANT"] = variant
# Make sure data dir exists
os.makedirs(os.path.dirname(app.config["DATABASE_PATH"]), exist_ok=True)
db.init_app(app)
csrf.init_app(app)
login_manager.init_app(app)
login_manager.login_view = "auth.login"
login_manager.login_message = "Please sign in to continue."
login_manager.login_message_category = "info"
@login_manager.user_loader
def load_user(user_id):
try:
return db.session.get(User, int(user_id))
except (TypeError, ValueError):
return None
# ---- Blueprints ----
# Unified app: the frontoffice lives at the root and the backoffice is
# mounted under /backoffice, so both are served on the SAME port. Access to
# backoffice views is still restricted to admins by @admin_required.
from .routes_auth import auth_bp
from .routes_main import main_bp
from .routes_backoffice import backoffice_bp
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
app.register_blueprint(backoffice_bp, url_prefix="/backoffice")
# ---- Error handlers ----
@app.errorhandler(403)
def forbidden(_):
return render_template("error.html", code=403, message="You don't have permission to view this page."), 403
@app.errorhandler(404)
def not_found(_):
return render_template("error.html", code=404, message="Page not found."), 404
@app.errorhandler(500)
def server_error(_):
return render_template("error.html", code=500, message="Internal server error."), 500
# ---- Security headers ----
@app.after_request
def add_security_headers(response):
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
# Reasonable CSP for our self-hosted assets + the QR data URI
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; "
"style-src 'self' 'unsafe-inline'; script-src 'self'; "
"font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
)
return response
# ---- Template context: brand + which area we're in ----
@app.context_processor
def inject_globals():
# The backoffice is now mounted under /backoffice on the same origin,
# so cross-links are simple same-site URLs and "is_backoffice" is
# determined by which blueprint served the current request.
is_bo = (request.blueprint == "backoffice") if request else False
try:
backoffice_url = url_for("backoffice.dashboard")
frontoffice_url = url_for("main.index")
except Exception:
backoffice_url = "/backoffice/"
frontoffice_url = "/"
return {
"app_variant": "backoffice" if is_bo else "frontoffice",
"brand_name": "AlertHub",
"is_backoffice": is_bo,
"app_version": APP_VERSION,
"footer_text": "© 2026 Martinhal IT - Joao Vaz - Version " + APP_VERSION,
"backoffice_url": backoffice_url,
"frontoffice_url": frontoffice_url,
}
# ---- Initialise DB (additive migrations) + seed default settings.
# Serialised across the container's worker processes via a file lock. ----
_initialise_database(app)
# ---- First-run guard: if there are no users yet, force the operator
# through the "create administrator" setup screen before anything else. ----
app._alerthub_has_users = False
@app.before_request
def _require_initial_setup():
if app._alerthub_has_users:
return None
# Allow static assets and the setup endpoint itself through.
if request.endpoint in ("static", "auth.setup"):
return None
try:
if User.query.count() > 0:
app._alerthub_has_users = True
return None
except Exception:
return None
return redirect(url_for("auth.setup"))
# ---- Scheduler. init_scheduler() is idempotent (guarded by a module-level
# singleton), and the container runs a single Gunicorn worker, so exactly
# one reminder scheduler runs regardless of how many times this is called. ----
init_scheduler(app)
return app
+37
View File
@@ -0,0 +1,37 @@
"""Auth helpers and decorators."""
from functools import wraps
from flask import abort, redirect, url_for, session, flash, request
from flask_login import current_user
def admin_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for("auth.login", next=request.full_path))
if not current_user.is_admin:
abort(403)
if session.get("mfa_pending"):
return redirect(url_for("auth.mfa_challenge"))
return view(*args, **kwargs)
return wrapped
def login_required_full(view):
"""Like flask_login.login_required, but also blocks users with pending MFA."""
@wraps(view)
def wrapped(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for("auth.login", next=request.full_path))
if session.get("mfa_pending"):
return redirect(url_for("auth.mfa_challenge"))
return view(*args, **kwargs)
return wrapped
def get_remote_ip() -> str:
"""Best-effort IP detection for audit logs."""
fwd = request.headers.get("X-Forwarded-For", "")
if fwd:
return fwd.split(",")[0].strip()
return request.remote_addr or ""
+81
View File
@@ -0,0 +1,81 @@
"""Configuration loaded from environment variables."""
import os
from datetime import timedelta
def _bool(val: str, default: bool = False) -> bool:
if val is None:
return default
return val.strip().lower() in ("1", "true", "yes", "on")
class Config:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-replace-me")
FLASK_ENV = os.environ.get("FLASK_ENV", "production")
DATABASE_PATH = os.environ.get("DATABASE_PATH", "/data/alerthub.db")
SQLALCHEMY_DATABASE_URI = f"sqlite:///{DATABASE_PATH}"
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = {
"pool_pre_ping": True,
# Wait (instead of erroring) when another process holds the SQLite
# write lock — both gunicorn apps share one database file.
"connect_args": {"timeout": 30},
}
# Bootstrap admin (only used on first DB init)
INITIAL_ADMIN_USERNAME = os.environ.get("INITIAL_ADMIN_USERNAME", "admin")
INITIAL_ADMIN_EMAIL = os.environ.get("INITIAL_ADMIN_EMAIL", "admin@example.com")
INITIAL_ADMIN_PASSWORD = os.environ.get("INITIAL_ADMIN_PASSWORD", "ChangeMeOnFirstLogin!")
# Session security
SESSION_COOKIE_SECURE = _bool(os.environ.get("SESSION_COOKIE_SECURE"), False)
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
PERMANENT_SESSION_LIFETIME = timedelta(
hours=int(os.environ.get("SESSION_LIFETIME_HOURS", "8"))
)
PASSWORD_MIN_LENGTH = int(os.environ.get("PASSWORD_MIN_LENGTH", "10"))
# MFA
MFA_ISSUER_NAME = os.environ.get("MFA_ISSUER_NAME", "AlertHub")
# Scheduler
SCHEDULER_INTERVAL_MINUTES = int(os.environ.get("SCHEDULER_INTERVAL_MINUTES", "60"))
# Default reminder lead times in days
DEFAULT_REMINDER_DAYS = os.environ.get("DEFAULT_REMINDER_DAYS", "30,14,7,1")
# SMTP defaults (seed only — admins edit live values via backoffice)
# Purelymail outgoing: smtp.purelymail.com : 465 (SSL/TLS)
SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.purelymail.com")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
SMTP_USERNAME = os.environ.get("SMTP_USERNAME", "")
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
SMTP_ENCRYPTION = os.environ.get("SMTP_ENCRYPTION", "ssl")
SMTP_FROM_ADDRESS = os.environ.get("SMTP_FROM_ADDRESS", "")
SMTP_FROM_NAME = os.environ.get("SMTP_FROM_NAME", "AlertHub")
# IMAP defaults (seed only) — used to save a copy of sent mail to the
# mailbox's Sent folder. Purelymail incoming: imap.purelymail.com : 993 (SSL/TLS)
IMAP_HOST = os.environ.get("IMAP_HOST", "imap.purelymail.com")
IMAP_PORT = int(os.environ.get("IMAP_PORT", "993"))
IMAP_USERNAME = os.environ.get("IMAP_USERNAME", "")
IMAP_PASSWORD = os.environ.get("IMAP_PASSWORD", "")
IMAP_SENT_FOLDER = os.environ.get("IMAP_SENT_FOLDER", "Sent")
# Ports (used by run scripts only)
FRONTOFFICE_PORT = int(os.environ.get("FRONTOFFICE_PORT", "8080"))
BACKOFFICE_PORT = int(os.environ.get("BACKOFFICE_PORT", "9001"))
# Optional explicit public URLs for the cross-console links in the top bar.
# If empty, the app derives them from the request host + the ports above
# (e.g. http://<your-host>:9001/). Set these when behind a reverse proxy
# or when the consoles are reached at custom hostnames/paths.
BACKOFFICE_PUBLIC_URL = os.environ.get("BACKOFFICE_PUBLIC_URL", "")
FRONTOFFICE_PUBLIC_URL = os.environ.get("FRONTOFFICE_PUBLIC_URL", "")
# Resource limits
MAX_CONTENT_LENGTH = 2 * 1024 * 1024 # 2MB upload cap
WTF_CSRF_TIME_LIMIT = 3600
+139
View File
@@ -0,0 +1,139 @@
"""WTForms — all forms get CSRF protection automatically via Flask-WTF."""
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, FileRequired
from wtforms import StringField, PasswordField, BooleanField, IntegerField, SelectField, DateField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo, Optional, NumberRange, Regexp
class LoginForm(FlaskForm):
username = StringField("Username", validators=[DataRequired(), Length(min=2, max=64)])
password = PasswordField("Password", validators=[DataRequired(), Length(min=1, max=200)])
submit = SubmitField("Sign in")
class SetupAdminForm(FlaskForm):
"""First-run form: create the very first administrator account."""
username = StringField("Admin username", validators=[
DataRequired(),
Length(min=2, max=64),
Regexp(r"^[A-Za-z0-9_.\-]+$", message="Letters, digits, underscore, dot, dash only."),
])
email = StringField("Admin email", validators=[DataRequired(), Email(), Length(max=255)])
password = PasswordField("Password", validators=[
DataRequired(),
Length(min=10, max=200, message="Min 10 characters"),
])
confirm = PasswordField("Confirm password", validators=[
DataRequired(),
EqualTo("password", message="Passwords must match."),
])
submit = SubmitField("Create administrator")
class AvatarForm(FlaskForm):
avatar = FileField("Avatar image", validators=[
FileRequired(message="Choose an image file."),
FileAllowed(["png", "jpg", "jpeg", "gif", "webp"], "Images only (png, jpg, gif, webp)."),
])
submit = SubmitField("Upload avatar")
class MfaForm(FlaskForm):
code = StringField("Authentication code", validators=[
DataRequired(),
Length(min=6, max=6, message="Must be 6 digits"),
Regexp(r"^\d{6}$", message="Digits only"),
])
submit = SubmitField("Verify")
class MfaSetupForm(FlaskForm):
code = StringField("Verification code", validators=[
DataRequired(),
Length(min=6, max=6),
Regexp(r"^\d{6}$"),
])
submit = SubmitField("Enable MFA")
class ChangePasswordForm(FlaskForm):
current_password = PasswordField("Current password", validators=[DataRequired()])
new_password = PasswordField("New password", validators=[
DataRequired(),
Length(min=10, max=200, message="Min 10 characters"),
])
confirm = PasswordField("Confirm new password", validators=[
DataRequired(),
EqualTo("new_password", message="Passwords must match."),
])
submit = SubmitField("Change password")
class AlertForm(FlaskForm):
title = StringField("Title", validators=[DataRequired(), Length(max=200)])
category = StringField("Category", validators=[Optional(), Length(max=64)])
description = TextAreaField("Description", validators=[Optional(), Length(max=5000)])
expiration_date = DateField("Expiration date", validators=[DataRequired()], format="%Y-%m-%d")
reminder_days = StringField(
"Reminder days (comma-separated, days before expiration)",
validators=[DataRequired(), Regexp(r"^[\d,\s]+$", message="Numbers separated by commas only")],
default="30,14,7,1",
)
is_active = BooleanField("Active", default=True)
submit = SubmitField("Save")
class UserForm(FlaskForm):
username = StringField("Username", validators=[
DataRequired(),
Length(min=2, max=64),
Regexp(r"^[A-Za-z0-9_.\-]+$", message="Letters, digits, underscore, dot, dash only."),
])
email = StringField("Email", validators=[DataRequired(), Email(), Length(max=255)])
role = SelectField("Role", choices=[("user", "User (view only)"), ("admin", "Administrator")])
is_active = BooleanField("Active", default=True)
password = PasswordField("Password (leave blank to keep current)", validators=[Optional(), Length(min=10, max=200)])
submit = SubmitField("Save")
class MailConfigForm(FlaskForm):
smtp_host = StringField("SMTP Host", validators=[DataRequired(), Length(max=255)])
smtp_port = IntegerField("Port", validators=[DataRequired(), NumberRange(min=1, max=65535)])
smtp_username = StringField("Username", validators=[Optional(), Length(max=255)])
smtp_password = PasswordField("Password (leave blank to keep current)", validators=[Optional(), Length(max=255)])
smtp_encryption = SelectField("Encryption", choices=[
("none", "None (plain — not recommended)"),
("starttls", "STARTTLS (port 587)"),
("ssl", "SSL/TLS (port 465)"),
])
smtp_from_address = StringField("From address", validators=[DataRequired(), Email(), Length(max=255)])
smtp_from_name = StringField("From name", validators=[Optional(), Length(max=255)])
# ---- IMAP: used to save a copy of every sent message to the Sent folder ----
imap_enabled = BooleanField("Save a copy of sent mail to the mailbox (IMAP)", default=True)
imap_host = StringField("IMAP Host", validators=[Optional(), Length(max=255)])
imap_port = IntegerField("IMAP Port", validators=[Optional(), NumberRange(min=1, max=65535)])
imap_username = StringField("IMAP Username (blank = use SMTP username)", validators=[Optional(), Length(max=255)])
imap_password = PasswordField("IMAP Password (blank = keep current / use SMTP password)", validators=[Optional(), Length(max=255)])
imap_sent_folder = StringField("Sent folder name", validators=[Optional(), Length(max=128)])
submit = SubmitField("Save mail configuration")
class TestMailForm(FlaskForm):
test_recipient = StringField("Send test email to", validators=[DataRequired(), Email()])
submit = SubmitField("Send test email")
class LogEmailForm(FlaskForm):
"""Email an exported log (CSV attachment) to a destination address."""
log_recipient = StringField("Email log to", validators=[DataRequired(), Email()])
submit = SubmitField("Email log")
class GlobalSettingsForm(FlaskForm):
default_reminder_days = StringField(
"Default reminder days (comma-separated)",
validators=[DataRequired(), Regexp(r"^[\d,\s]+$")],
)
submit = SubmitField("Save settings")
+161
View File
@@ -0,0 +1,161 @@
"""SMTP mailer that pulls live config from the AppSetting store.
Every send is recorded in the MailLog (visible in the backoffice "Mail Log"),
and — when IMAP is configured — a copy of the outgoing message is appended to
the external mailbox's Sent folder so it shows up in normal mail clients.
"""
import smtplib
import imaplib
import ssl
import time
import logging
from email.message import EmailMessage
from email.utils import formataddr, formatdate
from flask_login import current_user
from .models import AppSetting, MailLog
logger = logging.getLogger(__name__)
def get_mail_config() -> dict:
"""Read SMTP + IMAP settings from the database."""
return {
# ---- SMTP (outgoing) ----
"host": AppSetting.get("smtp_host", "") or "",
"port": int(AppSetting.get("smtp_port", "465") or "465"),
"username": AppSetting.get("smtp_username", "") or "",
"password": AppSetting.get("smtp_password", "") or "",
"encryption": (AppSetting.get("smtp_encryption", "ssl") or "ssl").lower(),
"from_address": AppSetting.get("smtp_from_address", "") or "",
"from_name": AppSetting.get("smtp_from_name", "AlertHub") or "AlertHub",
# ---- IMAP (for saving a copy in the Sent folder) ----
"imap_enabled": (AppSetting.get("imap_enabled", "true") or "true").lower() in ("1", "true", "yes", "on"),
"imap_host": AppSetting.get("imap_host", "") or "",
"imap_port": int(AppSetting.get("imap_port", "993") or "993"),
"imap_username": AppSetting.get("imap_username", "") or "",
"imap_password": AppSetting.get("imap_password", "") or "",
"imap_sent_folder": AppSetting.get("imap_sent_folder", "Sent") or "Sent",
}
def _build_message(cfg, to_addresses, subject, body_text, body_html=None, attachments=None) -> EmailMessage:
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = formataddr((cfg["from_name"], cfg["from_address"]))
msg["To"] = ", ".join(to_addresses)
msg["Date"] = formatdate(localtime=True)
msg.set_content(body_text)
if body_html:
msg.add_alternative(body_html, subtype="html")
for att in (attachments or []):
# att = (filename, mime_maintype, mime_subtype, bytes)
fname, maintype, subtype, data = att
msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=fname)
return msg
def _append_to_sent(cfg, msg) -> tuple[bool, str]:
"""Append a copy of the message to the mailbox's Sent folder over IMAP."""
if not cfg.get("imap_enabled"):
return False, "disabled"
imap_host = cfg["imap_host"] or ""
imap_user = cfg["imap_username"] or cfg["username"]
imap_pass = cfg["imap_password"] or cfg["password"]
if not imap_host or not imap_user or not imap_pass:
return False, "IMAP not fully configured"
folder = cfg.get("imap_sent_folder") or "Sent"
try:
context = ssl.create_default_context()
with imaplib.IMAP4_SSL(imap_host, cfg.get("imap_port", 993), ssl_context=context, timeout=20) as imap:
imap.login(imap_user, imap_pass)
raw = msg.as_bytes()
# \Seen so it doesn't show as unread in the Sent folder
typ, _ = imap.append(folder, "(\\Seen)", imaplib.Time2Internaldate(time.time()), raw)
if typ != "OK":
return False, f"IMAP append returned {typ}"
return True, "saved"
except (imaplib.IMAP4.error, ssl.SSLError, OSError) as e:
logger.exception("IMAP append to Sent failed")
return False, f"IMAP error: {e}"
def _smtp_send(cfg, msg) -> tuple[bool, str]:
encryption = cfg["encryption"]
try:
if encryption == "ssl":
context = ssl.create_default_context()
with smtplib.SMTP_SSL(cfg["host"], cfg["port"], context=context, timeout=20) as server:
if cfg["username"]:
server.login(cfg["username"], cfg["password"])
server.send_message(msg)
else:
with smtplib.SMTP(cfg["host"], cfg["port"], timeout=20) as server:
server.ehlo()
if encryption == "starttls":
context = ssl.create_default_context()
server.starttls(context=context)
server.ehlo()
if cfg["username"]:
server.login(cfg["username"], cfg["password"])
server.send_message(msg)
return True, "ok"
except smtplib.SMTPAuthenticationError as e:
logger.exception("SMTP auth failed")
err = e.smtp_error.decode("utf-8", errors="ignore") if isinstance(e.smtp_error, bytes) else e.smtp_error
return False, f"Authentication failed: {e.smtp_code} {err}"
except (smtplib.SMTPException, ssl.SSLError, OSError) as e:
logger.exception("SMTP error")
return False, f"SMTP error: {e}"
def send_mail(to_addresses, subject: str, body_text: str, body_html: str | None = None,
attachments=None, kind: str = "general", triggered_by: str | None = None) -> tuple[bool, str]:
"""Send an email. Returns (success, message). Never raises.
Side effects:
- Appends a copy to the external mailbox Sent folder (IMAP) on success.
- Records the send (and Sent-copy result) in the MailLog.
"""
cfg = get_mail_config()
# Resolve who triggered this (username) for the log.
if triggered_by is None:
try:
triggered_by = current_user.username if current_user.is_authenticated else "system"
except Exception:
triggered_by = "system"
if not cfg["host"] or not cfg["from_address"]:
info = "SMTP not configured (host or from-address missing)."
MailLog.record(kind, to_addresses, subject, "failed", info, "n/a", triggered_by)
return False, info
if isinstance(to_addresses, str):
to_addresses = [to_addresses]
to_addresses = [a for a in to_addresses if a]
if not to_addresses:
info = "No recipients."
MailLog.record(kind, "", subject, "failed", info, "n/a", triggered_by)
return False, info
msg = _build_message(cfg, to_addresses, subject, body_text, body_html, attachments)
ok, info = _smtp_send(cfg, msg)
sent_copy = "n/a"
if ok:
copied, copy_info = _append_to_sent(cfg, msg)
if copy_info == "disabled":
sent_copy = "disabled"
elif copied:
sent_copy = "saved"
else:
sent_copy = "failed"
info = f"{info}; Sent-copy: {copy_info}"
MailLog.record(kind, to_addresses, subject,
"sent" if ok else "failed", info, sent_copy, triggered_by)
if ok:
return True, f"Sent to {len(to_addresses)} recipient(s)."
return False, info
+192
View File
@@ -0,0 +1,192 @@
"""SQLAlchemy models."""
from datetime import datetime, timezone
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
import bcrypt
import json
db = SQLAlchemy()
def utcnow():
return datetime.now(timezone.utc)
class User(UserMixin, db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False, index=True)
email = db.Column(db.String(255), unique=True, nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(16), nullable=False, default="user") # 'user' | 'admin'
is_active = db.Column(db.Boolean, default=True, nullable=False)
mfa_secret = db.Column(db.String(64), nullable=True)
mfa_enabled = db.Column(db.Boolean, default=False, nullable=False)
# Optional user avatar (stored as normalised PNG bytes in the DB so it
# survives an "unzip over the top" upgrade without any external files).
avatar_data = db.Column(db.LargeBinary, nullable=True)
avatar_mime = db.Column(db.String(64), nullable=True)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
last_login_at = db.Column(db.DateTime, nullable=True)
failed_login_count = db.Column(db.Integer, default=0, nullable=False)
locked_until = db.Column(db.DateTime, nullable=True)
# ---- password helpers ----
def set_password(self, password: str) -> None:
self.password_hash = bcrypt.hashpw(
password.encode("utf-8"), bcrypt.gensalt(rounds=12)
).decode("utf-8")
def check_password(self, password: str) -> bool:
try:
return bcrypt.checkpw(
password.encode("utf-8"), self.password_hash.encode("utf-8")
)
except (ValueError, TypeError):
return False
@property
def is_admin(self) -> bool:
return self.role == "admin"
@property
def is_locked(self) -> bool:
return self.locked_until is not None and self.locked_until > utcnow()
@property
def has_avatar(self) -> bool:
return self.avatar_data is not None and len(self.avatar_data) > 0
@property
def initials(self) -> str:
return (self.username or "?")[:2].upper()
class Alert(db.Model):
__tablename__ = "alerts"
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
category = db.Column(db.String(64), nullable=True) # e.g. 'License', 'Renewal'
expiration_date = db.Column(db.Date, nullable=False)
# JSON-encoded list of integer days, e.g. "[30, 14, 7, 1]"
reminder_days_json = db.Column(db.String(255), nullable=False, default="[30,14,7,1]")
# JSON-encoded list of days for which the reminder has already been sent (avoid dupes)
reminders_sent_json = db.Column(db.String(255), nullable=False, default="[]")
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow, nullable=False)
creator = db.relationship("User", foreign_keys=[created_by])
@property
def reminder_days(self) -> list:
try:
return sorted(set(int(x) for x in json.loads(self.reminder_days_json)), reverse=True)
except (ValueError, TypeError):
return []
@reminder_days.setter
def reminder_days(self, value) -> None:
self.reminder_days_json = json.dumps(sorted(set(int(x) for x in value), reverse=True))
@property
def reminders_sent(self) -> list:
try:
return list(json.loads(self.reminders_sent_json))
except (ValueError, TypeError):
return []
@reminders_sent.setter
def reminders_sent(self, value) -> None:
self.reminders_sent_json = json.dumps(list(value))
class AppSetting(db.Model):
"""Generic key/value settings store (mail config + misc)."""
__tablename__ = "app_settings"
key = db.Column(db.String(64), primary_key=True)
value = db.Column(db.Text, nullable=True)
updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow, nullable=False)
@classmethod
def get(cls, key: str, default=None):
row = db.session.get(cls, key)
return row.value if row is not None else default
@classmethod
def set(cls, key: str, value: str) -> None:
row = db.session.get(cls, key)
if row is None:
row = cls(key=key, value=value)
db.session.add(row)
else:
row.value = value
db.session.commit()
@classmethod
def get_all(cls) -> dict:
return {row.key: row.value for row in cls.query.all()}
class AuditLog(db.Model):
__tablename__ = "audit_log"
id = db.Column(db.Integer, primary_key=True)
timestamp = db.Column(db.DateTime, default=utcnow, nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
username = db.Column(db.String(64), nullable=True) # denormalised for survival after delete
action = db.Column(db.String(64), nullable=False)
details = db.Column(db.Text, nullable=True)
ip_address = db.Column(db.String(45), nullable=True)
@staticmethod
def record(action: str, user=None, details: str = "", ip: str = ""):
entry = AuditLog(
action=action,
user_id=getattr(user, "id", None),
username=getattr(user, "username", None),
details=details[:1000] if details else None,
ip_address=ip,
)
db.session.add(entry)
db.session.commit()
class MailLog(db.Model):
"""Record of every outbound email dispatched by the platform."""
__tablename__ = "mail_log"
id = db.Column(db.Integer, primary_key=True)
timestamp = db.Column(db.DateTime, default=utcnow, nullable=False, index=True)
kind = db.Column(db.String(32), nullable=False, default="general") # reminder | test | log-export | general
recipients = db.Column(db.Text, nullable=True)
subject = db.Column(db.String(500), nullable=True)
status = db.Column(db.String(16), nullable=False, default="unknown") # sent | failed
info = db.Column(db.Text, nullable=True)
sent_copy = db.Column(db.String(24), nullable=False, default="n/a") # saved | failed | disabled | n/a
triggered_by = db.Column(db.String(64), nullable=True) # username or "system"
@staticmethod
def record(kind, recipients, subject, status, info="", sent_copy="n/a", triggered_by="system"):
if isinstance(recipients, (list, tuple)):
recipients = ", ".join(str(r) for r in recipients)
entry = MailLog(
kind=(kind or "general")[:32],
recipients=(recipients or "")[:2000],
subject=(subject or "")[:500],
status=(status or "unknown")[:16],
info=(info or "")[:2000],
sent_copy=(sent_copy or "n/a")[:24],
triggered_by=(triggered_by or "system")[:64],
)
db.session.add(entry)
db.session.commit()
return entry
+308
View File
@@ -0,0 +1,308 @@
"""Authentication routes: first-run setup, login, MFA, logout, password, avatar."""
import io
import base64
from datetime import timedelta
import pyotp
import qrcode
from PIL import Image, ImageOps
from flask import (
Blueprint, render_template, redirect, url_for, flash, request, session,
current_app, abort, Response,
)
from flask_login import login_user, logout_user, login_required, current_user
from .models import db, User, AuditLog, utcnow
from .forms import LoginForm, MfaForm, MfaSetupForm, ChangePasswordForm, SetupAdminForm, AvatarForm
from .auth_utils import get_remote_ip, login_required_full
auth_bp = Blueprint("auth", __name__)
AVATAR_SIZE = 256
def _normalise_avatar(file_storage) -> bytes:
"""Open an uploaded image, square-crop + resize, return PNG bytes.
Raises ValueError if the file is not a usable image.
"""
try:
img = Image.open(file_storage.stream)
img = ImageOps.exif_transpose(img)
img = img.convert("RGBA") if img.mode in ("RGBA", "LA", "P") else img.convert("RGB")
# Center-crop to a square, then resize.
img = ImageOps.fit(img, (AVATAR_SIZE, AVATAR_SIZE), method=Image.LANCZOS)
if img.mode == "RGBA":
background = Image.new("RGBA", img.size, (255, 255, 255, 0))
img = Image.alpha_composite(background, img)
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
return buf.getvalue()
except Exception as exc: # noqa: BLE001 - surface a clean error to the user
raise ValueError(f"Could not process image: {exc}")
# --------------------------------------------------------------------------- #
# First-run setup — create the initial administrator
# --------------------------------------------------------------------------- #
@auth_bp.route("/setup", methods=["GET", "POST"])
def setup():
# Only available while there are zero users. Once an admin exists, hide it.
if User.query.count() > 0:
return redirect(url_for("auth.login"))
form = SetupAdminForm()
if form.validate_on_submit():
admin = User(
username=form.username.data.strip(),
email=form.email.data.strip().lower(),
role="admin",
is_active=True,
)
admin.set_password(form.password.data)
db.session.add(admin)
db.session.commit()
current_app._alerthub_has_users = True
AuditLog.record("setup.admin_created", user=admin, details=f"username={admin.username}", ip=get_remote_ip())
flash("Administrator account created. Please sign in.", "success")
return redirect(url_for("auth.login"))
return render_template("setup.html", form=form)
# --------------------------------------------------------------------------- #
# Login
# --------------------------------------------------------------------------- #
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated and not session.get("mfa_pending"):
return redirect(url_for("main.index"))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data.strip()).first()
ip = get_remote_ip()
if user is None or not user.check_password(form.password.data):
# Increment failed login if user exists
if user is not None:
user.failed_login_count = (user.failed_login_count or 0) + 1
if user.failed_login_count >= 5:
user.locked_until = utcnow() + timedelta(minutes=15)
user.failed_login_count = 0
AuditLog.record("login.lockout", user=user, details=f"5 failed attempts", ip=ip)
db.session.commit()
AuditLog.record("login.fail", details=f"username={form.username.data}", ip=ip)
flash("Invalid credentials.", "error")
return render_template("login.html", form=form), 401
if not user.is_active:
flash("Account is disabled.", "error")
AuditLog.record("login.disabled", user=user, ip=ip)
return render_template("login.html", form=form), 403
if user.is_locked:
flash("Account is temporarily locked due to failed login attempts. Try again later.", "error")
AuditLog.record("login.locked", user=user, ip=ip)
return render_template("login.html", form=form), 423
# Password OK — reset counters
user.failed_login_count = 0
user.locked_until = None
if user.mfa_enabled:
# Stage 1 success — but require MFA before granting full session
session["mfa_pending"] = True
session["pending_user_id"] = user.id
db.session.commit()
AuditLog.record("login.password_ok", user=user, ip=ip)
return redirect(url_for("auth.mfa_challenge"))
# No MFA — full login
login_user(user, remember=False)
session.permanent = True
session.pop("mfa_pending", None)
session.pop("pending_user_id", None)
user.last_login_at = utcnow()
db.session.commit()
AuditLog.record("login.success", user=user, ip=ip)
next_url = request.args.get("next") or url_for("main.index")
return redirect(next_url)
return render_template("login.html", form=form)
# --------------------------------------------------------------------------- #
# MFA challenge (after password OK)
# --------------------------------------------------------------------------- #
@auth_bp.route("/mfa", methods=["GET", "POST"])
def mfa_challenge():
if not session.get("mfa_pending"):
return redirect(url_for("auth.login"))
user_id = session.get("pending_user_id")
user = db.session.get(User, user_id) if user_id else None
if user is None or not user.mfa_enabled or not user.mfa_secret:
session.pop("mfa_pending", None)
session.pop("pending_user_id", None)
return redirect(url_for("auth.login"))
form = MfaForm()
if form.validate_on_submit():
totp = pyotp.TOTP(user.mfa_secret)
if totp.verify(form.code.data, valid_window=1):
login_user(user, remember=False)
session.permanent = True
session.pop("mfa_pending", None)
session.pop("pending_user_id", None)
user.last_login_at = utcnow()
db.session.commit()
AuditLog.record("login.mfa_ok", user=user, ip=get_remote_ip())
return redirect(url_for("main.index"))
flash("Invalid authentication code.", "error")
AuditLog.record("login.mfa_fail", user=user, ip=get_remote_ip())
return render_template("mfa.html", form=form)
# --------------------------------------------------------------------------- #
# Logout
# --------------------------------------------------------------------------- #
@auth_bp.route("/logout", methods=["POST"])
@login_required
def logout():
AuditLog.record("logout", user=current_user, ip=get_remote_ip())
logout_user()
session.clear()
flash("Signed out.", "success")
return redirect(url_for("auth.login"))
# --------------------------------------------------------------------------- #
# Account: change password / set up MFA
# --------------------------------------------------------------------------- #
@auth_bp.route("/account", methods=["GET"])
@login_required_full
def account():
return render_template(
"account.html",
password_form=ChangePasswordForm(),
mfa_setup_form=MfaSetupForm(),
avatar_form=AvatarForm(),
)
# --------------------------------------------------------------------------- #
# Avatar: upload / remove / serve
# --------------------------------------------------------------------------- #
@auth_bp.route("/account/avatar", methods=["POST"])
@login_required_full
def avatar_upload():
form = AvatarForm()
if form.validate_on_submit():
try:
png_bytes = _normalise_avatar(form.avatar.data)
except ValueError as e:
flash(str(e), "error")
return redirect(url_for("auth.account"))
current_user.avatar_data = png_bytes
current_user.avatar_mime = "image/png"
db.session.commit()
AuditLog.record("avatar.update", user=current_user, ip=get_remote_ip())
flash("Avatar updated.", "success")
else:
for errs in form.errors.values():
for e in errs:
flash(e, "error")
return redirect(url_for("auth.account"))
@auth_bp.route("/account/avatar/remove", methods=["POST"])
@login_required_full
def avatar_remove():
current_user.avatar_data = None
current_user.avatar_mime = None
db.session.commit()
AuditLog.record("avatar.remove", user=current_user, ip=get_remote_ip())
flash("Avatar removed.", "info")
return redirect(url_for("auth.account"))
@auth_bp.route("/avatar/<int:user_id>", methods=["GET"])
@login_required_full
def avatar(user_id: int):
user = db.session.get(User, user_id)
if user is None or not user.has_avatar:
abort(404)
resp = Response(user.avatar_data, mimetype=user.avatar_mime or "image/png")
resp.headers["Cache-Control"] = "private, max-age=60"
return resp
@auth_bp.route("/account/password", methods=["POST"])
@login_required_full
def change_password():
form = ChangePasswordForm()
if form.validate_on_submit():
if not current_user.check_password(form.current_password.data):
flash("Current password is incorrect.", "error")
else:
current_user.set_password(form.new_password.data)
db.session.commit()
AuditLog.record("password.change", user=current_user, ip=get_remote_ip())
flash("Password updated.", "success")
return redirect(url_for("auth.account"))
return render_template(
"account.html",
password_form=form,
mfa_setup_form=MfaSetupForm(),
)
@auth_bp.route("/account/mfa/setup", methods=["GET", "POST"])
@login_required_full
def mfa_setup():
"""Generate a new TOTP secret + QR code; user confirms with a 6-digit code."""
if current_user.mfa_enabled:
flash("MFA is already enabled.", "info")
return redirect(url_for("auth.account"))
# Keep candidate secret in session until verified
if "mfa_setup_secret" not in session:
session["mfa_setup_secret"] = pyotp.random_base32()
secret = session["mfa_setup_secret"]
issuer = current_app.config.get("MFA_ISSUER_NAME", "AlertHub")
uri = pyotp.TOTP(secret).provisioning_uri(name=current_user.email, issuer_name=issuer)
# Generate QR code as base64 PNG
img = qrcode.make(uri)
buf = io.BytesIO()
img.save(buf, format="PNG")
qr_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
form = MfaSetupForm()
if form.validate_on_submit():
if pyotp.TOTP(secret).verify(form.code.data, valid_window=1):
current_user.mfa_secret = secret
current_user.mfa_enabled = True
db.session.commit()
session.pop("mfa_setup_secret", None)
AuditLog.record("mfa.enabled", user=current_user, ip=get_remote_ip())
flash("MFA enabled successfully. You will be prompted for a code on next sign-in.", "success")
return redirect(url_for("auth.account"))
flash("Verification code did not match. Try again.", "error")
return render_template("mfa_setup.html", form=form, qr_b64=qr_b64, secret=secret)
@auth_bp.route("/account/mfa/disable", methods=["POST"])
@login_required_full
def mfa_disable():
current_user.mfa_enabled = False
current_user.mfa_secret = None
db.session.commit()
AuditLog.record("mfa.disabled", user=current_user, ip=get_remote_ip())
flash("MFA disabled.", "info")
return redirect(url_for("auth.account"))
+426
View File
@@ -0,0 +1,426 @@
"""Backoffice routes — admins only.
Sections:
3. Manage Users & Permissions
- Mail / SMTP configuration
- Global settings (default reminder days, scheduler, run-now)
- Audit log viewer
"""
import csv
import io
from datetime import date, datetime
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, current_app, Response
from flask_login import current_user
from .models import db, User, Alert, AppSetting, AuditLog, MailLog
from .forms import UserForm, MailConfigForm, TestMailForm, GlobalSettingsForm, LogEmailForm
from .auth_utils import admin_required, get_remote_ip
from .mailer import send_mail, get_mail_config
from .scheduler import trigger_now
backoffice_bp = Blueprint("backoffice", __name__)
def _csv_response(filename: str, header: list, rows) -> Response:
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(header)
for r in rows:
writer.writerow(r)
data = buf.getvalue()
return Response(
data,
mimetype="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
def _audit_rows():
for e in AuditLog.query.order_by(AuditLog.timestamp.desc()).all():
yield [
e.timestamp.strftime("%Y-%m-%d %H:%M:%S") if e.timestamp else "",
e.username or "",
e.action or "",
e.details or "",
e.ip_address or "",
]
def _maillog_rows():
for m in MailLog.query.order_by(MailLog.timestamp.desc()).all():
yield [
m.timestamp.strftime("%Y-%m-%d %H:%M:%S") if m.timestamp else "",
m.kind or "",
m.recipients or "",
m.subject or "",
m.status or "",
m.sent_copy or "",
m.triggered_by or "",
m.info or "",
]
AUDIT_CSV_HEADER = ["timestamp_utc", "user", "action", "details", "ip"]
MAILLOG_CSV_HEADER = ["timestamp_utc", "kind", "recipients", "subject", "status", "sent_copy", "triggered_by", "info"]
def _build_csv_string(header, rows) -> str:
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(header)
for r in rows:
writer.writerow(r)
return buf.getvalue()
# --------------------------------------------------------------------------- #
# Backoffice home — admin dashboard
# --------------------------------------------------------------------------- #
@backoffice_bp.route("/")
@admin_required
def dashboard():
user_count = User.query.count()
admin_count = User.query.filter_by(role="admin").count()
active_alerts = Alert.query.filter_by(is_active=True).count()
today = date.today()
expiring_soon = Alert.query.filter(
Alert.is_active.is_(True),
Alert.expiration_date >= today,
).order_by(Alert.expiration_date.asc()).limit(5).all()
recent_audit = AuditLog.query.order_by(AuditLog.timestamp.desc()).limit(10).all()
return render_template(
"backoffice/dashboard.html",
user_count=user_count,
admin_count=admin_count,
active_alerts=active_alerts,
expiring_soon=expiring_soon,
recent_audit=recent_audit,
today=today,
)
# --------------------------------------------------------------------------- #
# User management
# --------------------------------------------------------------------------- #
@backoffice_bp.route("/users")
@admin_required
def users_list():
users = User.query.order_by(User.username.asc()).all()
return render_template("backoffice/users_list.html", users=users)
@backoffice_bp.route("/users/new", methods=["GET", "POST"])
@admin_required
def user_new():
form = UserForm()
if form.validate_on_submit():
if not form.password.data:
flash("Password is required for a new user.", "error")
elif User.query.filter((User.username == form.username.data) | (User.email == form.email.data)).first():
flash("Username or email already exists.", "error")
elif len(form.password.data) < current_app.config["PASSWORD_MIN_LENGTH"]:
flash(f"Password must be at least {current_app.config['PASSWORD_MIN_LENGTH']} characters.", "error")
else:
user = User(
username=form.username.data.strip(),
email=form.email.data.strip().lower(),
role=form.role.data,
is_active=form.is_active.data,
)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
AuditLog.record("user.create", user=current_user, details=f"new={user.username} role={user.role}", ip=get_remote_ip())
flash(f"User '{user.username}' created.", "success")
return redirect(url_for("backoffice.users_list"))
return render_template("backoffice/user_form.html", form=form, user=None)
@backoffice_bp.route("/users/<int:user_id>/edit", methods=["GET", "POST"])
@admin_required
def user_edit(user_id: int):
user = db.session.get(User, user_id)
if user is None:
abort(404)
form = UserForm(obj=user)
if form.validate_on_submit():
# Username / email uniqueness check (excluding self)
clash = User.query.filter(
User.id != user.id,
(User.username == form.username.data) | (User.email == form.email.data.lower())
).first()
if clash:
flash("Another user already has that username or email.", "error")
else:
# Prevent self-demotion of last admin / self-deactivation lock-out
if user.id == current_user.id:
if form.role.data != "admin":
flash("You cannot demote yourself.", "error")
return render_template("backoffice/user_form.html", form=form, user=user)
if not form.is_active.data:
flash("You cannot deactivate your own account.", "error")
return render_template("backoffice/user_form.html", form=form, user=user)
# Prevent removing the last active admin
if user.role == "admin" and form.role.data != "admin":
other_admins = User.query.filter(User.id != user.id, User.role == "admin", User.is_active.is_(True)).count()
if other_admins == 0:
flash("Cannot demote the last administrator.", "error")
return render_template("backoffice/user_form.html", form=form, user=user)
user.username = form.username.data.strip()
user.email = form.email.data.strip().lower()
user.role = form.role.data
user.is_active = form.is_active.data
if form.password.data:
if len(form.password.data) < current_app.config["PASSWORD_MIN_LENGTH"]:
flash(f"Password must be at least {current_app.config['PASSWORD_MIN_LENGTH']} characters.", "error")
return render_template("backoffice/user_form.html", form=form, user=user)
user.set_password(form.password.data)
db.session.commit()
AuditLog.record("user.update", user=current_user, details=f"id={user.id} username={user.username}", ip=get_remote_ip())
flash("User updated.", "success")
return redirect(url_for("backoffice.users_list"))
return render_template("backoffice/user_form.html", form=form, user=user)
@backoffice_bp.route("/users/<int:user_id>/delete", methods=["POST"])
@admin_required
def user_delete(user_id: int):
user = db.session.get(User, user_id)
if user is None:
abort(404)
if user.id == current_user.id:
flash("You cannot delete your own account.", "error")
return redirect(url_for("backoffice.users_list"))
if user.role == "admin":
other_admins = User.query.filter(User.id != user.id, User.role == "admin", User.is_active.is_(True)).count()
if other_admins == 0:
flash("Cannot delete the last administrator.", "error")
return redirect(url_for("backoffice.users_list"))
username = user.username
db.session.delete(user)
db.session.commit()
AuditLog.record("user.delete", user=current_user, details=f"deleted={username}", ip=get_remote_ip())
flash(f"User '{username}' deleted.", "success")
return redirect(url_for("backoffice.users_list"))
@backoffice_bp.route("/users/<int:user_id>/reset-mfa", methods=["POST"])
@admin_required
def user_reset_mfa(user_id: int):
user = db.session.get(User, user_id)
if user is None:
abort(404)
user.mfa_enabled = False
user.mfa_secret = None
db.session.commit()
AuditLog.record("user.mfa_reset", user=current_user, details=f"target={user.username}", ip=get_remote_ip())
flash(f"MFA reset for '{user.username}'. They can re-enrol on next login.", "info")
return redirect(url_for("backoffice.users_list"))
@backoffice_bp.route("/users/<int:user_id>/unlock", methods=["POST"])
@admin_required
def user_unlock(user_id: int):
user = db.session.get(User, user_id)
if user is None:
abort(404)
user.locked_until = None
user.failed_login_count = 0
db.session.commit()
AuditLog.record("user.unlock", user=current_user, details=f"target={user.username}", ip=get_remote_ip())
flash(f"Unlocked '{user.username}'.", "success")
return redirect(url_for("backoffice.users_list"))
# --------------------------------------------------------------------------- #
# Mail configuration
# --------------------------------------------------------------------------- #
@backoffice_bp.route("/mail-config", methods=["GET", "POST"])
@admin_required
def mail_config():
form = MailConfigForm()
test_form = TestMailForm()
if request.method == "GET":
cfg = get_mail_config()
form.smtp_host.data = cfg["host"]
form.smtp_port.data = cfg["port"]
form.smtp_username.data = cfg["username"]
form.smtp_encryption.data = cfg["encryption"]
form.smtp_from_address.data = cfg["from_address"]
form.smtp_from_name.data = cfg["from_name"]
form.imap_enabled.data = cfg["imap_enabled"]
form.imap_host.data = cfg["imap_host"]
form.imap_port.data = cfg["imap_port"]
form.imap_username.data = cfg["imap_username"]
form.imap_sent_folder.data = cfg["imap_sent_folder"]
if form.submit.data and form.validate_on_submit():
AppSetting.set("smtp_host", form.smtp_host.data.strip())
AppSetting.set("smtp_port", str(form.smtp_port.data))
AppSetting.set("smtp_username", form.smtp_username.data.strip())
if form.smtp_password.data:
AppSetting.set("smtp_password", form.smtp_password.data)
AppSetting.set("smtp_encryption", form.smtp_encryption.data)
AppSetting.set("smtp_from_address", form.smtp_from_address.data.strip().lower())
AppSetting.set("smtp_from_name", (form.smtp_from_name.data or "").strip() or "AlertHub")
# ---- IMAP (Sent-folder copy) ----
AppSetting.set("imap_enabled", "true" if form.imap_enabled.data else "false")
AppSetting.set("imap_host", (form.imap_host.data or "").strip())
AppSetting.set("imap_port", str(form.imap_port.data or 993))
AppSetting.set("imap_username", (form.imap_username.data or "").strip())
if form.imap_password.data:
AppSetting.set("imap_password", form.imap_password.data)
AppSetting.set("imap_sent_folder", (form.imap_sent_folder.data or "").strip() or "Sent")
AuditLog.record("mailconfig.update", user=current_user, ip=get_remote_ip())
flash("Mail configuration saved.", "success")
return redirect(url_for("backoffice.mail_config"))
return render_template("backoffice/mail_config.html", form=form, test_form=test_form)
@backoffice_bp.route("/mail-config/test", methods=["POST"])
@admin_required
def mail_test():
test_form = TestMailForm()
if test_form.validate_on_submit():
ok, info = send_mail(
test_form.test_recipient.data.strip(),
"[AlertHub] Test email",
"This is a test email from AlertHub. If you received this, SMTP is configured correctly.",
"<p>This is a <strong>test email</strong> from AlertHub. If you received this, SMTP is configured correctly.</p>",
kind="test",
triggered_by=current_user.username,
)
AuditLog.record("mailconfig.test", user=current_user, details=f"to={test_form.test_recipient.data} ok={ok}", ip=get_remote_ip())
flash(("Test email sent. " if ok else "Test failed: ") + info, "success" if ok else "error")
else:
flash("Provide a valid recipient email address.", "error")
return redirect(url_for("backoffice.mail_config"))
# --------------------------------------------------------------------------- #
# Global settings + scheduler trigger
# --------------------------------------------------------------------------- #
@backoffice_bp.route("/settings", methods=["GET", "POST"])
@admin_required
def settings():
form = GlobalSettingsForm()
if request.method == "GET":
form.default_reminder_days.data = AppSetting.get("default_reminder_days", "30,14,7,1")
if form.validate_on_submit():
cleaned = ",".join(
str(x) for x in sorted({int(p.strip()) for p in form.default_reminder_days.data.split(",") if p.strip().isdigit()}, reverse=True)
)
if not cleaned:
flash("Please enter at least one valid day value.", "error")
else:
AppSetting.set("default_reminder_days", cleaned)
AuditLog.record("settings.update", user=current_user, details=f"default_reminder_days={cleaned}", ip=get_remote_ip())
flash("Settings saved.", "success")
return redirect(url_for("backoffice.settings"))
scheduler_interval = current_app.config.get("SCHEDULER_INTERVAL_MINUTES", 60)
return render_template("backoffice/settings.html", form=form, scheduler_interval=scheduler_interval)
@backoffice_bp.route("/settings/run-check", methods=["POST"])
@admin_required
def run_check_now():
"""Manually run the reminder scan once."""
try:
trigger_now(current_app._get_current_object())
AuditLog.record("scheduler.manual_run", user=current_user, ip=get_remote_ip())
flash("Reminder check executed.", "success")
except Exception as e:
current_app.logger.exception("Manual scheduler run failed")
flash(f"Reminder check failed: {e}", "error")
return redirect(url_for("backoffice.settings"))
# --------------------------------------------------------------------------- #
# Audit log viewer
# --------------------------------------------------------------------------- #
@backoffice_bp.route("/audit-log")
@admin_required
def audit_log():
page = max(1, int(request.args.get("page", 1)))
per_page = 50
pagination = AuditLog.query.order_by(AuditLog.timestamp.desc()).paginate(page=page, per_page=per_page, error_out=False)
return render_template("backoffice/audit_log.html", pagination=pagination, log_email_form=LogEmailForm())
@backoffice_bp.route("/audit-log/export")
@admin_required
def audit_log_export():
stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
AuditLog.record("auditlog.export", user=current_user, ip=get_remote_ip())
return _csv_response(f"alerthub-audit-log-{stamp}.csv", AUDIT_CSV_HEADER, _audit_rows())
@backoffice_bp.route("/audit-log/email", methods=["POST"])
@admin_required
def audit_log_email():
form = LogEmailForm()
if not form.validate_on_submit():
flash("Provide a valid destination email address.", "error")
return redirect(url_for("backoffice.audit_log"))
recipient = form.log_recipient.data.strip()
stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
csv_str = _build_csv_string(AUDIT_CSV_HEADER, _audit_rows())
ok, info = send_mail(
recipient,
f"[AlertHub] Audit log export ({stamp} UTC)",
"Attached is the AlertHub audit log export requested from the backoffice.",
"<p>Attached is the <strong>AlertHub audit log</strong> export requested from the backoffice.</p>",
attachments=[(f"alerthub-audit-log-{stamp}.csv", "text", "csv", csv_str.encode("utf-8"))],
kind="log-export",
triggered_by=current_user.username,
)
AuditLog.record("auditlog.email", user=current_user, details=f"to={recipient} ok={ok}", ip=get_remote_ip())
flash(("Audit log emailed. " if ok else "Failed to email log: ") + info, "success" if ok else "error")
return redirect(url_for("backoffice.audit_log"))
# --------------------------------------------------------------------------- #
# Mail log viewer (every message the platform sends)
# --------------------------------------------------------------------------- #
@backoffice_bp.route("/mail-log")
@admin_required
def mail_log():
page = max(1, int(request.args.get("page", 1)))
per_page = 50
pagination = MailLog.query.order_by(MailLog.timestamp.desc()).paginate(page=page, per_page=per_page, error_out=False)
return render_template("backoffice/mail_log.html", pagination=pagination, log_email_form=LogEmailForm())
@backoffice_bp.route("/mail-log/export")
@admin_required
def mail_log_export():
stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
AuditLog.record("maillog.export", user=current_user, ip=get_remote_ip())
return _csv_response(f"alerthub-mail-log-{stamp}.csv", MAILLOG_CSV_HEADER, _maillog_rows())
@backoffice_bp.route("/mail-log/email", methods=["POST"])
@admin_required
def mail_log_email():
form = LogEmailForm()
if not form.validate_on_submit():
flash("Provide a valid destination email address.", "error")
return redirect(url_for("backoffice.mail_log"))
recipient = form.log_recipient.data.strip()
stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
csv_str = _build_csv_string(MAILLOG_CSV_HEADER, _maillog_rows())
ok, info = send_mail(
recipient,
f"[AlertHub] Mail log export ({stamp} UTC)",
"Attached is the AlertHub mail log export requested from the backoffice.",
"<p>Attached is the <strong>AlertHub mail log</strong> export requested from the backoffice.</p>",
attachments=[(f"alerthub-mail-log-{stamp}.csv", "text", "csv", csv_str.encode("utf-8"))],
kind="log-export",
triggered_by=current_user.username,
)
AuditLog.record("maillog.email", user=current_user, details=f"to={recipient} ok={ok}", ip=get_remote_ip())
flash(("Mail log emailed. " if ok else "Failed to email log: ") + info, "success" if ok else "error")
return redirect(url_for("backoffice.mail_log"))
+137
View File
@@ -0,0 +1,137 @@
"""Frontoffice routes — visible to all authenticated users.
Sections:
1. View Data (everyone)
2. Add/Remove/Edit (admins only)
"""
import json
from datetime import date
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
from flask_login import current_user
from .models import db, Alert, AppSetting, AuditLog
from .forms import AlertForm
from .auth_utils import admin_required, login_required_full, get_remote_ip
main_bp = Blueprint("main", __name__)
def _parse_days(text: str) -> list[int]:
return sorted({int(x.strip()) for x in text.split(",") if x.strip().isdigit() and int(x.strip()) >= 0}, reverse=True)
# --------------------------------------------------------------------------- #
# Section 1 — View Data
# --------------------------------------------------------------------------- #
@main_bp.route("/")
@login_required_full
def index():
today = date.today()
alerts = Alert.query.filter_by(is_active=True).order_by(Alert.expiration_date.asc()).all()
payload = []
for a in alerts:
days_left = (a.expiration_date - today).days
payload.append({
"id": a.id,
"title": a.title,
"category": a.category,
"description": a.description,
"expiration_date": a.expiration_date.isoformat(),
"days_left": days_left,
"reminder_days": a.reminder_days,
})
return render_template("index.html", alerts=payload, today_iso=today.isoformat())
# --------------------------------------------------------------------------- #
# Section 2 — Add / Remove / Edit
# --------------------------------------------------------------------------- #
@main_bp.route("/alerts")
@login_required_full
def alerts_list():
alerts = Alert.query.order_by(Alert.expiration_date.asc()).all()
return render_template("alerts_list.html", alerts=alerts)
@main_bp.route("/alerts/new", methods=["GET", "POST"])
@admin_required
def alert_new():
default_reminder_days = AppSetting.get("default_reminder_days", "30,14,7,1")
form = AlertForm(reminder_days=default_reminder_days)
if form.validate_on_submit():
days = _parse_days(form.reminder_days.data)
if not days:
flash("Reminder days must contain at least one valid number.", "error")
else:
alert = Alert(
title=form.title.data.strip(),
category=(form.category.data or "").strip() or None,
description=(form.description.data or "").strip() or None,
expiration_date=form.expiration_date.data,
is_active=form.is_active.data,
created_by=current_user.id,
)
alert.reminder_days = days
db.session.add(alert)
db.session.commit()
AuditLog.record("alert.create", user=current_user, details=f"id={alert.id} title={alert.title}", ip=get_remote_ip())
flash("Alert created.", "success")
return redirect(url_for("main.alerts_list"))
return render_template("alert_form.html", form=form, alert=None)
@main_bp.route("/alerts/<int:alert_id>/edit", methods=["GET", "POST"])
@admin_required
def alert_edit(alert_id: int):
alert = db.session.get(Alert, alert_id)
if alert is None:
abort(404)
form = AlertForm(obj=alert)
if request.method == "GET":
form.reminder_days.data = ",".join(str(d) for d in alert.reminder_days)
if form.validate_on_submit():
days = _parse_days(form.reminder_days.data)
if not days:
flash("Reminder days must contain at least one valid number.", "error")
else:
alert.title = form.title.data.strip()
alert.category = (form.category.data or "").strip() or None
alert.description = (form.description.data or "").strip() or None
alert.expiration_date = form.expiration_date.data
alert.is_active = form.is_active.data
# If reminder_days changed OR expiration_date changed, reset sent log
if days != alert.reminder_days:
alert.reminders_sent = []
alert.reminder_days = days
db.session.commit()
AuditLog.record("alert.update", user=current_user, details=f"id={alert.id}", ip=get_remote_ip())
flash("Alert updated.", "success")
return redirect(url_for("main.alerts_list"))
return render_template("alert_form.html", form=form, alert=alert)
@main_bp.route("/alerts/<int:alert_id>/delete", methods=["POST"])
@admin_required
def alert_delete(alert_id: int):
alert = db.session.get(Alert, alert_id)
if alert is None:
abort(404)
title = alert.title
db.session.delete(alert)
db.session.commit()
AuditLog.record("alert.delete", user=current_user, details=f"id={alert_id} title={title}", ip=get_remote_ip())
flash(f"Alert '{title}' deleted.", "success")
return redirect(url_for("main.alerts_list"))
@main_bp.route("/alerts/<int:alert_id>/reset-reminders", methods=["POST"])
@admin_required
def alert_reset_reminders(alert_id: int):
alert = db.session.get(Alert, alert_id)
if alert is None:
abort(404)
alert.reminders_sent = []
db.session.commit()
AuditLog.record("alert.reset_reminders", user=current_user, details=f"id={alert_id}", ip=get_remote_ip())
flash("Reminder send-history cleared. Pending reminders will be re-sent on next scheduler run.", "info")
return redirect(url_for("main.alerts_list"))
+79
View File
@@ -0,0 +1,79 @@
"""Background scheduler that scans alerts and dispatches reminder emails."""
import logging
from datetime import date, timezone
from apscheduler.schedulers.background import BackgroundScheduler
from .models import Alert, User, db
from .mailer import send_mail
logger = logging.getLogger(__name__)
_scheduler: BackgroundScheduler | None = None
def _check_and_send(app):
"""Iterate active alerts; for each reminder threshold reached, email all users."""
with app.app_context():
today = date.today()
active_alerts = Alert.query.filter_by(is_active=True).all()
recipients = [u.email for u in User.query.filter_by(is_active=True).all() if u.email]
if not recipients:
logger.info("Scheduler tick: no recipients.")
return
for alert in active_alerts:
days_left = (alert.expiration_date - today).days
already_sent = set(alert.reminders_sent)
newly_sent = []
for threshold in alert.reminder_days:
if days_left <= threshold and threshold not in already_sent:
subject = f"[AlertHub] Reminder: {alert.title} expires in {days_left} day(s)"
body_text = (
f"Alert: {alert.title}\n"
f"Category: {alert.category or ''}\n"
f"Expiration date: {alert.expiration_date.isoformat()}\n"
f"Days remaining: {days_left}\n\n"
f"{alert.description or ''}\n"
)
body_html = f"""
<div style="font-family: -apple-system, sans-serif; max-width: 600px;">
<h2 style="color: #00d4aa;">⏰ AlertHub Reminder</h2>
<h3>{alert.title}</h3>
<p><strong>Category:</strong> {alert.category or ''}<br>
<strong>Expiration date:</strong> {alert.expiration_date.isoformat()}<br>
<strong>Days remaining:</strong> <span style="color:#ff6b6b;font-size:1.2em;">{days_left}</span></p>
<p>{alert.description or ''}</p>
</div>
"""
ok, info = send_mail(recipients, subject, body_text, body_html,
kind="reminder", triggered_by="system")
logger.info("Alert %s threshold %d: send=%s (%s)", alert.id, threshold, ok, info)
if ok:
newly_sent.append(threshold)
if newly_sent:
alert.reminders_sent = list(already_sent.union(newly_sent))
db.session.commit()
def init_scheduler(app):
"""Start the scheduler if not already running. Called once at app startup."""
global _scheduler
if _scheduler is not None and _scheduler.running:
return _scheduler
interval = max(1, int(app.config.get("SCHEDULER_INTERVAL_MINUTES", 60)))
_scheduler = BackgroundScheduler(timezone="UTC")
_scheduler.add_job(
_check_and_send,
"interval",
minutes=interval,
args=[app],
id="alert_reminder_job",
replace_existing=True,
next_run_time=None, # don't run immediately on boot
)
_scheduler.start()
logger.info("Scheduler started; reminder check every %d minute(s).", interval)
return _scheduler
def trigger_now(app):
"""Manual trigger (for the 'Run check now' button)."""
_check_and_send(app)
+869
View File
@@ -0,0 +1,869 @@
/* ======================================================================
AlertHub — IT operations console aesthetic
Typography: JetBrains Mono (display/mono), Space Grotesk avoided in favour
of system stack with mono accents to keep the terminal vibe.
====================================================================== */
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap');
:root {
--font-mono: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace;
--font-sans: 'IBM Plex Sans', -apple-system, system-ui, sans-serif;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 14px;
--tx-h: 1.15;
--tx-b: 1.55;
}
/* ---------- Dark theme (default) ---------- */
[data-theme="dark"] {
--bg-base: #0a0e14;
--bg-elev-1: #0f141b;
--bg-elev-2: #141a23;
--bg-elev-3: #1a212c;
--grid-line: rgba(0, 212, 170, 0.04);
--border: #1f2733;
--border-strong: #2a3442;
--text-primary: #d8e2ec;
--text-secondary: #8b96a5;
--text-dim: #5a6371;
--accent: #00d4aa; /* signal green */
--accent-soft: rgba(0, 212, 170, 0.12);
--accent-hot: #00ffcc;
--magenta: #ff3e8b; /* alert magenta */
--amber: #ffb454;
--danger: #ff5c5c;
--info: #4ad8ff;
--shadow-1: 0 1px 0 rgba(255,255,255,0.03), 0 8px 24px rgba(0,0,0,0.5);
--glow-accent: 0 0 0 1px rgba(0,212,170,0.3), 0 0 20px rgba(0,212,170,0.15);
}
/* ---------- Light theme ---------- */
[data-theme="light"] {
--bg-base: #f4f6f9;
--bg-elev-1: #ffffff;
--bg-elev-2: #f9fbfd;
--bg-elev-3: #f0f3f7;
--grid-line: rgba(0, 130, 100, 0.05);
--border: #e2e8ef;
--border-strong: #c8d2dd;
--text-primary: #0e1620;
--text-secondary: #4a5566;
--text-dim: #8693a3;
--accent: #008f74;
--accent-soft: rgba(0, 143, 116, 0.08);
--accent-hot: #00b388;
--magenta: #d92671;
--amber: #d97a13;
--danger: #d93939;
--info: #1f9ec5;
--shadow-1: 0 1px 2px rgba(15, 30, 45, 0.06), 0 8px 20px rgba(15, 30, 45, 0.05);
--glow-accent: 0 0 0 1px rgba(0, 143, 116, 0.25);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg-base);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 15px;
line-height: var(--tx-b);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
body {
position: relative;
overflow-x: hidden;
}
/* Decorative grid background */
.grid-bg {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 32px 32px;
mask-image: radial-gradient(ellipse at top, black 30%, transparent 75%);
-webkit-mask-image: radial-gradient(ellipse at top, black 30%, transparent 75%);
}
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hot); text-decoration: underline; text-underline-offset: 3px; }
h1, h2, h3, h4 {
font-family: var(--font-sans);
font-weight: 700;
letter-spacing: -0.01em;
line-height: var(--tx-h);
margin: 0 0 .6em;
}
h1 { font-size: 1.85rem; }
h2 { font-size: 1.35rem; }
h3 { font-size: 1.1rem; }
code, .mono { font-family: var(--font-mono); font-size: .92em; }
/* ---------- Top bar ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(10px) saturate(140%);
-webkit-backdrop-filter: blur(10px) saturate(140%);
background: color-mix(in srgb, var(--bg-elev-1) 82%, transparent);
border-bottom: 1px solid var(--border);
}
.topbar-inner {
max-width: 1320px;
margin: 0 auto;
padding: 12px 24px;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 24px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
font-family: var(--font-mono);
font-weight: 700;
letter-spacing: -0.01em;
}
.brand:hover { text-decoration: none; }
.brand-mark {
color: var(--accent);
font-size: 1.1em;
letter-spacing: -2px;
filter: drop-shadow(0 0 6px var(--accent));
}
.brand-name { font-size: 1.05rem; }
.brand-tag {
font-family: var(--font-mono);
font-size: .65rem;
font-weight: 500;
letter-spacing: 0.15em;
padding: 2px 7px;
border-radius: 4px;
background: var(--accent-soft);
color: var(--accent);
margin-left: 6px;
}
body.is-backoffice .brand-tag {
background: color-mix(in srgb, var(--magenta) 14%, transparent);
color: var(--magenta);
}
.topbar-nav {
display: flex;
gap: 4px;
justify-self: center;
}
.navlink {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
font-family: var(--font-mono);
font-size: .85rem;
font-weight: 500;
color: var(--text-secondary);
border-radius: var(--radius-sm);
transition: all .15s ease;
}
.navlink:hover { color: var(--text-primary); background: var(--bg-elev-2); text-decoration: none; }
.navlink.active { color: var(--accent); background: var(--accent-soft); }
.navlink.active .dot { background: var(--accent); box-shadow: 0 0 8px var(--accent); }
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-dim);
}
.dot-admin { background: var(--magenta); box-shadow: 0 0 8px var(--magenta); }
.navlink-cross { border: 1px solid var(--border); }
.navlink-cross:hover { border-color: var(--border-strong); color: var(--magenta); }
.navlink-cross.active { color: var(--magenta); background: color-mix(in srgb, var(--magenta) 12%, transparent); }
.navlink-cross.active .dot-admin { background: var(--magenta); }
.topbar-right { display: inline-flex; gap: 10px; align-items: center; justify-self: end; }
.icon-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
width: 36px; height: 36px;
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all .15s ease;
}
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); background: var(--bg-elev-2); }
.icon-sun { display: none; }
.icon-moon { display: block; }
[data-theme="light"] .icon-sun { display: block; }
[data-theme="light"] .icon-moon { display: none; }
.user-chip {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 4px 12px 4px 4px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-primary);
background: var(--bg-elev-2);
}
.user-chip:hover { border-color: var(--border-strong); text-decoration: none; }
.user-avatar {
width: 28px; height: 28px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--info));
color: #061218;
font-family: var(--font-mono);
font-size: .72rem;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
}
.user-avatar-img {
object-fit: cover;
padding: 0;
border: 1px solid var(--border-strong);
background: var(--bg-elev-3);
}
.user-meta { display: flex; flex-direction: column; line-height: 1.1; }
.user-name { font-size: .85rem; }
.user-role {
font-family: var(--font-mono);
font-size: .6rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.user-role.role-admin { color: var(--magenta); }
.user-role.role-user { color: var(--text-dim); }
.inline-form { display: inline; }
/* ---------- Container ---------- */
.container {
max-width: 1320px;
margin: 0 auto;
padding: 32px 24px 64px;
position: relative;
z-index: 1;
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 16px;
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: .85rem;
font-weight: 500;
border: 1px solid transparent;
cursor: pointer;
transition: all .15s ease;
white-space: nowrap;
text-decoration: none;
}
.btn:hover { text-decoration: none; }
.btn-primary {
background: var(--accent);
color: #02181a;
border-color: var(--accent);
}
.btn-primary:hover { background: var(--accent-hot); box-shadow: var(--glow-accent); color: #02181a; }
.btn-ghost {
background: transparent;
color: var(--text-primary);
border-color: var(--border);
}
.btn-ghost:hover { border-color: var(--border-strong); background: var(--bg-elev-2); }
.btn-danger {
background: transparent;
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 35%, transparent);
}
.btn-danger:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); border-color: var(--danger); }
.btn-sm { padding: 5px 10px; font-size: .78rem; }
.btn-block { display: flex; width: 100%; justify-content: center; }
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
/* ---------- Cards / Panels ---------- */
.panel {
background: var(--bg-elev-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-1);
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.panel-header h2, .panel-header h3 { margin: 0; }
.panel-body { padding: 20px; }
.section-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin: 0 0 24px;
padding-bottom: 16px;
border-bottom: 1px dashed var(--border);
}
.section-head .lead { color: var(--text-secondary); margin: 6px 0 0; font-size: .95rem; }
.section-tag {
font-family: var(--font-mono);
font-size: .65rem;
letter-spacing: 0.15em;
color: var(--accent);
text-transform: uppercase;
margin-bottom: 6px;
display: block;
}
/* ---------- Forms ---------- */
.form { display: grid; gap: 16px; }
.form label {
display: block;
font-family: var(--font-mono);
font-size: .78rem;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 6px;
}
.form input[type=text],
.form input[type=email],
.form input[type=password],
.form input[type=number],
.form input[type=date],
.form select,
.form textarea {
width: 100%;
padding: 10px 12px;
background: var(--bg-elev-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: .95rem;
transition: all .15s ease;
}
.form textarea { font-family: var(--font-sans); resize: vertical; min-height: 90px; }
.form input:focus, .form select:focus, .form textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
background: var(--bg-elev-1);
}
.form .check-row {
display: inline-flex;
align-items: center;
gap: 10px;
}
.form .check-row label {
margin: 0;
text-transform: none;
letter-spacing: 0;
font-family: var(--font-sans);
font-size: .95rem;
font-weight: 500;
color: var(--text-primary);
}
.help { color: var(--text-dim); font-size: .82rem; margin-top: 4px; }
.form-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 720px) { .form-grid-2 { grid-template-columns: 1fr; } }
.error { color: var(--danger); font-size: .82rem; margin-top: 4px; }
/* ---------- Flash messages ---------- */
.flash-stack { display: grid; gap: 10px; margin-bottom: 24px; }
.flash {
padding: 12px 16px;
border-radius: var(--radius-sm);
border: 1px solid;
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-elev-1);
font-size: .92rem;
}
.flash-icon {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.flash-success { border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
.flash-success .flash-icon { background: var(--accent); box-shadow: 0 0 8px var(--accent); }
.flash-error { border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
.flash-error .flash-icon { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
.flash-info { border-color: color-mix(in srgb, var(--info) 40%, transparent); }
.flash-info .flash-icon { background: var(--info); }
/* ---------- Auth pages ---------- */
.auth-shell {
min-height: calc(100vh - 200px);
display: flex;
align-items: center;
justify-content: center;
padding: 40px 16px;
}
.auth-card {
width: 100%;
max-width: 420px;
background: var(--bg-elev-1);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 32px;
box-shadow: var(--shadow-1);
position: relative;
}
.auth-card::before {
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, var(--accent), transparent 40%, var(--magenta));
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: .5;
pointer-events: none;
}
.auth-prompt {
font-family: var(--font-mono);
font-size: .8rem;
color: var(--accent);
margin-bottom: 6px;
letter-spacing: 0.06em;
}
.auth-prompt::before { content: '> '; opacity: .7; }
.auth-card h1 { margin-bottom: 6px; font-size: 1.5rem; }
.auth-sub { color: var(--text-secondary); font-size: .9rem; margin-bottom: 24px; }
/* ---------- ALERT cards (the heart of the View page) ---------- */
.alerts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 18px;
}
.alert-card {
background: var(--bg-elev-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 20px;
position: relative;
overflow: hidden;
transition: transform .2s ease, box-shadow .2s ease, border-color .2s ease;
}
.alert-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-1); border-color: var(--border-strong); }
.alert-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: var(--accent);
opacity: .8;
}
.alert-card.is-warning::before { background: var(--amber); }
.alert-card.is-critical::before { background: var(--danger); }
.alert-card.is-expired::before { background: var(--text-dim); }
.alert-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.alert-title { margin: 0; font-size: 1.1rem; font-weight: 700; line-height: 1.3; }
.alert-cat {
display: inline-block;
font-family: var(--font-mono);
font-size: .65rem;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 3px 8px;
border-radius: 3px;
background: var(--bg-elev-3);
color: var(--text-secondary);
}
.alert-desc {
color: var(--text-secondary);
font-size: .9rem;
margin: 0 0 16px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.alert-message {
font-family: var(--font-mono);
font-size: .85rem;
color: var(--text-secondary);
margin-bottom: 16px;
padding: 10px 12px;
background: var(--bg-elev-2);
border-left: 2px solid var(--accent);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.alert-card.is-warning .alert-message { border-left-color: var(--amber); }
.alert-card.is-critical .alert-message { border-left-color: var(--danger); }
.alert-card.is-expired .alert-message { border-left-color: var(--text-dim); }
/* The countdown timer */
.countdown {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.countdown-segment {
background: var(--bg-elev-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 4px 8px;
text-align: center;
position: relative;
}
.countdown-value {
font-family: var(--font-mono);
font-size: 1.5rem;
font-weight: 700;
color: var(--accent);
line-height: 1;
display: block;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.alert-card.is-warning .countdown-value { color: var(--amber); }
.alert-card.is-critical .countdown-value {
color: var(--danger);
animation: pulse-num 1.4s ease-in-out infinite;
}
.alert-card.is-expired .countdown-value { color: var(--text-dim); }
.countdown-label {
font-family: var(--font-mono);
font-size: .55rem;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-dim);
display: block;
margin-top: 4px;
}
@keyframes pulse-num {
0%, 100% { opacity: 1; }
50% { opacity: .55; }
}
.alert-meta {
margin-top: 14px;
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-mono);
font-size: .72rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.alert-status-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
margin-right: 6px;
box-shadow: 0 0 6px var(--accent);
}
.alert-card.is-warning .alert-status-dot { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
.alert-card.is-critical .alert-status-dot {
background: var(--danger);
box-shadow: 0 0 8px var(--danger);
animation: blink 1s ease-in-out infinite;
}
.alert-card.is-expired .alert-status-dot { background: var(--text-dim); box-shadow: none; }
@keyframes blink { 50% { opacity: .25; } }
/* Empty state */
.empty {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
border: 1px dashed var(--border);
border-radius: var(--radius-md);
}
.empty .empty-icon { font-size: 2rem; opacity: .5; margin-bottom: 12px; }
/* ---------- Tables ---------- */
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
background: var(--bg-elev-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.table th, .table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.table thead th {
background: var(--bg-elev-2);
font-family: var(--font-mono);
font-size: .72rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-secondary);
}
.table tbody tr:hover { background: var(--bg-elev-2); }
.table tbody tr:last-child td { border-bottom: none; }
.table .actions { text-align: right; white-space: nowrap; }
.role-badge {
display: inline-block;
font-family: var(--font-mono);
font-size: .68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 3px 8px;
border-radius: 3px;
}
.role-badge.role-admin { background: color-mix(in srgb, var(--magenta) 14%, transparent); color: var(--magenta); }
.role-badge.role-user { background: var(--bg-elev-3); color: var(--text-secondary); }
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: .7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.status-badge .led { width: 6px; height: 6px; border-radius: 50%; }
.status-active .led { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
.status-inactive .led { background: var(--text-dim); }
/* ---------- Stat cards ---------- */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 28px;
}
.stat {
background: var(--bg-elev-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 18px;
position: relative;
}
.stat-label {
font-family: var(--font-mono);
font-size: .68rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-dim);
}
.stat-value {
font-family: var(--font-mono);
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1.1;
margin-top: 6px;
}
.stat-accent .stat-value { color: var(--accent); }
.stat-warn .stat-value { color: var(--amber); }
/* ---------- QR / MFA pages ---------- */
.mfa-qr {
display: block;
margin: 0 auto;
width: 200px;
height: 200px;
background: white;
padding: 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.mfa-secret {
font-family: var(--font-mono);
font-size: .9rem;
background: var(--bg-elev-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
user-select: all;
word-break: break-all;
text-align: center;
}
/* ---------- Footer ---------- */
.footer {
max-width: 1320px;
margin: 0 auto;
padding: 18px 24px 36px;
display: flex;
align-items: center;
gap: 10px;
font-family: var(--font-mono);
font-size: .75rem;
color: var(--text-dim);
border-top: 1px solid var(--border);
}
/* ---------- Avatar panel (account page) ---------- */
.avatar-row {
display: flex;
gap: 24px;
align-items: flex-start;
flex-wrap: wrap;
}
.avatar-preview {
width: 96px; height: 96px;
border-radius: 50%;
overflow: hidden;
flex: 0 0 auto;
border: 1px solid var(--border-strong);
background: var(--bg-elev-3);
display: inline-flex;
align-items: center;
justify-content: center;
}
.avatar-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.avatar-initials {
font-family: var(--font-mono);
font-size: 1.8rem;
font-weight: 700;
color: var(--accent);
}
.avatar-actions { flex: 1 1 260px; min-width: 260px; }
/* ---------- Log export / email actions ---------- */
.log-actions {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.log-email-form {
display: inline-flex;
gap: 8px;
align-items: center;
}
.log-email-form input[type="text"],
.log-email-form input[type="email"] { margin: 0; min-width: 220px; }
@media (max-width: 720px) {
.log-actions { width: 100%; }
.log-email-form { width: 100%; }
.log-email-form input { flex: 1; min-width: 0; }
}
.footer-sep { opacity: .5; }
.footer-copy {
margin-left: auto;
color: var(--text-secondary);
letter-spacing: .02em;
}
.footer .status-pill { margin-left: 18px; }
@media (max-width: 720px) {
.footer { flex-wrap: wrap; }
.footer-copy { margin-left: 0; flex-basis: 100%; order: 3; }
.footer .status-pill { margin-left: auto; }
}
.kbd {
background: var(--bg-elev-2);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 6px;
font-size: .72rem;
color: var(--text-secondary);
}
.status-pill {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
text-transform: uppercase;
}
.status-pill .led {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent);
animation: led-pulse 2.5s ease-in-out infinite;
}
@keyframes led-pulse {
0%, 100% { opacity: 1; }
50% { opacity: .4; }
}
/* ---------- Pagination ---------- */
.pagination {
display: flex;
justify-content: center;
gap: 6px;
margin-top: 20px;
flex-wrap: wrap;
}
.pagination a, .pagination span {
font-family: var(--font-mono);
font-size: .82rem;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
}
.pagination a:hover { color: var(--accent); border-color: var(--accent); text-decoration: none; }
.pagination .current { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
/* ---------- Misc ---------- */
.row-spread { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.row-gap { display: flex; gap: 12px; flex-wrap: wrap; }
.muted { color: var(--text-secondary); }
.dim { color: var(--text-dim); }
hr { border: 0; border-top: 1px dashed var(--border); margin: 24px 0; }
@media (max-width: 800px) {
.topbar-inner { grid-template-columns: 1fr; gap: 12px; }
.topbar-nav { justify-self: flex-start; flex-wrap: wrap; }
.topbar-right { justify-self: flex-start; }
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#0a0e14"/><path d="M9 9 h3 v14 h-3z M14 9 h3 v14 h-3z" fill="#00d4aa"/><circle cx="22" cy="11" r="2" fill="#ff3e8b"/></svg>

After

Width:  |  Height:  |  Size: 226 B

+73
View File
@@ -0,0 +1,73 @@
// ====== Theme toggle =======================================================
(function () {
const btn = document.getElementById('theme-toggle');
if (!btn) return;
btn.addEventListener('click', () => {
const root = document.documentElement;
const next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
try { localStorage.setItem('alerthub-theme', next); } catch (_) {}
});
})();
// ====== Countdown timer ====================================================
// Each .countdown element has data-expires="YYYY-MM-DD" and renders 4 cells
// for days / hours / minutes / seconds. Updates every second.
(function () {
const els = document.querySelectorAll('.countdown[data-expires]');
if (!els.length) return;
function pad(n) { return n < 10 ? '0' + n : '' + n; }
function render() {
const now = new Date();
els.forEach((el) => {
const target = new Date(el.dataset.expires + 'T23:59:59');
let diff = (target.getTime() - now.getTime()) / 1000;
const card = el.closest('.alert-card');
const negative = diff < 0;
diff = Math.abs(diff);
const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
const minutes = Math.floor((diff % 3600) / 60);
const seconds = Math.floor(diff % 60);
const segs = el.querySelectorAll('.countdown-value');
if (segs.length === 4) {
segs[0].textContent = days;
segs[1].textContent = pad(hours);
segs[2].textContent = pad(minutes);
segs[3].textContent = pad(seconds);
}
if (card) {
card.classList.remove('is-warning', 'is-critical', 'is-expired');
if (negative) {
card.classList.add('is-expired');
} else if (days < 7) {
card.classList.add('is-critical');
} else if (days < 30) {
card.classList.add('is-warning');
}
}
const dotLabel = el.parentElement && el.parentElement.querySelector('.alert-state-text');
if (dotLabel) {
if (negative) dotLabel.textContent = 'EXPIRED';
else if (days < 7) dotLabel.textContent = 'CRITICAL';
else if (days < 30) dotLabel.textContent = 'WARNING';
else dotLabel.textContent = 'NOMINAL';
}
});
}
render();
setInterval(render, 1000);
})();
// ====== Confirm-on-click for destructive actions ===========================
document.querySelectorAll('form[data-confirm]').forEach((form) => {
form.addEventListener('submit', (e) => {
if (!window.confirm(form.dataset.confirm)) e.preventDefault();
});
});
+81
View File
@@ -0,0 +1,81 @@
{% extends "base.html" %}
{% block title %}Account{% endblock %}
{% block content %}
<div class="section-head">
<div>
<span class="section-tag">// account security</span>
<h1>{{ current_user.username }}</h1>
<p class="lead">{{ current_user.email }} · role <span class="role-badge role-{{ current_user.role }}">{{ current_user.role }}</span></p>
</div>
</div>
<section class="panel" style="margin-bottom: 20px;">
<header class="panel-header"><h3>Profile avatar</h3></header>
<div class="panel-body">
<div class="avatar-row">
<div class="avatar-preview">
{% if current_user.has_avatar %}
<img src="{{ url_for('auth.avatar', user_id=current_user.id) }}?v={{ range(1,100000)|random }}" alt="Current avatar">
{% else %}
<span class="avatar-initials">{{ current_user.initials }}</span>
{% endif %}
</div>
<div class="avatar-actions">
<form method="post" action="{{ url_for('auth.avatar_upload') }}" enctype="multipart/form-data" class="form">
{{ avatar_form.csrf_token }}
<div>{{ avatar_form.avatar.label }}{{ avatar_form.avatar(accept="image/*") }}</div>
<div class="help">PNG, JPG, GIF or WebP. Squared and resized to {{ 256 }}×{{ 256 }} automatically. Max 2&nbsp;MB.</div>
<div class="btn-row">
{{ avatar_form.submit(class_="btn btn-primary") }}
{% if current_user.has_avatar %}
<button type="submit" form="avatar-remove-form" class="btn btn-ghost">Remove avatar</button>
{% endif %}
</div>
</form>
{% if current_user.has_avatar %}
<form method="post" action="{{ url_for('auth.avatar_remove') }}" id="avatar-remove-form" class="inline-form" data-confirm="Remove your avatar?">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
{% endif %}
</div>
</div>
</div>
</section>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<section class="panel">
<header class="panel-header"><h3>Change password</h3></header>
<div class="panel-body">
<form method="post" action="{{ url_for('auth.change_password') }}" class="form" novalidate>
{{ password_form.csrf_token }}
<div>{{ password_form.current_password.label }}{{ password_form.current_password(autocomplete="current-password") }}</div>
<div>{{ password_form.new_password.label }}{{ password_form.new_password(autocomplete="new-password") }}</div>
<div>{{ password_form.confirm.label }}{{ password_form.confirm(autocomplete="new-password") }}</div>
{{ password_form.submit(class_="btn btn-primary") }}
</form>
</div>
</section>
<section class="panel">
<header class="panel-header">
<h3>Multi-factor authentication</h3>
<span class="status-badge {{ 'status-active' if current_user.mfa_enabled else 'status-inactive' }}">
<span class="led"></span>{{ 'enabled' if current_user.mfa_enabled else 'disabled' }}
</span>
</header>
<div class="panel-body">
{% if current_user.mfa_enabled %}
<p>MFA is currently <strong>active</strong> on your account. You'll be prompted for a 6-digit code on every sign-in.</p>
<form method="post" action="{{ url_for('auth.mfa_disable') }}" class="inline-form" data-confirm="Disable MFA? This reduces your account security.">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger">Disable MFA</button>
</form>
{% else %}
<p>MFA is currently <strong>not configured</strong>. Strongly recommended for admin accounts.</p>
<a href="{{ url_for('auth.mfa_setup') }}" class="btn btn-primary">Set up MFA now</a>
{% endif %}
</div>
</section>
</div>
<style>@media (max-width: 720px) { div[style*="grid-template-columns: 1fr 1fr"] { grid-template-columns: 1fr !important; } }</style>
{% endblock %}
+48
View File
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}{{ 'Edit' if alert else 'New' }} alert{% endblock %}
{% block content %}
<div class="section-head">
<div>
<span class="section-tag">// section 02 {{ 'edit' if alert else 'new' }}</span>
<h1>{{ 'Edit alert' if alert else 'New alert' }}</h1>
{% if alert %}<p class="lead">Last updated <span class="mono">{{ alert.updated_at.strftime('%Y-%m-%d %H:%M:%S') }}</span> UTC.</p>{% endif %}
</div>
<a href="{{ url_for('main.alerts_list') }}" class="btn btn-ghost">← Back</a>
</div>
<div class="panel" style="max-width: 720px;">
<div class="panel-body">
<form method="post" class="form" novalidate>
{{ form.csrf_token }}
<div>
{{ form.title.label }}{{ form.title(placeholder="e.g. Microsoft 365 Renewal") }}
{% for err in form.title.errors %}<div class="error">{{ err }}</div>{% endfor %}
</div>
<div class="form-grid-2">
<div>
{{ form.category.label }}{{ form.category(placeholder="License, SSL, Domain, Contract …") }}
{% for err in form.category.errors %}<div class="error">{{ err }}</div>{% endfor %}
</div>
<div>
{{ form.expiration_date.label }}{{ form.expiration_date() }}
{% for err in form.expiration_date.errors %}<div class="error">{{ err }}</div>{% endfor %}
</div>
</div>
<div>
{{ form.description.label }}{{ form.description(rows=4, placeholder="Optional detail shown on the alert card.") }}
{% for err in form.description.errors %}<div class="error">{{ err }}</div>{% endfor %}
</div>
<div>
{{ form.reminder_days.label }}{{ form.reminder_days() }}
<div class="help">Comma-separated days BEFORE expiration when an email reminder fires. Example: <span class="mono">30,14,7,1</span></div>
{% for err in form.reminder_days.errors %}<div class="error">{{ err }}</div>{% endfor %}
</div>
<div class="check-row">{{ form.is_active() }} {{ form.is_active.label }}</div>
<div class="btn-row">
{{ form.submit(class_="btn btn-primary") }}
<a href="{{ url_for('main.alerts_list') }}" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}Manage Alerts{% endblock %}
{% block content %}
<div class="section-head">
<div>
<span class="section-tag">// section 02 add / remove / edit</span>
<h1>Manage alerts</h1>
<p class="lead">Create, modify, or retire alerts. Only administrators can make changes.</p>
</div>
{% if current_user.is_admin %}
<a href="{{ url_for('main.alert_new') }}" class="btn btn-primary">+ New alert</a>
{% endif %}
</div>
{% if alerts %}
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Category</th>
<th>Expires</th>
<th>Reminders</th>
<th>Status</th>
<th class="actions">Actions</th>
</tr>
</thead>
<tbody>
{% for a in alerts %}
<tr>
<td><strong>{{ a.title }}</strong>{% if a.description %}<br><span class="dim" style="font-size: .82rem;">{{ a.description[:80] }}{% if a.description|length > 80 %}…{% endif %}</span>{% endif %}</td>
<td>{% if a.category %}<span class="alert-cat">{{ a.category }}</span>{% else %}<span class="dim"></span>{% endif %}</td>
<td class="mono">{{ a.expiration_date.isoformat() }}</td>
<td class="mono dim">{{ a.reminder_days|join(', ') }}d</td>
<td>
<span class="status-badge {{ 'status-active' if a.is_active else 'status-inactive' }}">
<span class="led"></span>{{ 'active' if a.is_active else 'inactive' }}
</span>
</td>
<td class="actions">
{% if current_user.is_admin %}
<a href="{{ url_for('main.alert_edit', alert_id=a.id) }}" class="btn btn-ghost btn-sm">Edit</a>
<form method="post" action="{{ url_for('main.alert_reset_reminders', alert_id=a.id) }}" class="inline-form" data-confirm="Re-send any pending reminders that were already sent?">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-ghost btn-sm" type="submit">Reset reminders</button>
</form>
<form method="post" action="{{ url_for('main.alert_delete', alert_id=a.id) }}" class="inline-form" data-confirm="Permanently delete '{{ a.title }}'?">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-danger btn-sm" type="submit">Delete</button>
</form>
{% else %}
<span class="dim">view only</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">
<p><strong>No alerts yet.</strong></p>
{% if current_user.is_admin %}<a href="{{ url_for('main.alert_new') }}" class="btn btn-primary">Create one</a>{% endif %}
</div>
{% endif %}
{% endblock %}
+51
View File
@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}Audit log{% endblock %}
{% block content %}
<div class="section-head">
<div>
<span class="section-tag">// console audit log</span>
<h1>Audit log</h1>
<p class="lead">Sign-ins, configuration changes, and alert mutations. Most-recent first.</p>
</div>
<div class="log-actions">
<a href="{{ url_for('backoffice.audit_log_export') }}" class="btn btn-ghost">⬇ Export CSV</a>
<form method="post" action="{{ url_for('backoffice.audit_log_email') }}" class="log-email-form" novalidate>
{{ log_email_form.csrf_token }}
{{ log_email_form.log_recipient(placeholder="destination@example.com") }}
{{ log_email_form.submit(class_="btn btn-primary") }}
</form>
</div>
</div>
<table class="table">
<thead>
<tr><th>Timestamp (UTC)</th><th>User</th><th>Action</th><th>Details</th><th>IP</th></tr>
</thead>
<tbody>
{% for e in pagination.items %}
<tr>
<td class="mono dim" style="font-size: .82rem; white-space: nowrap;">{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>{{ e.username or '—' }}</td>
<td><span class="mono" style="color: var(--accent);">{{ e.action }}</span></td>
<td class="dim">{{ e.details or '' }}</td>
<td class="mono dim">{{ e.ip_address or '' }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="dim" style="text-align: center; padding: 32px;">No audit entries yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% if pagination.pages > 1 %}
<div class="pagination">
{% if pagination.has_prev %}<a href="{{ url_for('backoffice.audit_log', page=pagination.prev_num) }}"> prev</a>{% endif %}
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
{% if p == pagination.page %}<span class="current">{{ p }}</span>
{% else %}<a href="{{ url_for('backoffice.audit_log', page=p) }}">{{ p }}</a>{% endif %}
{% else %}<span></span>{% endif %}
{% endfor %}
{% if pagination.has_next %}<a href="{{ url_for('backoffice.audit_log', page=pagination.next_num) }}">next </a>{% endif %}
</div>
{% endif %}
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="section-head">
<div>
<span class="section-tag">// console overview</span>
<h1>Operations dashboard</h1>
<p class="lead">System status as of <span class="mono">{{ today.isoformat() }}</span>.</p>
</div>
</div>
<div class="stat-grid">
<div class="stat stat-accent">
<div class="stat-label">Active alerts</div>
<div class="stat-value">{{ active_alerts }}</div>
</div>
<div class="stat">
<div class="stat-label">Users</div>
<div class="stat-value">{{ user_count }}</div>
</div>
<div class="stat stat-warn">
<div class="stat-label">Administrators</div>
<div class="stat-value">{{ admin_count }}</div>
</div>
</div>
<div style="display:grid; grid-template-columns: 1.2fr 1fr; gap: 20px;">
<section class="panel">
<header class="panel-header">
<h3>Expiring soonest</h3>
<a href="{{ url_for('backoffice.settings') }}" class="muted mono" style="font-size: .82rem;">settings →</a>
</header>
<div class="panel-body">
{% if expiring_soon %}
<table class="table" style="border: none;">
<thead>
<tr><th>Title</th><th>Expires</th><th>Days</th></tr>
</thead>
<tbody>
{% for a in expiring_soon %}
{% set days = (a.expiration_date - today).days %}
<tr>
<td><strong>{{ a.title }}</strong>{% if a.category %} <span class="alert-cat">{{ a.category }}</span>{% endif %}</td>
<td class="mono">{{ a.expiration_date.isoformat() }}</td>
<td class="mono" style="color: {% if days < 7 %}var(--danger){% elif days < 30 %}var(--amber){% else %}var(--accent){% endif %};">{{ days }}d</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="dim">No upcoming alerts.</p>
{% endif %}
</div>
</section>
<section class="panel">
<header class="panel-header"><h3>Recent activity</h3>
<a href="{{ url_for('backoffice.audit_log') }}" class="muted mono" style="font-size: .82rem;">full log →</a>
</header>
<div class="panel-body">
{% if recent_audit %}
<ul style="list-style: none; padding: 0; margin: 0; display: grid; gap: 8px;">
{% for entry in recent_audit %}
<li class="mono" style="font-size: .82rem; display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);">
<span><span style="color: var(--accent);">{{ entry.action }}</span> {% if entry.username %}<span class="dim">· {{ entry.username }}</span>{% endif %}{% if entry.details %}<br><span class="dim">{{ entry.details }}</span>{% endif %}</span>
<span class="dim" style="white-space: nowrap;">{{ entry.timestamp.strftime('%m-%d %H:%M') }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="dim">No activity yet.</p>
{% endif %}
</div>
</section>
</div>
<style>@media (max-width: 800px) { div[style*="grid-template-columns: 1.2fr 1fr"] { grid-template-columns: 1fr !important; } }</style>
{% endblock %}
+90
View File
@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}Mail configuration{% endblock %}
{% block content %}
<div class="section-head">
<div>
<span class="section-tag">// console mail server</span>
<h1>SMTP configuration</h1>
<p class="lead">External mail server used to dispatch alert reminders. Edits take effect immediately.</p>
</div>
</div>
<div style="display: grid; grid-template-columns: 1.4fr 1fr; gap: 20px;">
<section class="panel">
<header class="panel-header"><h3>Server settings</h3></header>
<div class="panel-body">
<form method="post" action="{{ url_for('backoffice.mail_config') }}" class="form" novalidate>
{{ form.csrf_token }}
<div class="form-grid-2">
<div style="grid-column: span 2;">
{{ form.smtp_host.label }}{{ form.smtp_host(placeholder="smtp.example.com") }}
{% for err in form.smtp_host.errors %}<div class="error">{{ err }}</div>{% endfor %}
</div>
<div>
{{ form.smtp_port.label }}{{ form.smtp_port() }}
{% for err in form.smtp_port.errors %}<div class="error">{{ err }}</div>{% endfor %}
</div>
<div>
{{ form.smtp_encryption.label }}{{ form.smtp_encryption() }}
<div class="help">587 → STARTTLS · 465 → SSL/TLS · 25 → none</div>
</div>
<div>
{{ form.smtp_username.label }}{{ form.smtp_username(autocomplete="off") }}
</div>
<div>
{{ form.smtp_password.label }}{{ form.smtp_password(autocomplete="new-password") }}
<div class="help">Leave blank to keep the current password.</div>
</div>
<div>
{{ form.smtp_from_address.label }}{{ form.smtp_from_address() }}
</div>
<div>
{{ form.smtp_from_name.label }}{{ form.smtp_from_name() }}
</div>
</div>
<hr>
<h4 style="margin: 4px 0 2px; font-family: var(--font-mono); color: var(--text-secondary);">Sent-folder copy (IMAP)</h4>
<p class="help" style="margin-top: 0;">When enabled, a copy of every message the platform sends is saved to the mailbox's Sent folder over IMAP, so it appears in your normal mail client.</p>
<div class="check-row" style="margin-bottom: 12px;">{{ form.imap_enabled() }} {{ form.imap_enabled.label }}</div>
<div class="form-grid-2">
<div style="grid-column: span 2;">
{{ form.imap_host.label }}{{ form.imap_host(placeholder="imap.purelymail.com") }}
{% for err in form.imap_host.errors %}<div class="error">{{ err }}</div>{% endfor %}
</div>
<div>
{{ form.imap_port.label }}{{ form.imap_port(placeholder="993") }}
{% for err in form.imap_port.errors %}<div class="error">{{ err }}</div>{% endfor %}
</div>
<div>
{{ form.imap_sent_folder.label }}{{ form.imap_sent_folder(placeholder="Sent") }}
<div class="help">Usually <code>Sent</code>.</div>
</div>
<div>
{{ form.imap_username.label }}{{ form.imap_username(autocomplete="off") }}
</div>
<div>
{{ form.imap_password.label }}{{ form.imap_password(autocomplete="new-password") }}
<div class="help">Blank keeps current; if never set, the SMTP password is used.</div>
</div>
</div>
{{ form.submit(class_="btn btn-primary") }}
</form>
</div>
</section>
<section class="panel">
<header class="panel-header"><h3>Send test email</h3></header>
<div class="panel-body">
<p class="muted" style="font-size: .9rem;">Confirms credentials, port, and encryption are correct.</p>
<form method="post" action="{{ url_for('backoffice.mail_test') }}" class="form" novalidate>
{{ test_form.csrf_token }}
<div>{{ test_form.test_recipient.label }}{{ test_form.test_recipient(placeholder="you@example.com") }}</div>
{{ test_form.submit(class_="btn btn-ghost btn-block") }}
</form>
</div>
</section>
</div>
<style>@media (max-width: 800px) { div[style*="grid-template-columns: 1.4fr 1fr"] { grid-template-columns: 1fr !important; } }</style>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}Mail log{% endblock %}
{% block content %}
<div class="section-head">
<div>
<span class="section-tag">// console mail log</span>
<h1>Mail log</h1>
<p class="lead">Every message the platform has dispatched, whether a copy reached the Sent folder, and its delivery result. Most-recent first.</p>
</div>
<div class="log-actions">
<a href="{{ url_for('backoffice.mail_log_export') }}" class="btn btn-ghost">⬇ Export CSV</a>
<form method="post" action="{{ url_for('backoffice.mail_log_email') }}" class="log-email-form" novalidate>
{{ log_email_form.csrf_token }}
{{ log_email_form.log_recipient(placeholder="destination@example.com") }}
{{ log_email_form.submit(class_="btn btn-primary") }}
</form>
</div>
</div>
<table class="table">
<thead>
<tr>
<th>Timestamp (UTC)</th><th>Kind</th><th>Recipients</th><th>Subject</th>
<th>Status</th><th>Sent copy</th><th>By</th><th>Info</th>
</tr>
</thead>
<tbody>
{% for m in pagination.items %}
<tr>
<td class="mono dim" style="font-size: .82rem; white-space: nowrap;">{{ m.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td><span class="mono" style="color: var(--info);">{{ m.kind }}</span></td>
<td class="dim" style="font-size: .85rem;">{{ m.recipients or '—' }}</td>
<td>{{ m.subject or '—' }}</td>
<td>
{% if m.status == 'sent' %}
<span class="status-badge status-active"><span class="led"></span>sent</span>
{% else %}
<span class="status-badge" style="color: var(--danger);"><span class="led" style="background: var(--danger);"></span>failed</span>
{% endif %}
</td>
<td>
{% if m.sent_copy == 'saved' %}
<span class="mono" style="color: var(--accent);">saved</span>
{% elif m.sent_copy == 'failed' %}
<span class="mono" style="color: var(--danger);">failed</span>
{% elif m.sent_copy == 'disabled' %}
<span class="mono dim">disabled</span>
{% else %}
<span class="mono dim"></span>
{% endif %}
</td>
<td class="mono dim" style="font-size: .82rem;">{{ m.triggered_by or 'system' }}</td>
<td class="dim" style="font-size: .8rem;">{{ m.info or '' }}</td>
</tr>
{% else %}
<tr><td colspan="8" class="dim" style="text-align: center; padding: 32px;">No mail has been sent yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% if pagination.pages > 1 %}
<div class="pagination">
{% if pagination.has_prev %}<a href="{{ url_for('backoffice.mail_log', page=pagination.prev_num) }}"> prev</a>{% endif %}
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
{% if p == pagination.page %}<span class="current">{{ p }}</span>
{% else %}<a href="{{ url_for('backoffice.mail_log', page=p) }}">{{ p }}</a>{% endif %}
{% else %}<span></span>{% endif %}
{% endfor %}
{% if pagination.has_next %}<a href="{{ url_for('backoffice.mail_log', page=pagination.next_num) }}">next </a>{% endif %}
</div>
{% endif %}
{% endblock %}
+45
View File
@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Settings{% endblock %}
{% block content %}
<div class="section-head">
<div>
<span class="section-tag">// console settings</span>
<h1>Global settings</h1>
<p class="lead">Default reminder cadence and scheduler controls.</p>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<section class="panel">
<header class="panel-header"><h3>Default reminder schedule</h3></header>
<div class="panel-body">
<form method="post" class="form" novalidate>
{{ form.csrf_token }}
<div>
{{ form.default_reminder_days.label }}{{ form.default_reminder_days() }}
<div class="help">When creating a new alert, this is the default. Existing alerts are unaffected.</div>
{% for err in form.default_reminder_days.errors %}<div class="error">{{ err }}</div>{% endfor %}
</div>
{{ form.submit(class_="btn btn-primary") }}
</form>
</div>
</section>
<section class="panel">
<header class="panel-header"><h3>Scheduler</h3></header>
<div class="panel-body">
<p>Reminder dispatch runs every <strong class="mono" style="color: var(--accent);">{{ scheduler_interval }}</strong> minute(s).</p>
<p class="muted" style="font-size: .9rem;">Configured via the <code>SCHEDULER_INTERVAL_MINUTES</code> environment variable. Restart the container to change it.</p>
<hr>
<p>You can trigger a check immediately to verify reminders dispatch correctly.</p>
<form method="post" action="{{ url_for('backoffice.run_check_now') }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-ghost" type="submit">▶ Run reminder check now</button>
</form>
</div>
</section>
</div>
<style>@media (max-width: 800px) { div[style*="grid-template-columns: 1fr 1fr"] { grid-template-columns: 1fr !important; } }</style>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}{{ 'Edit' if user else 'New' }} user{% endblock %}
{% block content %}
<div class="section-head">
<div>
<span class="section-tag">// section 03 {{ 'edit' if user else 'new' }} user</span>
<h1>{{ 'Edit user' if user else 'New user' }}</h1>
{% if user %}<p class="lead">Created <span class="mono">{{ user.created_at.strftime('%Y-%m-%d') }}</span></p>{% endif %}
</div>
<a href="{{ url_for('backoffice.users_list') }}" class="btn btn-ghost">← Back</a>
</div>
<div class="panel" style="max-width: 640px;">
<div class="panel-body">
<form method="post" class="form" novalidate>
{{ form.csrf_token }}
<div class="form-grid-2">
<div>{{ form.username.label }}{{ form.username() }}
{% for err in form.username.errors %}<div class="error">{{ err }}</div>{% endfor %}
</div>
<div>{{ form.email.label }}{{ form.email() }}
{% for err in form.email.errors %}<div class="error">{{ err }}</div>{% endfor %}
</div>
</div>
<div class="form-grid-2">
<div>{{ form.role.label }}{{ form.role() }}</div>
<div class="check-row" style="align-self: end;">{{ form.is_active() }} {{ form.is_active.label }}</div>
</div>
<div>
{{ form.password.label }}{{ form.password(autocomplete="new-password") }}
<div class="help">{% if user %}Leave blank to keep the existing password.{% else %}Min {{ config.PASSWORD_MIN_LENGTH }} characters.{% endif %}</div>
{% for err in form.password.errors %}<div class="error">{{ err }}</div>{% endfor %}
</div>
<div class="btn-row">
{{ form.submit(class_="btn btn-primary") }}
<a href="{{ url_for('backoffice.users_list') }}" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}Users{% endblock %}
{% block content %}
<div class="section-head">
<div>
<span class="section-tag">// section 03 users & permissions</span>
<h1>Users</h1>
<p class="lead">Manage accounts, roles, MFA, and lockouts.</p>
</div>
<a href="{{ url_for('backoffice.user_new') }}" class="btn btn-primary">+ New user</a>
</div>
<table class="table">
<thead>
<tr>
<th>Username</th><th>Email</th><th>Role</th><th>MFA</th><th>Status</th><th>Last login</th><th class="actions">Actions</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td><strong>{{ u.username }}</strong>{% if u.id == current_user.id %} <span class="dim mono" style="font-size: .72rem;">(you)</span>{% endif %}</td>
<td class="mono dim" style="font-size: .85rem;">{{ u.email }}</td>
<td><span class="role-badge role-{{ u.role }}">{{ u.role }}</span></td>
<td>
<span class="status-badge {{ 'status-active' if u.mfa_enabled else 'status-inactive' }}">
<span class="led"></span>{{ 'on' if u.mfa_enabled else 'off' }}
</span>
</td>
<td>
{% if u.is_locked %}
<span class="status-badge" style="color: var(--danger);"><span class="led" style="background: var(--danger);"></span>locked</span>
{% else %}
<span class="status-badge {{ 'status-active' if u.is_active else 'status-inactive' }}"><span class="led"></span>{{ 'active' if u.is_active else 'disabled' }}</span>
{% endif %}
</td>
<td class="mono dim" style="font-size: .82rem;">
{% if u.last_login_at %}{{ u.last_login_at.strftime('%Y-%m-%d %H:%M') }}{% else %}never{% endif %}
</td>
<td class="actions">
<a href="{{ url_for('backoffice.user_edit', user_id=u.id) }}" class="btn btn-ghost btn-sm">Edit</a>
{% if u.mfa_enabled %}
<form method="post" action="{{ url_for('backoffice.user_reset_mfa', user_id=u.id) }}" class="inline-form" data-confirm="Reset MFA for {{ u.username }}?">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-ghost btn-sm" type="submit">Reset MFA</button>
</form>
{% endif %}
{% if u.is_locked %}
<form method="post" action="{{ url_for('backoffice.user_unlock', user_id=u.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-ghost btn-sm" type="submit">Unlock</button>
</form>
{% endif %}
{% if u.id != current_user.id %}
<form method="post" action="{{ url_for('backoffice.user_delete', user_id=u.id) }}" class="inline-form" data-confirm="Permanently delete user '{{ u.username }}'?">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-danger btn-sm" type="submit">Delete</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+136
View File
@@ -0,0 +1,136 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light">
<title>{% block title %}{{ brand_name }}{% endblock %} — {{ brand_name }}{% if is_backoffice %} · backoffice{% endif %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<script>
// Theme bootstrap — set BEFORE first paint to avoid flash
(function () {
try {
var saved = localStorage.getItem('alerthub-theme');
var theme = saved || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.setAttribute('data-theme', theme);
} catch (_) {}
})();
</script>
</head>
<body class="{{ 'is-backoffice' if is_backoffice else 'is-frontoffice' }}">
<div class="grid-bg" aria-hidden="true"></div>
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="{{ url_for('main.index') if not is_backoffice else url_for('backoffice.dashboard') }}">
<span class="brand-mark">▮▮</span>
<span class="brand-name">{{ brand_name }}</span>
<span class="brand-tag">{{ 'BACKOFFICE' if is_backoffice else 'FRONTOFFICE' }}</span>
</a>
{% if current_user.is_authenticated and not session.get('mfa_pending') %}
<nav class="topbar-nav">
{% if not is_backoffice %}
<a href="{{ url_for('main.index') }}" class="navlink {% if request.endpoint == 'main.index' %}active{% endif %}">
<span class="dot"></span>View
</a>
<a href="{{ url_for('main.alerts_list') }}" class="navlink {% if request.endpoint and request.endpoint.startswith('main.alert') %}active{% endif %}">
<span class="dot"></span>Manage Alerts
</a>
{% if current_user.is_admin %}
<a href="{{ url_for('backoffice.dashboard') }}" class="navlink navlink-cross {% if is_backoffice %}active{% endif %}" title="Open the admin backoffice">
<span class="dot dot-admin"></span>Backoffice
</a>
{% endif %}
{% else %}
<a href="{{ url_for('main.index') }}" class="navlink navlink-cross" title="Back to the alerts frontoffice">
<span class="dot"></span>Frontoffice
</a>
<a href="{{ url_for('backoffice.dashboard') }}" class="navlink {% if request.endpoint == 'backoffice.dashboard' %}active{% endif %}">
<span class="dot"></span>Dashboard
</a>
<a href="{{ url_for('backoffice.users_list') }}" class="navlink {% if request.endpoint and request.endpoint.startswith('backoffice.user') %}active{% endif %}">
<span class="dot"></span>Users
</a>
<a href="{{ url_for('backoffice.mail_config') }}" class="navlink {% if request.endpoint and request.endpoint.startswith('backoffice.mail') %}active{% endif %}">
<span class="dot"></span>Mail
</a>
<a href="{{ url_for('backoffice.settings') }}" class="navlink {% if request.endpoint == 'backoffice.settings' %}active{% endif %}">
<span class="dot"></span>Settings
</a>
<a href="{{ url_for('backoffice.audit_log') }}" class="navlink {% if request.endpoint == 'backoffice.audit_log' %}active{% endif %}">
<span class="dot"></span>Audit
</a>
<a href="{{ url_for('backoffice.mail_log') }}" class="navlink {% if request.endpoint == 'backoffice.mail_log' %}active{% endif %}">
<span class="dot"></span>Mail Log
</a>
{% endif %}
</nav>
<div class="topbar-right">
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle theme" aria-label="Toggle theme">
<svg class="icon-sun" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
<a href="{{ url_for('auth.account') }}" class="user-chip" title="Account">
{% if current_user.has_avatar %}
<img class="user-avatar user-avatar-img" src="{{ url_for('auth.avatar', user_id=current_user.id) }}" alt="{{ current_user.username }}">
{% else %}
<span class="user-avatar">{{ current_user.initials }}</span>
{% endif %}
<span class="user-meta">
<span class="user-name">{{ current_user.username }}</span>
<span class="user-role role-{{ current_user.role }}">{{ current_user.role }}</span>
</span>
</a>
<form method="post" action="{{ url_for('auth.logout') }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-ghost" type="submit">Sign out</button>
</form>
</div>
{% else %}
<div class="topbar-right">
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle theme" aria-label="Toggle theme">
<svg class="icon-sun" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
</div>
{% endif %}
</div>
</header>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-stack">
{% for cat, msg in messages %}
<div class="flash flash-{{ cat }}">
<span class="flash-icon"></span>
<span class="flash-text">{{ msg }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
{% if request.endpoint != 'auth.login' %}
<footer class="footer">
<span class="kbd">{{ brand_name }}</span>
<span class="footer-sep">·</span>
<span>{{ 'admin console' if is_backoffice else 'alert console' }}</span>
<span class="footer-copy">{{ footer_text }}</span>
<span class="status-pill"><span class="led"></span>operational</span>
</footer>
{% endif %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}{{ code }} · Error{% endblock %}
{% block content %}
<div class="auth-shell">
<div class="auth-card" style="text-align: center;">
<div class="auth-prompt">error</div>
<h1 style="font-family: var(--font-mono); font-size: 3rem; color: var(--magenta);">{{ code }}</h1>
<p class="auth-sub">{{ message }}</p>
<a href="{{ url_for('main.index') if not is_backoffice else url_for('backoffice.dashboard') }}" class="btn btn-primary">← Return home</a>
</div>
</div>
{% endblock %}
+60
View File
@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}View · Alerts{% endblock %}
{% block content %}
<div class="section-head">
<div>
<span class="section-tag">// section 01 view</span>
<h1>Active alerts</h1>
<p class="lead">Live countdown to expiration. Reminders are dispatched automatically by email per the configured schedule.</p>
</div>
<div class="row-gap">
<span class="kbd">today: <strong style="color: var(--accent);">{{ today_iso }}</strong></span>
{% if current_user.is_admin %}
<a href="{{ url_for('main.alert_new') }}" class="btn btn-primary">+ New alert</a>
{% endif %}
</div>
</div>
{% if alerts %}
<div class="alerts-grid">
{% for a in alerts %}
{% set state = 'is-expired' if a.days_left < 0 else ('is-critical' if a.days_left < 7 else ('is-warning' if a.days_left < 30 else '')) %}
{% set state_text = 'EXPIRED' if a.days_left < 0 else ('CRITICAL' if a.days_left < 7 else ('WARNING' if a.days_left < 30 else 'NOMINAL')) %}
<article class="alert-card {{ state }}">
<header class="alert-head">
<h3 class="alert-title">{{ a.title }}</h3>
{% if a.category %}<span class="alert-cat">{{ a.category }}</span>{% endif %}
</header>
{% if a.description %}<p class="alert-desc">{{ a.description }}</p>{% endif %}
<div class="alert-message">
<strong>{{ a.title }}</strong> · expires {{ a.expiration_date }}
</div>
<div class="countdown" data-expires="{{ a.expiration_date }}">
<div class="countdown-segment"><span class="countdown-value"></span><span class="countdown-label">days</span></div>
<div class="countdown-segment"><span class="countdown-value"></span><span class="countdown-label">hours</span></div>
<div class="countdown-segment"><span class="countdown-value"></span><span class="countdown-label">min</span></div>
<div class="countdown-segment"><span class="countdown-value"></span><span class="countdown-label">sec</span></div>
</div>
<div class="alert-meta">
<span><span class="alert-status-dot"></span><span class="alert-state-text">{{ state_text }}</span></span>
<span>reminders: {{ a.reminder_days|join(', ') }}d</span>
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="empty">
<div class="empty-icon">▮▮</div>
<p><strong>No active alerts.</strong></p>
{% if current_user.is_admin %}
<p class="dim">Add your first one — license renewals, certificates, contracts, …</p>
<a href="{{ url_for('main.alert_new') }}" class="btn btn-primary">Create the first alert</a>
{% else %}
<p class="dim">Ask an administrator to create alerts for your team.</p>
{% endif %}
</div>
{% endif %}
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Sign in{% endblock %}
{% block content %}
<div class="auth-shell">
<div class="auth-card">
<div class="auth-prompt">authenticate</div>
<h1>Sign in to {{ brand_name }}</h1>
<p class="auth-sub">{% if is_backoffice %}Administrator console.{% else %}Alert operations console.{% endif %}</p>
<form method="post" class="form" novalidate>
{{ form.csrf_token }}
<div>
{{ form.username.label }}
{{ form.username(autocomplete="username", autofocus=True) }}
</div>
<div>
{{ form.password.label }}
{{ form.password(autocomplete="current-password") }}
</div>
{{ form.submit(class_="btn btn-primary btn-block") }}
</form>
</div>
</div>
{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}Verify identity{% endblock %}
{% block content %}
<div class="auth-shell">
<div class="auth-card">
<div class="auth-prompt">verify</div>
<h1>Two-factor authentication</h1>
<p class="auth-sub">Enter the 6-digit code from your authenticator app.</p>
<form method="post" class="form" novalidate>
{{ form.csrf_token }}
<div>
{{ form.code.label }}
{{ form.code(autocomplete="one-time-code", inputmode="numeric", pattern="[0-9]{6}", autofocus=True, maxlength=6) }}
</div>
{{ form.submit(class_="btn btn-primary btn-block") }}
</form>
</div>
</div>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Set up MFA{% endblock %}
{% block content %}
<div class="section-head">
<div>
<span class="section-tag">// account mfa</span>
<h1>Enable multi-factor authentication</h1>
<p class="lead">Scan the QR code with your authenticator (Google Authenticator, Authy, 1Password, …) and enter the 6-digit code to confirm.</p>
</div>
</div>
<div class="panel" style="max-width: 540px; margin: 0 auto;">
<div class="panel-body">
<img src="data:image/png;base64,{{ qr_b64 }}" alt="MFA QR code" class="mfa-qr">
<p class="dim" style="text-align:center; font-size: .82rem; margin: 16px 0 4px;">Manual key (if you can't scan):</p>
<div class="mfa-secret">{{ secret }}</div>
<hr>
<form method="post" class="form" novalidate>
{{ form.csrf_token }}
<div>
{{ form.code.label }}
{{ form.code(autocomplete="one-time-code", inputmode="numeric", pattern="[0-9]{6}", maxlength=6) }}
<div class="help">Enter the current 6-digit code shown in your authenticator.</div>
</div>
<div class="btn-row">
{{ form.submit(class_="btn btn-primary") }}
<a href="{{ url_for('auth.account') }}" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Initial setup{% endblock %}
{% block content %}
<div class="auth-shell">
<div class="auth-card">
<div class="auth-prompt">first-run setup</div>
<h1>Create administrator</h1>
<p class="auth-sub">No accounts exist yet. Create the first administrator to secure {{ brand_name }}.</p>
<form method="post" class="form" novalidate>
{{ form.csrf_token }}
<div>
{{ form.username.label }}
{{ form.username(autocomplete="username", autofocus=True) }}
{% for err in form.username.errors %}<div class="error">{{ err }}</div>{% endfor %}
</div>
<div>
{{ form.email.label }}
{{ form.email(autocomplete="email") }}
{% for err in form.email.errors %}<div class="error">{{ err }}</div>{% endfor %}
</div>
<div>
{{ form.password.label }}
{{ form.password(autocomplete="new-password") }}
{% for err in form.password.errors %}<div class="error">{{ err }}</div>{% endfor %}
</div>
<div>
{{ form.confirm.label }}
{{ form.confirm(autocomplete="new-password") }}
{% for err in form.confirm.errors %}<div class="error">{{ err }}</div>{% endfor %}
</div>
{{ form.submit(class_="btn btn-primary btn-block") }}
</form>
</div>
</div>
{% endblock %}