This commit is contained in:
jpmvaz
2026-09-13 20:09:20 +01:00
commit 9b5cc30fb4
482 changed files with 57569 additions and 0 deletions
@@ -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 %}
@@ -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 %}
@@ -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 %}
@@ -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 %}
@@ -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 %}
@@ -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 %}
@@ -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 %}
@@ -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 %}
@@ -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 %}
@@ -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 %}
@@ -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 %}