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,9 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV DATA_DIR=/data PORT=8080
VOLUME /data
EXPOSE 8080
CMD ["python", "app.py"]
+90
View File
@@ -0,0 +1,90 @@
# Birthday Manager
Self-hosted birthday reminder application that automatically e-mails people on their birthday.
© 2026 Martinhal IT - Joao Vaz - Version 1.1
## Features
**Page 1 — Dashboard**
- Banner showing whose birthday it is today (based on system date/time) with a **Force Send** button to resend the e-mail
- Add / remove birthday entries: First Name, Last Name, Date of Birth, Gender, E-mail address
- Yearly calendar overview of all entries (hover a marked day to see the names; browse other years)
**Page 2 — Administration**
- Add / remove application users
- Every user can enable/disable MFA (TOTP — Google Authenticator, Authy, etc.) and change their own password
- Full e-mail log: who received mail, at what date & time, and whether it succeeded
- Two template zones: **E-mail Template Male** and **E-Mail Template Female**, with placeholders `{first_name}`, `{last_name}`, `{age}`
- Image upload — reference an uploaded image inside a template with `<img src="/uploads/filename">` and it is embedded inline in the outgoing e-mail
Automatic sending: a scheduler runs every day at `SEND_HOUR` (default 08:00) and e-mails everyone whose birthday it is, using the template matching their gender. Duplicates are avoided (one automatic mail per person per day); **Force Send** always resends. February 29 birthdays are celebrated on February 28 in non-leap years.
Every successfully sent e-mail is also copied to the mailbox's **Sent** folder via IMAP, so it shows up in your normal mail client.
## Mail provider
The app comes pre-configured for **Purelymail**:
| | Server | Port | Security |
|---|---|---|---|
| Outgoing (SMTP) | `smtp.purelymail.com` | `465` | SSL/TLS |
| Incoming (IMAP, Sent-folder copy) | `imap.purelymail.com` | `993` | SSL/TLS |
You only need to fill in `SMTP_USER`, `SMTP_PASS` and `SMTP_FROM` with your Purelymail mailbox credentials. If your network only supports STARTTLS for SMTP, set `SMTP_PORT=587` and `SMTP_SECURITY=starttls` instead. Any other provider works too — just override the host/port variables.
## Quick start (Docker — recommended)
1. Edit `docker-compose.yml` and fill in your Purelymail credentials (`SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`) plus a random `SECRET_KEY` and your timezone.
2. Run:
```bash
docker compose up -d --build
```
3. Open http://localhost:8080 and log in with **admin / admin** — then change the password on the Administration page immediately.
All data (SQLite database + uploaded images) lives in `./data`, so back up that folder.
## Quick start (plain Python)
```bash
pip install -r requirements.txt
# Purelymail servers are the built-in defaults - only credentials are needed
export SMTP_USER=you@yourdomain.com
export SMTP_PASS=your-purelymail-password
export SMTP_FROM=you@yourdomain.com
export SEND_HOUR=8
export SECRET_KEY=$(python -c "import secrets;print(secrets.token_hex(32))")
python app.py
```
The app listens on port 8080 (`PORT` env var to change). Data is stored in `./data` (`DATA_DIR` env var to change).
## Environment variables
| Variable | Default | Purpose |
|---|---|---|
| `SMTP_HOST` | `smtp.purelymail.com` | SMTP server |
| `SMTP_PORT` | `465` | SMTP port |
| `SMTP_USER` | | SMTP username / mailbox |
| `SMTP_PASS` | | SMTP password |
| `SMTP_FROM` | `SMTP_USER` | From address |
| `SMTP_SECURITY` | `ssl` | `ssl`, `starttls` or `none` |
| `IMAP_HOST` | `imap.purelymail.com` | IMAP server for the Sent-folder copy |
| `IMAP_PORT` | `993` | IMAP port (SSL/TLS) |
| `IMAP_USER` | `SMTP_USER` | IMAP username |
| `IMAP_PASS` | `SMTP_PASS` | IMAP password |
| `IMAP_SENT_FOLDER` | `Sent` | Mailbox folder sent mail is stored in |
| `SEND_HOUR` | `8` | Hour (023) the daily automatic send runs |
| `SECRET_KEY` | random | Flask session key — set it so logins survive restarts |
| `PORT` | `8080` | HTTP port |
| `DATA_DIR` | `./data` | Where the database and uploads are stored |
## Notes
- The e-mail address field on entries is required because it is where the birthday greeting is sent.
- Failed sends (e.g. SMTP misconfigured) are also recorded in the e-mail log with the error as tooltip on the "Failed" status. If a mail is delivered but the IMAP copy to the Sent folder fails, the mail is still logged as Sent and the IMAP error appears as a tooltip.
- Run behind a reverse proxy (nginx / Caddy / Traefik) with HTTPS if you expose it beyond your LAN.
+647
View File
@@ -0,0 +1,647 @@
"""
Birthday Manager - self-hosted birthday reminder & e-mail application
(c) 2026 Martinhal IT - Joao Vaz - Version 1.1
"""
import base64
import calendar as cal
import imaplib
import io
import os
import re
import secrets
import smtplib
import sqlite3
import uuid
from datetime import date, datetime
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from functools import wraps
import pyotp
import qrcode
from apscheduler.schedulers.background import BackgroundScheduler
from flask import (Flask, flash, g, redirect, render_template, request,
send_from_directory, session, url_for)
from werkzeug.security import check_password_hash, generate_password_hash
from werkzeug.utils import secure_filename
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(BASE_DIR, "data"))
UPLOAD_DIR = os.path.join(DATA_DIR, "uploads")
DB_PATH = os.path.join(DATA_DIR, "birthday.db")
ALLOWED_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
os.makedirs(UPLOAD_DIR, exist_ok=True)
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32))
app.config["MAX_CONTENT_LENGTH"] = 8 * 1024 * 1024 # 8 MB uploads
APP_VERSION = "Version 1.1"
FOOTER_TEXT = "\u00a9 2026 Martinhal IT - Joao Vaz - " + APP_VERSION
# ---------------------------------------------------------------- database
def get_db():
if "db" not in g:
g.db = sqlite3.connect(DB_PATH)
g.db.row_factory = sqlite3.Row
g.db.execute("PRAGMA foreign_keys = ON")
return g.db
@app.teardown_appcontext
def close_db(_exc):
db = g.pop("db", None)
if db is not None:
db.close()
def db_connect():
"""Standalone connection for use outside a request (scheduler)."""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
DEFAULT_TEMPLATE_MALE = (
"<p>Dear {first_name} {last_name},</p>"
"<p>Happy Birthday! Wishing you a fantastic day and a wonderful year ahead. "
"Congratulations on turning {age}!</p>"
"<p>Best regards,<br>Martinhal IT</p>"
)
DEFAULT_TEMPLATE_FEMALE = (
"<p>Dear {first_name} {last_name},</p>"
"<p>Happy Birthday! Wishing you a beautiful day filled with joy. "
"Congratulations on turning {age}!</p>"
"<p>Warm regards,<br>Martinhal IT</p>"
)
def init_db():
conn = db_connect()
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
mfa_secret TEXT,
mfa_enabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS birthdays (
id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
dob TEXT NOT NULL, -- YYYY-MM-DD
gender TEXT NOT NULL, -- Male / Female
email TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS email_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient_name TEXT NOT NULL,
recipient_email TEXT NOT NULL,
subject TEXT NOT NULL,
sent_at TEXT NOT NULL, -- YYYY-MM-DD HH:MM:SS
status TEXT NOT NULL, -- Sent / Failed
detail TEXT
);
CREATE TABLE IF NOT EXISTS templates (
gender TEXT PRIMARY KEY, -- Male / Female
subject TEXT NOT NULL,
body TEXT NOT NULL
);
"""
)
# Seed default admin user
if conn.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0:
conn.execute(
"INSERT INTO users (username, password_hash, created_at) VALUES (?,?,?)",
("admin", generate_password_hash("admin"),
datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
)
print(" * Created default user 'admin' with password 'admin' - change it!")
# Seed default templates
for gender, body in (("Male", DEFAULT_TEMPLATE_MALE),
("Female", DEFAULT_TEMPLATE_FEMALE)):
conn.execute(
"INSERT OR IGNORE INTO templates (gender, subject, body) VALUES (?,?,?)",
(gender, "Happy Birthday, {first_name}!", body),
)
conn.commit()
conn.close()
# ---------------------------------------------------------------- auth
def login_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if not session.get("user_id"):
return redirect(url_for("login", next=request.path))
return view(*args, **kwargs)
return wrapped
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
user = get_db().execute(
"SELECT * FROM users WHERE username = ?", (username,)
).fetchone()
if user and check_password_hash(user["password_hash"], password):
if user["mfa_enabled"]:
session.clear()
session["mfa_pending_user"] = user["id"]
return redirect(url_for("mfa_verify"))
session.clear()
session["user_id"] = user["id"]
session["username"] = user["username"]
return redirect(url_for("dashboard"))
flash("Invalid username or password.", "error")
return render_template("login.html", footer=FOOTER_TEXT)
@app.route("/mfa-verify", methods=["GET", "POST"])
def mfa_verify():
pending = session.get("mfa_pending_user")
if not pending:
return redirect(url_for("login"))
if request.method == "POST":
code = request.form.get("code", "").strip().replace(" ", "")
user = get_db().execute("SELECT * FROM users WHERE id = ?", (pending,)).fetchone()
if user and user["mfa_secret"] and pyotp.TOTP(user["mfa_secret"]).verify(code, valid_window=1):
session.clear()
session["user_id"] = user["id"]
session["username"] = user["username"]
return redirect(url_for("dashboard"))
flash("Invalid authentication code.", "error")
return render_template("mfa_verify.html", footer=FOOTER_TEXT)
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
# ---------------------------------------------------------------- helpers
def calc_age(dob: date, on: date) -> int:
years = on.year - dob.year
if (on.month, on.day) < (dob.month, dob.day):
years -= 1
return years
def birthday_matches_today(dob: date, today: date) -> bool:
"""Feb 29 birthdays are celebrated on Feb 28 in non-leap years."""
if dob.month == 2 and dob.day == 29 and not cal.isleap(today.year):
return today.month == 2 and today.day == 28
return dob.month == today.month and dob.day == today.day
def todays_birthdays(conn):
today = date.today()
rows = conn.execute("SELECT * FROM birthdays").fetchall()
result = []
for r in rows:
dob = datetime.strptime(r["dob"], "%Y-%m-%d").date()
if birthday_matches_today(dob, today):
result.append((r, calc_age(dob, today)))
return result
def render_placeholders(text: str, row, age: int) -> str:
return (text.replace("{first_name}", row["first_name"])
.replace("{last_name}", row["last_name"])
.replace("{age}", str(age)))
def smtp_settings():
# Defaults are pre-configured for Purelymail (smtp.purelymail.com:465 SSL/TLS).
# If your client only supports STARTTLS, set SMTP_PORT=587 and SMTP_SECURITY=starttls.
return {
"host": os.environ.get("SMTP_HOST", "smtp.purelymail.com"),
"port": int(os.environ.get("SMTP_PORT", "465")),
"user": os.environ.get("SMTP_USER", ""),
"password": os.environ.get("SMTP_PASS", ""),
"sender": os.environ.get("SMTP_FROM", os.environ.get("SMTP_USER", "birthday@localhost")),
"security": os.environ.get("SMTP_SECURITY", "ssl").lower(), # ssl / starttls / none
}
def imap_settings():
# Defaults are pre-configured for Purelymail (imap.purelymail.com:993 SSL/TLS).
# IMAP credentials fall back to the SMTP ones if not set separately.
return {
"host": os.environ.get("IMAP_HOST", "imap.purelymail.com"),
"port": int(os.environ.get("IMAP_PORT", "993")),
"user": os.environ.get("IMAP_USER", os.environ.get("SMTP_USER", "")),
"password": os.environ.get("IMAP_PASS", os.environ.get("SMTP_PASS", "")),
"folder": os.environ.get("IMAP_SENT_FOLDER", "Sent"),
}
def save_to_sent_folder(msg) -> str:
"""Append the sent message to the mailbox's Sent folder via IMAP.
Returns '' on success or an error description on failure."""
cfg = imap_settings()
if not cfg["user"]:
return "IMAP not configured - message not copied to Sent folder"
try:
imap = imaplib.IMAP4_SSL(cfg["host"], cfg["port"], timeout=20)
imap.login(cfg["user"], cfg["password"])
folder = cfg["folder"]
# Create the folder if the server doesn't have it yet
status, _ = imap.select(folder)
if status != "OK":
imap.create(folder)
imap.append(folder, r"(\Seen)", imaplib.Time2Internaldate(datetime.now().timestamp()),
msg.as_bytes())
imap.logout()
return ""
except Exception as exc:
return f"Delivered, but copy to Sent folder failed: {exc}"
IMG_SRC_RE = re.compile(r'src="/uploads/([^"]+)"')
def build_email(row, age: int, conn) -> MIMEMultipart:
tpl = conn.execute(
"SELECT * FROM templates WHERE gender = ?", (row["gender"],)
).fetchone()
subject = render_placeholders(tpl["subject"], row, age)
body_html = render_placeholders(tpl["body"], row, age)
msg = MIMEMultipart("related")
msg["Subject"] = subject
msg["From"] = smtp_settings()["sender"]
msg["To"] = row["email"]
# Embed uploaded images referenced in the template as inline attachments
inline = []
def to_cid(match):
fname = match.group(1)
path = os.path.join(UPLOAD_DIR, secure_filename(fname))
if os.path.isfile(path):
cid = uuid.uuid4().hex
inline.append((path, cid))
return f'src="cid:{cid}"'
return match.group(0)
body_html = IMG_SRC_RE.sub(to_cid, body_html)
alt = MIMEMultipart("alternative")
alt.attach(MIMEText(re.sub(r"<[^>]+>", " ", body_html), "plain", "utf-8"))
alt.attach(MIMEText(body_html, "html", "utf-8"))
msg.attach(alt)
for path, cid in inline:
with open(path, "rb") as f:
img = MIMEImage(f.read())
img.add_header("Content-ID", f"<{cid}>")
img.add_header("Content-Disposition", "inline", filename=os.path.basename(path))
msg.attach(img)
return msg
def send_birthday_email(row, age: int, conn) -> None:
cfg = smtp_settings()
msg = build_email(row, age, conn)
status, detail = "Sent", ""
try:
if not cfg["host"]:
raise RuntimeError("SMTP is not configured (set SMTP_HOST etc.)")
if cfg["security"] == "ssl":
server = smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=20)
else:
server = smtplib.SMTP(cfg["host"], cfg["port"], timeout=20)
if cfg["security"] == "starttls":
server.starttls()
if cfg["user"]:
server.login(cfg["user"], cfg["password"])
server.send_message(msg)
server.quit()
detail = save_to_sent_folder(msg) # keep a copy in the mailbox Sent folder
except Exception as exc: # log failures too
status, detail = "Failed", str(exc)
conn.execute(
"INSERT INTO email_log (recipient_name, recipient_email, subject, sent_at, status, detail)"
" VALUES (?,?,?,?,?,?)",
(f'{row["first_name"]} {row["last_name"]}', row["email"], msg["Subject"],
datetime.now().strftime("%Y-%m-%d %H:%M:%S"), status, detail),
)
conn.commit()
def already_sent_today(conn, row) -> bool:
today = date.today().strftime("%Y-%m-%d")
hit = conn.execute(
"SELECT 1 FROM email_log WHERE recipient_email = ? AND status = 'Sent'"
" AND sent_at LIKE ?", (row["email"], today + "%"),
).fetchone()
return hit is not None
def daily_birthday_job():
conn = db_connect()
try:
for row, age in todays_birthdays(conn):
if not already_sent_today(conn, row):
send_birthday_email(row, age, conn)
finally:
conn.close()
# ---------------------------------------------------------------- page 1
MONTH_NAMES = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"]
def build_year_calendar(conn, year: int):
"""Return list of month dicts with week grids; birthday days carry names."""
rows = conn.execute("SELECT * FROM birthdays").fetchall()
marks = {}
for r in rows:
dob = datetime.strptime(r["dob"], "%Y-%m-%d").date()
m, d = dob.month, dob.day
if m == 2 and d == 29 and not cal.isleap(year):
d = 28
marks.setdefault((m, d), []).append(f'{r["first_name"]} {r["last_name"]}')
months = []
c = cal.Calendar(firstweekday=0) # Monday
today = date.today()
for m in range(1, 13):
weeks = []
for week in c.monthdayscalendar(year, m):
weeks.append([
{
"day": d or "",
"names": marks.get((m, d), []) if d else [],
"today": bool(d) and today.year == year and today.month == m and today.day == d,
} for d in week
])
months.append({"name": MONTH_NAMES[m - 1], "weeks": weeks})
return months
@app.route("/", methods=["GET"])
@login_required
def dashboard():
conn = get_db()
year = request.args.get("year", type=int) or date.today().year
entries = conn.execute(
"SELECT * FROM birthdays ORDER BY substr(dob,6) ASC"
).fetchall()
today_list = [
{"row": r, "age": a, "sent": already_sent_today(conn, r)}
for r, a in todays_birthdays(conn)
]
return render_template(
"dashboard.html",
footer=FOOTER_TEXT,
username=session.get("username"),
entries=entries,
today_list=today_list,
today=date.today(),
months=build_year_calendar(conn, year),
year=year,
)
@app.route("/birthdays/add", methods=["POST"])
@login_required
def add_birthday():
f = request.form
first = f.get("first_name", "").strip()
last = f.get("last_name", "").strip()
dob = f.get("dob", "").strip()
gender = f.get("gender", "").strip()
email = f.get("email", "").strip()
try:
datetime.strptime(dob, "%Y-%m-%d")
assert first and last and email and gender in ("Male", "Female")
except (ValueError, AssertionError):
flash("Please fill in all fields with valid values.", "error")
return redirect(url_for("dashboard"))
get_db().execute(
"INSERT INTO birthdays (first_name, last_name, dob, gender, email) VALUES (?,?,?,?,?)",
(first, last, dob, gender, email),
)
get_db().commit()
flash(f"Added {first} {last}.", "ok")
return redirect(url_for("dashboard"))
@app.route("/birthdays/<int:bid>/delete", methods=["POST"])
@login_required
def delete_birthday(bid):
get_db().execute("DELETE FROM birthdays WHERE id = ?", (bid,))
get_db().commit()
flash("Entry removed.", "ok")
return redirect(url_for("dashboard"))
@app.route("/force-send", methods=["POST"])
@login_required
def force_send():
conn = get_db()
todays = todays_birthdays(conn)
if not todays:
flash("Nobody has a birthday today - nothing to send.", "error")
else:
for row, age in todays:
send_birthday_email(row, age, conn)
flash(f"Birthday e-mail resent to {len(todays)} recipient(s). Check the log on the Administration page.", "ok")
return redirect(url_for("dashboard"))
# ---------------------------------------------------------------- page 2
@app.route("/admin")
@login_required
def admin():
conn = get_db()
users = conn.execute("SELECT id, username, mfa_enabled, created_at FROM users ORDER BY username").fetchall()
log = conn.execute("SELECT * FROM email_log ORDER BY sent_at DESC, id DESC LIMIT 500").fetchall()
tpl_male = conn.execute("SELECT * FROM templates WHERE gender='Male'").fetchone()
tpl_female = conn.execute("SELECT * FROM templates WHERE gender='Female'").fetchone()
images = sorted(os.listdir(UPLOAD_DIR))
return render_template(
"admin.html",
footer=FOOTER_TEXT,
username=session.get("username"),
user_id=session.get("user_id"),
users=users,
log=log,
tpl_male=tpl_male,
tpl_female=tpl_female,
images=images,
)
@app.route("/users/add", methods=["POST"])
@login_required
def add_user():
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if not username or len(password) < 4:
flash("Username and a password of at least 4 characters are required.", "error")
return redirect(url_for("admin"))
try:
get_db().execute(
"INSERT INTO users (username, password_hash, created_at) VALUES (?,?,?)",
(username, generate_password_hash(password),
datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
)
get_db().commit()
flash(f"User '{username}' created.", "ok")
except sqlite3.IntegrityError:
flash("That username already exists.", "error")
return redirect(url_for("admin"))
@app.route("/users/<int:uid>/delete", methods=["POST"])
@login_required
def delete_user(uid):
conn = get_db()
if uid == session.get("user_id"):
flash("You cannot delete the account you are logged in with.", "error")
elif conn.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] <= 1:
flash("At least one user must remain.", "error")
else:
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
conn.commit()
flash("User removed.", "ok")
return redirect(url_for("admin"))
@app.route("/account/password", methods=["POST"])
@login_required
def change_password():
current = request.form.get("current", "")
new = request.form.get("new", "")
confirm = request.form.get("confirm", "")
conn = get_db()
user = conn.execute("SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone()
if not check_password_hash(user["password_hash"], current):
flash("Current password is incorrect.", "error")
elif len(new) < 4:
flash("New password must be at least 4 characters.", "error")
elif new != confirm:
flash("New passwords do not match.", "error")
else:
conn.execute("UPDATE users SET password_hash = ? WHERE id = ?",
(generate_password_hash(new), user["id"]))
conn.commit()
flash("Password changed.", "ok")
return redirect(url_for("admin"))
@app.route("/account/mfa/setup", methods=["GET", "POST"])
@login_required
def mfa_setup():
conn = get_db()
user = conn.execute("SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone()
if request.method == "POST":
code = request.form.get("code", "").strip().replace(" ", "")
secret = session.get("mfa_setup_secret")
if secret and pyotp.TOTP(secret).verify(code, valid_window=1):
conn.execute("UPDATE users SET mfa_secret = ?, mfa_enabled = 1 WHERE id = ?",
(secret, user["id"]))
conn.commit()
session.pop("mfa_setup_secret", None)
flash("Two-factor authentication is now enabled.", "ok")
return redirect(url_for("admin"))
flash("That code did not match. Scan the QR code and try again.", "error")
secret = session.get("mfa_setup_secret") or pyotp.random_base32()
session["mfa_setup_secret"] = secret
uri = pyotp.TOTP(secret).provisioning_uri(
name=user["username"], issuer_name="Birthday Manager")
buf = io.BytesIO()
qrcode.make(uri).save(buf, format="PNG")
qr_b64 = base64.b64encode(buf.getvalue()).decode()
return render_template("mfa_setup.html", footer=FOOTER_TEXT,
username=session.get("username"),
qr=qr_b64, secret=secret)
@app.route("/account/mfa/disable", methods=["POST"])
@login_required
def mfa_disable():
conn = get_db()
conn.execute("UPDATE users SET mfa_enabled = 0, mfa_secret = NULL WHERE id = ?",
(session["user_id"],))
conn.commit()
flash("Two-factor authentication disabled for your account.", "ok")
return redirect(url_for("admin"))
@app.route("/templates/save", methods=["POST"])
@login_required
def save_template():
gender = request.form.get("gender")
if gender not in ("Male", "Female"):
flash("Unknown template.", "error")
return redirect(url_for("admin"))
subject = request.form.get("subject", "").strip() or "Happy Birthday, {first_name}!"
body = request.form.get("body", "").strip()
get_db().execute("UPDATE templates SET subject = ?, body = ? WHERE gender = ?",
(subject, body, gender))
get_db().commit()
flash(f"E-mail template ({gender}) saved.", "ok")
return redirect(url_for("admin"))
@app.route("/images/upload", methods=["POST"])
@login_required
def upload_image():
file = request.files.get("image")
if not file or not file.filename:
flash("Choose an image file to upload.", "error")
return redirect(url_for("admin"))
name = secure_filename(file.filename)
ext = os.path.splitext(name)[1].lower()
if ext not in ALLOWED_IMAGE_EXT:
flash("Only PNG, JPG, GIF or WEBP images are allowed.", "error")
return redirect(url_for("admin"))
unique = f"{os.path.splitext(name)[0]}-{uuid.uuid4().hex[:6]}{ext}"
file.save(os.path.join(UPLOAD_DIR, unique))
flash(f"Image uploaded. Insert it in a template with: <img src=\"/uploads/{unique}\">", "ok")
return redirect(url_for("admin"))
@app.route("/images/<path:fname>/delete", methods=["POST"])
@login_required
def delete_image(fname):
path = os.path.join(UPLOAD_DIR, secure_filename(fname))
if os.path.isfile(path):
os.remove(path)
flash("Image deleted.", "ok")
return redirect(url_for("admin"))
@app.route("/uploads/<path:fname>")
@login_required
def serve_upload(fname):
return send_from_directory(UPLOAD_DIR, secure_filename(fname))
# ---------------------------------------------------------------- startup
init_db()
scheduler = BackgroundScheduler(daemon=True)
send_hour = int(os.environ.get("SEND_HOUR", "8"))
scheduler.add_job(daily_birthday_job, "cron", hour=send_hour, minute=0,
id="daily-birthday-mail")
scheduler.start()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8080")), debug=False)
@@ -0,0 +1,30 @@
services:
birthday-manager:
build: .
container_name: birthday-manager
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./data:/data
environment:
# --- Purelymail account (fill in your mailbox credentials) ---
SMTP_USER: "you@yourdomain.com"
SMTP_PASS: "your-purelymail-password"
SMTP_FROM: "you@yourdomain.com"
# --- Outgoing mail (pre-configured for Purelymail, SSL/TLS) ---
SMTP_HOST: "smtp.purelymail.com"
SMTP_PORT: "465"
SMTP_SECURITY: "ssl" # if your network only allows STARTTLS: port 587 + "starttls"
# --- Incoming mail / Sent-folder copy (pre-configured for Purelymail) ---
IMAP_HOST: "imap.purelymail.com"
IMAP_PORT: "993" # SSL/TLS
# IMAP_USER / IMAP_PASS default to the SMTP credentials above
IMAP_SENT_FOLDER: "Sent"
# --- optional ---
SEND_HOUR: "8" # hour of day (0-23) automatic e-mails go out
SECRET_KEY: "change-this-to-a-long-random-string"
TZ: "Europe/Lisbon"
@@ -0,0 +1,5 @@
flask>=3.0
pyotp>=2.9
qrcode[pil]>=7.4
APScheduler>=3.10
werkzeug>=3.0
@@ -0,0 +1,230 @@
/* Birthday Manager — © 2026 Martinhal IT */
:root {
--pine: #123f3a;
--pine-deep: #0c2b28;
--paper: #f6f4ee;
--card: #ffffff;
--ink: #21302d;
--muted: #6b7a76;
--line: #e2ddd1;
--marigold: #e8a13a;
--marigold-deep: #c07f1c;
--danger: #b3402a;
--ok: #2e7d4f;
font-size: 16px;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--paper);
color: var(--ink);
font-family: "Inter", "Segoe UI", system-ui, sans-serif;
}
h1, h2, h3, .brand-name, .cal-year {
font-family: "Sora", "Segoe UI", system-ui, sans-serif;
letter-spacing: -0.01em;
}
h1 { font-size: 1.6rem; margin: 0.2rem 0; }
h2 { font-size: 1.15rem; margin-top: 0; }
h3 { font-size: 0.9rem; margin: 0 0 0.4rem; }
/* ---------- top bar ---------- */
.topbar {
display: flex;
align-items: center;
gap: 2rem;
padding: 0.7rem 1.4rem;
background: var(--pine);
color: #f2efe6;
}
.brand { display: flex; align-items: center; gap: 0.55rem; font-weight: 700; }
.brand-mark { font-size: 1.3rem; }
.nav { display: flex; gap: 0.4rem; flex: 1; }
.nav a {
color: #cfe0d8;
text-decoration: none;
padding: 0.45rem 0.9rem;
border-radius: 8px;
font-weight: 500;
}
.nav a:hover { background: rgba(255,255,255,0.08); color: #fff; }
.nav a.active { background: var(--marigold); color: var(--pine-deep); font-weight: 600; }
.userbox { display: flex; align-items: center; gap: 0.7rem; }
.userchip {
background: rgba(255,255,255,0.12);
padding: 0.3rem 0.75rem;
border-radius: 999px;
font-size: 0.85rem;
}
/* ---------- layout ---------- */
.page {
flex: 1;
width: min(1180px, 100%);
margin: 0 auto;
padding: 1.4rem 1.2rem 2.5rem;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 14px;
padding: 1.2rem 1.4rem;
margin-bottom: 1.3rem;
box-shadow: 0 1px 2px rgba(18, 63, 58, 0.05);
}
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1.3rem; }
.two-col .card { margin-bottom: 1.3rem; }
@media (max-width: 860px) { .two-col { grid-template-columns: 1fr; } }
.footer {
text-align: center;
padding: 1rem;
background: var(--pine-deep);
color: #cfe0d8;
font-size: 0.85rem;
}
/* ---------- today banner ---------- */
.today-banner {
display: flex;
gap: 1.5rem;
align-items: center;
justify-content: space-between;
border-left: 6px solid var(--marigold);
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.72rem;
color: var(--muted);
margin: 0;
}
.today-people { list-style: none; margin: 0.4rem 0 0; padding: 0; }
.today-people li { margin: 0.25rem 0; }
.today-right { text-align: right; flex-shrink: 0; max-width: 220px; }
/* ---------- forms ---------- */
label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.85rem; font-weight: 500; }
input, select, textarea {
font: inherit;
padding: 0.5rem 0.65rem;
border: 1px solid var(--line);
border-radius: 8px;
background: #fdfcf9;
}
input:focus, select:focus, textarea:focus, button:focus-visible, a:focus-visible {
outline: 2px solid var(--marigold);
outline-offset: 1px;
}
textarea { resize: vertical; font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 0.85rem; }
.entry-form {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
align-items: flex-end;
margin-bottom: 1rem;
}
.entry-form label { flex: 1 1 150px; }
.stack-form { display: flex; flex-direction: column; gap: 0.8rem; align-items: flex-start; }
.stack-form label { width: 100%; }
.row-gap { display: flex; gap: 0.7rem; margin-top: 0.8rem; }
/* ---------- buttons ---------- */
.btn {
font: inherit;
font-weight: 600;
border: none;
border-radius: 9px;
padding: 0.55rem 1.1rem;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.btn-primary { background: var(--pine); color: #fff; }
.btn-primary:hover { background: var(--pine-deep); }
.btn-accent { background: var(--marigold); color: var(--pine-deep); }
.btn-accent:hover { background: var(--marigold-deep); color: #fff; }
.btn-danger { background: #fbeae5; color: var(--danger); }
.btn-danger:hover { background: var(--danger); color: #fff; }
.btn-ghost { background: transparent; color: inherit; border: 1px solid currentColor; opacity: 0.85; }
.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.8rem; }
.btn-lg { padding: 0.8rem 1.6rem; font-size: 1.05rem; }
.btn-block { width: 100%; }
.btn[disabled] { opacity: 0.45; cursor: not-allowed; }
/* ---------- tables ---------- */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
th { text-align: left; color: var(--muted); font-weight: 600; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; }
th, td { padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--line); }
tbody tr:hover { background: #faf8f2; }
.ta-right { text-align: right; }
.nowrap { white-space: nowrap; }
.log-wrap { max-height: 420px; overflow-y: auto; }
/* ---------- pills / flash ---------- */
.pill { border-radius: 999px; padding: 0.15rem 0.6rem; font-size: 0.72rem; font-weight: 600; }
.pill-ok { background: #e2f2e8; color: var(--ok); }
.pill-bad { background: #fbeae5; color: var(--danger); }
.pill-wait { background: #fdf1dc; color: var(--marigold-deep); }
.flash { padding: 0.7rem 1rem; border-radius: 10px; margin-bottom: 1rem; font-size: 0.9rem; }
.flash-ok { background: #e2f2e8; color: var(--ok); }
.flash-error { background: #fbeae5; color: var(--danger); }
/* ---------- yearly calendar ---------- */
.cal-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.8rem; }
.cal-nav { display: flex; align-items: center; gap: 0.7rem; }
.cal-year { font-size: 1.2rem; font-weight: 700; }
.year-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(215px, 1fr));
gap: 1rem;
}
.mini-cal { font-size: 0.72rem; }
.mini-cal th, .mini-cal td { padding: 0.22rem; text-align: center; border: none; }
.mini-cal td { position: relative; border-radius: 6px; }
.mini-cal td.bday { background: #fdf1dc; font-weight: 700; color: var(--marigold-deep); cursor: help; }
.mini-cal td.today { outline: 2px solid var(--pine); }
.mini-cal .dot {
position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%);
width: 4px; height: 4px; border-radius: 50%; background: var(--marigold-deep);
}
/* ---------- images ---------- */
.image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-top: 1rem; }
.image-tile {
border: 1px solid var(--line); border-radius: 10px; padding: 0.6rem;
display: flex; flex-direction: column; gap: 0.5rem; align-items: center; text-align: center;
}
.image-tile img { max-width: 100%; max-height: 110px; object-fit: contain; border-radius: 6px; }
.image-tile code { word-break: break-all; }
/* ---------- auth / misc ---------- */
.auth-card {
background: var(--card); border: 1px solid var(--line); border-radius: 14px;
max-width: 380px; margin: 8vh auto 0; padding: 2rem;
display: flex; flex-direction: column; gap: 0.4rem;
}
.auth-card.wide { max-width: 480px; margin-top: 3vh; }
.auth-card form { display: flex; flex-direction: column; gap: 0.9rem; margin-top: 0.8rem; }
.qr-wrap { text-align: center; margin: 0.5rem 0; }
.qr-wrap img { width: 190px; height: 190px; }
.muted { color: var(--muted); }
.small { font-size: 0.8rem; }
code { background: #f0ede4; padding: 0.1rem 0.35rem; border-radius: 5px; font-size: 0.82em; }
.tpl-zone textarea { width: 100%; }
@media (prefers-reduced-motion: no-preference) {
.btn { transition: background 0.15s ease, color 0.15s ease; }
}
@media (max-width: 700px) {
.topbar { flex-wrap: wrap; gap: 0.6rem; }
.today-banner { flex-direction: column; align-items: flex-start; }
.today-right { text-align: left; }
}
@@ -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 %}