Add in-app Settings page for secrets; move Gmail creds out of source
- settings.py: JSON-on-volume store with env fallback; secrets never rendered. - /settings page (⚙️ in nav) to set Gmail address + app password + reminder prefs; password fields are write-only (blank keeps existing). - reminders.py reads Gmail creds from Settings instead of a hardcoded password (resolves #2 in code — note the old password is still in git history; rotate it). - mac_notification no longer crashes off-macOS; FLASK_DEBUG gates debug (#7). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
26
app.py
26
app.py
@@ -6,6 +6,7 @@ import re
|
||||
from datetime import datetime, date
|
||||
import calendar as cal_module
|
||||
from storage import load_json, save_json
|
||||
from settings import load_settings, save_settings, SECRET_KEYS
|
||||
from categories import CATEGORIES, CATEGORY_COLORS
|
||||
from bills import load_bills, save_bills, bills_for_month, calendar_grid, get_week_num
|
||||
from sinking import (load_funds, save_funds, FUND_COLORS, monthly_needed,
|
||||
@@ -2847,5 +2848,28 @@ def income_delete(entry_id):
|
||||
return redirect(url_for("income_view", year=year, month=month))
|
||||
|
||||
|
||||
@app.route("/settings", methods=["GET", "POST"])
|
||||
def settings_view():
|
||||
current = load_settings()
|
||||
if request.method == "POST":
|
||||
new = dict(current)
|
||||
new["gmail_address"] = request.form.get("gmail_address", "").strip()
|
||||
new["reminder_recipient"] = request.form.get("reminder_recipient", "").strip()
|
||||
new["reminders_enabled"] = request.form.get("reminders_enabled") == "on"
|
||||
# Secret fields: only overwrite when a new value is supplied; a blank
|
||||
# field keeps the existing stored secret.
|
||||
pw = request.form.get("gmail_app_password", "").strip()
|
||||
if pw:
|
||||
new["gmail_app_password"] = pw
|
||||
save_settings(new)
|
||||
return redirect(url_for("settings_view", saved=1))
|
||||
# Never send secret values to the template — only whether each one is set.
|
||||
safe = {k: ("" if k in SECRET_KEYS else v) for k, v in current.items()}
|
||||
secrets_status = {k: bool(current.get(k)) for k in SECRET_KEYS}
|
||||
return render_template("settings.html", settings=safe,
|
||||
secrets_status=secrets_status,
|
||||
saved=request.args.get("saved"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True, port=5000)
|
||||
app.run(debug=os.environ.get("FLASK_DEBUG") == "1", port=5000)
|
||||
|
||||
29
reminders.py
29
reminders.py
@@ -7,26 +7,41 @@ from email.mime.multipart import MIMEMultipart
|
||||
from datetime import date, timedelta
|
||||
import calendar as cal_module
|
||||
from bills import load_bills
|
||||
from settings import get_setting
|
||||
|
||||
EMAIL = "bonna61@gmail.com"
|
||||
APP_PASSWORD = "qowu zcff dbtk kqbs"
|
||||
|
||||
def _gmail():
|
||||
"""Resolve Gmail creds + recipient from Settings (env-var fallback)."""
|
||||
email = get_setting("gmail_address", env="GMAIL_ADDRESS", default="")
|
||||
pw = get_setting("gmail_app_password", env="GMAIL_APP_PASSWORD", default="")
|
||||
recipient = get_setting("reminder_recipient", default="") or email
|
||||
return email, pw, recipient
|
||||
|
||||
|
||||
def send_email(subject, body_html, body_text):
|
||||
email, pw, recipient = _gmail()
|
||||
if not (email and pw):
|
||||
print("Gmail not configured in Settings — skipping email reminder.")
|
||||
return
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = f"Moon Household Budget <{EMAIL}>"
|
||||
msg["To"] = EMAIL
|
||||
msg["From"] = f"Moon Household Budget <{email}>"
|
||||
msg["To"] = recipient
|
||||
msg.attach(MIMEText(body_text, "plain"))
|
||||
msg.attach(MIMEText(body_html, "html"))
|
||||
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
|
||||
server.login(EMAIL, APP_PASSWORD)
|
||||
server.sendmail(EMAIL, EMAIL, msg.as_string())
|
||||
server.login(email, pw)
|
||||
server.sendmail(email, recipient, msg.as_string())
|
||||
|
||||
|
||||
def mac_notification(title, message):
|
||||
# macOS-only; no-op (don't crash) when osascript isn't available, e.g. in
|
||||
# the Linux container.
|
||||
script = f'display notification "{message}" with title "{title}" sound name "Default"'
|
||||
subprocess.run(["osascript", "-e", script])
|
||||
try:
|
||||
subprocess.run(["osascript", "-e", script], check=False)
|
||||
except (FileNotFoundError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def bills_due_in(days):
|
||||
|
||||
52
settings.py
Normal file
52
settings.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""App settings + secret storage (Gmail credentials, reminder prefs, etc.).
|
||||
|
||||
Stored as JSON on the data volume (DATA_DIR/settings.json) via the atomic
|
||||
storage layer — so it is NEVER committed to source control. Secrets resolve
|
||||
with an environment-variable fallback, so a value can come from the settings
|
||||
page OR from the container's .env.
|
||||
|
||||
This is what lets us pull the hardcoded Gmail app password out of reminders.py
|
||||
(issue #2): the password lives in settings.json on the volume instead.
|
||||
"""
|
||||
import os
|
||||
from storage import load_json, save_json, DATA_DIR
|
||||
|
||||
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
|
||||
|
||||
# Keys holding secrets — their values are never rendered back to the browser.
|
||||
SECRET_KEYS = {"gmail_app_password"}
|
||||
|
||||
DEFAULTS = {
|
||||
"gmail_address": "",
|
||||
"gmail_app_password": "",
|
||||
"reminder_recipient": "", # blank => send to gmail_address
|
||||
"reminders_enabled": True,
|
||||
}
|
||||
|
||||
|
||||
def load_settings():
|
||||
data = load_json(SETTINGS_FILE, {}) or {}
|
||||
merged = dict(DEFAULTS)
|
||||
merged.update(data)
|
||||
return merged
|
||||
|
||||
|
||||
def save_settings(data):
|
||||
save_json(SETTINGS_FILE, data)
|
||||
|
||||
|
||||
def get_setting(key, env=None, default=""):
|
||||
"""Resolve a setting: stored value first, then env var, then default."""
|
||||
val = load_settings().get(key)
|
||||
if val not in (None, ""):
|
||||
return val
|
||||
if env:
|
||||
env_val = os.environ.get(env)
|
||||
if env_val not in (None, ""):
|
||||
return env_val
|
||||
return default
|
||||
|
||||
|
||||
def is_set(key):
|
||||
"""True if a (typically secret) value is present, without revealing it."""
|
||||
return bool(load_settings().get(key))
|
||||
@@ -24,6 +24,7 @@
|
||||
<a href="{{ url_for('net_worth_view') }}">Net Worth</a>
|
||||
<a href="{{ url_for('summary') }}">Spending Report</a>
|
||||
<a href="{{ url_for('suggestions.suggestions_page') }}" class="nav-suggest" title="Suggestions & feedback" aria-label="Suggestions">💡</a>
|
||||
<a href="{{ url_for('settings_view') }}" class="nav-settings" title="Settings" aria-label="Settings">⚙️</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main>
|
||||
|
||||
59
templates/settings.html
Normal file
59
templates/settings.html
Normal file
@@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div style="max-width:640px;">
|
||||
<h1>⚙️ Settings</h1>
|
||||
<p style="color:#7A6E68;margin-bottom:1.25rem;">
|
||||
Secrets are stored on the server (in the data volume) and are never shown
|
||||
here once saved. Leave a password field blank to keep the current value.
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert-banner" style="background:#E7F3E9;border-color:#BFE0C5;margin-bottom:1rem;">
|
||||
<span>✓ Settings saved.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ url_for('settings_view') }}">
|
||||
<fieldset style="border:1px solid #EDE8E2;border-radius:10px;padding:1rem 1.25rem;margin-bottom:1.25rem;">
|
||||
<legend style="padding:0 .5rem;font-weight:600;">📧 Email reminders (Gmail)</legend>
|
||||
|
||||
<label style="display:block;margin-bottom:.9rem;">
|
||||
<span style="display:block;font-size:.85rem;color:#7A6E68;margin-bottom:.25rem;">Gmail address</span>
|
||||
<input type="email" name="gmail_address" value="{{ settings.gmail_address }}"
|
||||
placeholder="you@gmail.com" style="width:100%;padding:.55rem;border:1px solid #cfcfd4;border-radius:8px;">
|
||||
</label>
|
||||
|
||||
<label style="display:block;margin-bottom:.9rem;">
|
||||
<span style="display:block;font-size:.85rem;color:#7A6E68;margin-bottom:.25rem;">
|
||||
Gmail app password
|
||||
{% if secrets_status.gmail_app_password %}
|
||||
<strong style="color:#5A9E68;">· set ✓</strong>
|
||||
{% else %}
|
||||
<strong style="color:#C57A6E;">· not set</strong>
|
||||
{% endif %}
|
||||
</span>
|
||||
<input type="password" name="gmail_app_password" autocomplete="new-password"
|
||||
placeholder="{% if secrets_status.gmail_app_password %}•••••••••••• (leave blank to keep){% else %}16-character app password{% endif %}"
|
||||
style="width:100%;padding:.55rem;border:1px solid #cfcfd4;border-radius:8px;">
|
||||
<span style="display:block;font-size:.78rem;color:#B0A89E;margin-top:.25rem;">
|
||||
Not your normal password — create an
|
||||
<a href="https://myaccount.google.com/apppasswords" target="_blank" rel="noopener">app password</a> in your Google account.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style="display:block;margin-bottom:.9rem;">
|
||||
<span style="display:block;font-size:.85rem;color:#7A6E68;margin-bottom:.25rem;">Send reminders to (optional)</span>
|
||||
<input type="email" name="reminder_recipient" value="{{ settings.reminder_recipient }}"
|
||||
placeholder="defaults to the Gmail address above" style="width:100%;padding:.55rem;border:1px solid #cfcfd4;border-radius:8px;">
|
||||
</label>
|
||||
|
||||
<label style="display:flex;align-items:center;gap:.5rem;">
|
||||
<input type="checkbox" name="reminders_enabled" {% if settings.reminders_enabled %}checked{% endif %}>
|
||||
<span>Email reminders enabled</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save settings</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user