This commit is contained in:
jpmvaz
2026-09-13 20:15:10 +01:00
commit 0072054fc7
39 changed files with 4414 additions and 0 deletions
@@ -0,0 +1,148 @@
{% extends "base.html" %}
{% set active = 'admin' %}
{% block title %}Administration - Birthday Manager{% endblock %}
{% block content %}
<!-- ======================= Users ======================= -->
<section class="card">
<h2>Users</h2>
<form method="post" action="{{ url_for('add_user') }}" class="entry-form">
<label>Username<input type="text" name="username" required></label>
<label>Password<input type="password" name="password" required minlength="4"></label>
<button class="btn btn-primary" type="submit">Add user</button>
</form>
<div class="table-wrap">
<table>
<thead><tr><th>Username</th><th>MFA</th><th>Created</th><th></th></tr></thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.username }}{% if u.id == user_id %} <span class="pill pill-ok">you</span>{% endif %}</td>
<td>{{ 'Enabled' if u.mfa_enabled else 'Off' }}</td>
<td>{{ u.created_at }}</td>
<td class="ta-right">
{% if u.id != user_id %}
<form method="post" action="{{ url_for('delete_user', uid=u.id) }}"
onsubmit="return confirm('Remove user {{ u.username }}?')">
<button class="btn btn-danger btn-sm" type="submit">Remove</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<!-- ======================= My account: password + MFA ======================= -->
<div class="two-col">
<section class="card">
<h2>Change my password</h2>
<form method="post" action="{{ url_for('change_password') }}" class="stack-form">
<label>Current password<input type="password" name="current" required autocomplete="current-password"></label>
<label>New password<input type="password" name="new" required minlength="4" autocomplete="new-password"></label>
<label>Confirm new password<input type="password" name="confirm" required minlength="4" autocomplete="new-password"></label>
<button class="btn btn-primary" type="submit">Change password</button>
</form>
</section>
<section class="card">
<h2>Two-factor authentication (MFA)</h2>
{% set me = users | selectattr('id', 'equalto', user_id) | first %}
{% if me and me.mfa_enabled %}
<p>MFA is <strong>enabled</strong> for your account. You'll be asked for a 6-digit code every time you sign in.</p>
<form method="post" action="{{ url_for('mfa_disable') }}"
onsubmit="return confirm('Disable two-factor authentication for your account?')">
<button class="btn btn-danger" type="submit">Disable MFA</button>
</form>
{% else %}
<p>Protect your account with a one-time code from an authenticator app (Google Authenticator, Authy, Microsoft Authenticator…).</p>
<a class="btn btn-primary" href="{{ url_for('mfa_setup') }}">Enable MFA</a>
{% endif %}
</section>
</div>
<!-- ======================= E-mail log ======================= -->
<section class="card">
<h2>E-mail log</h2>
{% if log %}
<div class="table-wrap log-wrap">
<table>
<thead><tr><th>Date &amp; time</th><th>Recipient</th><th>E-mail address</th><th>Subject</th><th>Status</th></tr></thead>
<tbody>
{% for l in log %}
<tr>
<td class="nowrap">{{ l.sent_at }}</td>
<td>{{ l.recipient_name }}</td>
<td>{{ l.recipient_email }}</td>
<td>{{ l.subject }}</td>
<td>
<span class="pill {{ 'pill-ok' if l.status == 'Sent' else 'pill-bad' }}"
{% if l.detail %}title="{{ l.detail }}"{% endif %}>{{ l.status }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted">No e-mails have been sent yet.</p>
{% endif %}
</section>
<!-- ======================= Images ======================= -->
<section class="card">
<h2>Images</h2>
<p class="muted">Upload images here, then place them inside a template with
<code>&lt;img src="/uploads/filename"&gt;</code>. Images are embedded inline in the e-mail when it is sent.</p>
<form method="post" action="{{ url_for('upload_image') }}" enctype="multipart/form-data" class="entry-form">
<input type="file" name="image" accept=".png,.jpg,.jpeg,.gif,.webp" required>
<button class="btn btn-primary" type="submit">Upload image</button>
</form>
{% if images %}
<div class="image-grid">
{% for img in images %}
<div class="image-tile">
<img src="{{ url_for('serve_upload', fname=img) }}" alt="{{ img }}">
<code class="small">/uploads/{{ img }}</code>
<form method="post" action="{{ url_for('delete_image', fname=img) }}"
onsubmit="return confirm('Delete this image?')">
<button class="btn btn-danger btn-sm" type="submit">Delete</button>
</form>
</div>
{% endfor %}
</div>
{% endif %}
</section>
<!-- ======================= Template zones ======================= -->
<div class="two-col">
<section class="card tpl-zone">
<h2>E-mail Template Male</h2>
<form method="post" action="{{ url_for('save_template') }}" class="stack-form">
<input type="hidden" name="gender" value="Male">
<label>Subject<input type="text" name="subject" value="{{ tpl_male.subject }}"></label>
<label>Body (HTML)
<textarea name="body" rows="10">{{ tpl_male.body }}</textarea>
</label>
<p class="muted small">Placeholders: <code>{first_name}</code> <code>{last_name}</code> <code>{age}</code> — insert images with <code>&lt;img src="/uploads/…"&gt;</code></p>
<button class="btn btn-primary" type="submit">Save Male template</button>
</form>
</section>
<section class="card tpl-zone">
<h2>E-Mail Template Female</h2>
<form method="post" action="{{ url_for('save_template') }}" class="stack-form">
<input type="hidden" name="gender" value="Female">
<label>Subject<input type="text" name="subject" value="{{ tpl_female.subject }}"></label>
<label>Body (HTML)
<textarea name="body" rows="10">{{ tpl_female.body }}</textarea>
</label>
<p class="muted small">Placeholders: <code>{first_name}</code> <code>{last_name}</code> <code>{age}</code> — insert images with <code>&lt;img src="/uploads/…"&gt;</code></p>
<button class="btn btn-primary" type="submit">Save Female template</button>
</form>
</section>
</div>
{% endblock %}
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Birthday Manager{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-mark">🎂</span>
<span class="brand-name">Birthday Manager</span>
</div>
{% if username %}
<nav class="nav">
<a href="{{ url_for('dashboard') }}" class="{{ 'active' if active == 'dashboard' }}">Dashboard</a>
<a href="{{ url_for('admin') }}" class="{{ 'active' if active == 'admin' }}">Administration</a>
</nav>
<div class="userbox">
<span class="userchip">{{ username }}</span>
<a class="btn btn-ghost" href="{{ url_for('logout') }}">Log out</a>
</div>
{% endif %}
</header>
<main class="page">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, msg in messages %}
<div class="flash flash-{{ category }}">{{ msg }}</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="footer">{{ footer }}</footer>
</body>
</html>
@@ -0,0 +1,118 @@
{% extends "base.html" %}
{% set active = 'dashboard' %}
{% block title %}Dashboard - Birthday Manager{% endblock %}
{% block content %}
<!-- ======================= Today's birthdays + Force Send ======================= -->
<section class="card today-banner">
<div class="today-left">
<p class="eyebrow">Today, {{ today.strftime('%A %d %B %Y') }}</p>
{% if today_list %}
<h1>🎉 Birthday{{ 's' if today_list|length > 1 }} today</h1>
<ul class="today-people">
{% for item in today_list %}
<li>
<strong>{{ item.row.first_name }} {{ item.row.last_name }}</strong>
turns {{ item.age }}
<span class="pill {{ 'pill-ok' if item.sent else 'pill-wait' }}">
{{ 'e-mail sent' if item.sent else 'e-mail pending' }}
</span>
</li>
{% endfor %}
</ul>
{% else %}
<h1>No birthdays today</h1>
<p class="muted">The next celebration is waiting on the calendar below.</p>
{% endif %}
</div>
<form method="post" action="{{ url_for('force_send') }}" class="today-right">
<button class="btn btn-accent btn-lg" type="submit" {{ 'disabled' if not today_list }}>Force Send</button>
<p class="muted small">Sends today's birthday e-mail{{ 's' if today_list|length != 1 }} again immediately.</p>
</form>
</section>
<!-- ======================= Add / remove entries ======================= -->
<section class="card">
<h2>Birthday entries</h2>
<form method="post" action="{{ url_for('add_birthday') }}" class="entry-form">
<label>First Name<input type="text" name="first_name" required></label>
<label>Last Name<input type="text" name="last_name" required></label>
<label>Date of Birth<input type="date" name="dob" required></label>
<label>Gender
<select name="gender" required>
<option value="" disabled selected>Select…</option>
<option>Male</option>
<option>Female</option>
</select>
</label>
<label>E-mail address<input type="email" name="email" required placeholder="needed to send the greeting"></label>
<button class="btn btn-primary" type="submit">Add entry</button>
</form>
{% if entries %}
<div class="table-wrap">
<table>
<thead>
<tr><th>First Name</th><th>Last Name</th><th>Date of Birth</th><th>Gender</th><th>E-mail</th><th></th></tr>
</thead>
<tbody>
{% for e in entries %}
<tr>
<td>{{ e.first_name }}</td>
<td>{{ e.last_name }}</td>
<td>{{ e.dob }}</td>
<td>{{ e.gender }}</td>
<td>{{ e.email }}</td>
<td class="ta-right">
<form method="post" action="{{ url_for('delete_birthday', bid=e.id) }}"
onsubmit="return confirm('Remove {{ e.first_name }} {{ e.last_name }}?')">
<button class="btn btn-danger btn-sm" type="submit">Remove</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted">No entries yet. Add the first birthday above.</p>
{% endif %}
</section>
<!-- ======================= Yearly calendar ======================= -->
<section class="card">
<div class="cal-head">
<h2>Yearly overview</h2>
<div class="cal-nav">
<a class="btn btn-ghost btn-sm" href="{{ url_for('dashboard', year=year-1) }}">&larr; {{ year - 1 }}</a>
<span class="cal-year">{{ year }}</span>
<a class="btn btn-ghost btn-sm" href="{{ url_for('dashboard', year=year+1) }}">{{ year + 1 }} &rarr;</a>
</div>
</div>
<div class="year-grid">
{% for month in months %}
<div class="month">
<h3>{{ month.name }}</h3>
<table class="mini-cal">
<thead><tr><th>Mo</th><th>Tu</th><th>We</th><th>Th</th><th>Fr</th><th>Sa</th><th>Su</th></tr></thead>
<tbody>
{% for week in month.weeks %}
<tr>
{% for cell in week %}
<td class="{{ 'bday' if cell.names }} {{ 'today' if cell.today }}"
{% if cell.names %}title="{{ cell.names | join(', ') }}"{% endif %}>
{{ cell.day }}
{% if cell.names %}<span class="dot"></span>{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
</div>
<p class="muted small">Days marked with a dot have a birthday — hover to see who. Today is outlined.</p>
</section>
{% endblock %}
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Sign in - Birthday Manager{% endblock %}
{% block content %}
<div class="auth-card">
<h1>Sign in</h1>
<p class="muted">Birthday Manager</p>
<form method="post">
<label>Username
<input type="text" name="username" required autofocus autocomplete="username">
</label>
<label>Password
<input type="password" name="password" required autocomplete="current-password">
</label>
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
</form>
</div>
{% endblock %}
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% set active = 'admin' %}
{% block title %}Enable two-factor authentication{% endblock %}
{% block content %}
<div class="auth-card wide">
<h1>Enable two-factor authentication</h1>
<p class="muted">Scan this QR code with Google Authenticator, Microsoft Authenticator, Authy or any TOTP app, then enter the 6-digit code it shows to confirm.</p>
<div class="qr-wrap">
<img alt="MFA QR code" src="data:image/png;base64,{{ qr }}">
<p class="muted small">Can't scan? Enter this key manually: <code>{{ secret }}</code></p>
</div>
<form method="post">
<label>Authentication code
<input type="text" name="code" inputmode="numeric" maxlength="7" required autofocus autocomplete="one-time-code">
</label>
<div class="row-gap">
<button class="btn btn-primary" type="submit">Confirm &amp; enable</button>
<a class="btn btn-ghost" href="{{ url_for('admin') }}">Cancel</a>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Two-factor authentication{% endblock %}
{% block content %}
<div class="auth-card">
<h1>Two-factor code</h1>
<p class="muted">Enter the 6-digit code from your authenticator app.</p>
<form method="post">
<label>Authentication code
<input type="text" name="code" inputmode="numeric" pattern="[0-9 ]*" maxlength="7" required autofocus autocomplete="one-time-code">
</label>
<button class="btn btn-primary btn-block" type="submit">Verify</button>
</form>
</div>
{% endblock %}