Add ntfy authentication support (username/password and API key)

- Settings table gets ntfy_username, ntfy_password, ntfy_api_key columns
- Backend applies Basic or Bearer auth header when sending notifications
- Settings page UI lets you toggle between no auth, basic, or token auth
- Masked credential display on load to avoid exposing stored secrets
- README updated with auth modes documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 23:49:54 -07:00
parent 1ff44e4276
commit 1bed02ebb5
8 changed files with 109 additions and 16 deletions

View File

@@ -12,7 +12,7 @@ Sproutly takes the guesswork out of seed starting. Enter your plant varieties on
- **Seed Library** — manage plant varieties with frost-relative timing, germination days, sun/water requirements
- **Garden Tracker** — log growing batches and track status from `planned``germinating``seedling``potted up``hardening off``garden``harvested`
- **Year Timeline** — visual calendar showing when each variety's stages fall across the year
- **Ntfy Notifications** — daily summary push notifications to your phone, configurable time and topic
- **Ntfy Notifications** — daily summary push notifications to your phone, configurable time, topic, and authentication
- **Settings** — set your last frost date, fall frost date, location, timezone, and notification preferences
## Stack
@@ -45,7 +45,7 @@ Access the app at **http://localhost:8053**
### First Steps
1. Go to **Settings** and enter your last frost date — this anchors all planting schedule calculations
2. Optionally configure an [ntfy](https://ntfy.sh) topic for push notifications
2. Optionally configure an [ntfy](https://ntfy.sh) topic for push notifications — supports no auth, username/password, or API key/token
3. Browse the pre-loaded **Seed Library** (12 common vegetables, herbs, and flowers included)
4. Start logging batches in **My Garden** as you sow seeds
@@ -97,7 +97,18 @@ Key endpoints:
- `GET/PUT /api/settings/` — app settings
- `POST /api/notifications/test` — send test ntfy notification
- `POST /api/notifications/daily` — trigger daily summary
- `GET /api/notifications/log` — recent notification history
## Ntfy Authentication
For private ntfy servers or access-controlled topics, the Settings page supports three auth modes:
| Mode | When to use |
|------|-------------|
| None | Public ntfy.sh topics |
| Username & Password | ntfy server with basic auth enabled |
| API Key / Token | ntfy account access token (generate in ntfy account settings) |
## Status
This project is in early development. Core infrastructure and UI prototype are functional. UI design and feature set are evolving based on user feedback.
Core infrastructure is functional. UI design and feature set are evolving based on user feedback.

View File

@@ -86,6 +86,9 @@ class Settings(Base):
notification_time = Column(String(5), default="07:00")
timezone = Column(String(50), default="UTC")
location_name = Column(String(100))
ntfy_username = Column(String(200))
ntfy_password = Column(String(200))
ntfy_api_key = Column(String(200))
class NotificationLog(Base):

View File

@@ -1,3 +1,4 @@
import base64
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
@@ -96,16 +97,25 @@ async def send_ntfy(settings: Settings, title: str, message: str, db: Session, p
server = (settings.ntfy_server or "https://ntfy.sh").rstrip("/")
url = f"{server}/{settings.ntfy_topic}"
headers = {
"Title": title,
"Priority": priority,
"Tags": "seedling",
}
if settings.ntfy_api_key:
headers["Authorization"] = f"Bearer {settings.ntfy_api_key}"
elif settings.ntfy_username:
creds = base64.b64encode(
f"{settings.ntfy_username}:{settings.ntfy_password or ''}".encode()
).decode()
headers["Authorization"] = f"Basic {creds}"
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
url,
content=message.encode("utf-8"),
headers={
"Title": title,
"Priority": priority,
"Tags": "seedling",
},
headers=headers,
)
resp.raise_for_status()

View File

@@ -78,6 +78,9 @@ class SettingsUpdate(BaseModel):
notification_time: Optional[str] = "07:00"
timezone: Optional[str] = "UTC"
location_name: Optional[str] = None
ntfy_username: Optional[str] = None
ntfy_password: Optional[str] = None
ntfy_api_key: Optional[str] = None
class SettingsOut(SettingsUpdate):

View File

@@ -41,7 +41,10 @@ CREATE TABLE IF NOT EXISTS settings (
ntfy_server VARCHAR(200) DEFAULT 'https://ntfy.sh',
notification_time VARCHAR(5) DEFAULT '07:00',
timezone VARCHAR(50) DEFAULT 'UTC',
location_name VARCHAR(100)
location_name VARCHAR(100),
ntfy_username VARCHAR(200),
ntfy_password VARCHAR(200),
ntfy_api_key VARCHAR(200)
);
CREATE TABLE IF NOT EXISTS notification_log (

View File

@@ -628,3 +628,9 @@ a:hover { text-decoration: underline; }
.main-content { margin-left: 0; padding: 1rem; }
.stats-row { grid-template-columns: 1fr 1fr; }
}
/* Ntfy auth toggle */
.hidden { display: none !important; }
.auth-toggle { display: flex; gap: 1.25rem; flex-wrap: wrap; }
.auth-toggle-option { display: flex; align-items: center; gap: 0.35rem; cursor: pointer; font-size: 0.9rem; }
.auth-toggle-option input[type="radio"] { accent-color: var(--green-mid); }

View File

@@ -182,6 +182,37 @@
<input type="text" id="s-ntfy-topic" class="form-input" placeholder="my-garden-alerts" />
<span class="form-hint">Subscribe to this topic in the ntfy app</span>
</div>
<div class="form-group">
<label class="form-label">Authentication</label>
<div class="auth-toggle">
<label class="auth-toggle-option">
<input type="radio" name="ntfy-auth-type" value="none" checked onchange="App.toggleNtfyAuth(this.value)" /> None
</label>
<label class="auth-toggle-option">
<input type="radio" name="ntfy-auth-type" value="basic" onchange="App.toggleNtfyAuth(this.value)" /> Username &amp; Password
</label>
<label class="auth-toggle-option">
<input type="radio" name="ntfy-auth-type" value="token" onchange="App.toggleNtfyAuth(this.value)" /> API Key / Token
</label>
</div>
</div>
<div id="ntfy-basic-auth" class="hidden">
<div class="form-group">
<label class="form-label">Username</label>
<input type="text" id="s-ntfy-username" class="form-input" placeholder="ntfy username" autocomplete="off" />
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" id="s-ntfy-password" class="form-input" placeholder="ntfy password" autocomplete="off" />
</div>
</div>
<div id="ntfy-token-auth" class="hidden">
<div class="form-group">
<label class="form-label">API Key / Access Token</label>
<input type="password" id="s-ntfy-api-key" class="form-input" placeholder="tk_..." autocomplete="off" />
<span class="form-hint">Generate in ntfy account settings</span>
</div>
</div>
<div class="form-group">
<label class="form-label">Daily Summary Time</label>
<input type="time" id="s-notif-time" class="form-input" />

View File

@@ -389,6 +389,11 @@ function renderGarden() {
function filterBatches() { renderGarden(); }
// ===== Settings =====
function toggleNtfyAuth(type) {
document.getElementById('ntfy-basic-auth').classList.toggle('hidden', type !== 'basic');
document.getElementById('ntfy-token-auth').classList.toggle('hidden', type !== 'token');
}
async function loadSettings() {
try {
state.settings = await api.get('/settings/');
@@ -400,6 +405,16 @@ async function loadSettings() {
document.getElementById('s-ntfy-server').value = s.ntfy_server || 'https://ntfy.sh';
document.getElementById('s-ntfy-topic').value = s.ntfy_topic || '';
document.getElementById('s-notif-time').value = s.notification_time || '07:00';
// Auth type
let authType = 'none';
if (s.ntfy_api_key) authType = 'token';
else if (s.ntfy_username) authType = 'basic';
document.querySelector(`input[name="ntfy-auth-type"][value="${authType}"]`).checked = true;
toggleNtfyAuth(authType);
document.getElementById('s-ntfy-username').value = s.ntfy_username || '';
document.getElementById('s-ntfy-password').value = s.ntfy_password ? '••••••••' : '';
document.getElementById('s-ntfy-api-key').value = s.ntfy_api_key ? '••••••••' : '';
} catch (e) {
toast('Failed to load settings: ' + e.message, true);
}
@@ -407,15 +422,26 @@ async function loadSettings() {
async function saveSettings() {
try {
const authType = document.querySelector('input[name="ntfy-auth-type"]:checked').value;
const rawPassword = document.getElementById('s-ntfy-password').value;
const rawApiKey = document.getElementById('s-ntfy-api-key').value;
// If the placeholder mask is still showing, don't overwrite
const isMask = v => v === '••••••••';
const payload = {
location_name: document.getElementById('s-location').value || null,
last_frost_date: document.getElementById('s-last-frost').value || null,
location_name: document.getElementById('s-location').value || null,
last_frost_date: document.getElementById('s-last-frost').value || null,
first_frost_fall_date: document.getElementById('s-first-frost').value || null,
timezone: document.getElementById('s-timezone').value || 'UTC',
ntfy_server: document.getElementById('s-ntfy-server').value || 'https://ntfy.sh',
ntfy_topic: document.getElementById('s-ntfy-topic').value || null,
notification_time: document.getElementById('s-notif-time').value || '07:00',
timezone: document.getElementById('s-timezone').value || 'UTC',
ntfy_server: document.getElementById('s-ntfy-server').value || 'https://ntfy.sh',
ntfy_topic: document.getElementById('s-ntfy-topic').value || null,
notification_time: document.getElementById('s-notif-time').value || '07:00',
ntfy_username: authType === 'basic' ? (document.getElementById('s-ntfy-username').value || null) : null,
ntfy_password: authType === 'basic' && !isMask(rawPassword) ? (rawPassword || null) : (authType === 'basic' ? undefined : null),
ntfy_api_key: authType === 'token' && !isMask(rawApiKey) ? (rawApiKey || null) : (authType === 'token' ? undefined : null),
};
// Remove undefined keys so they are excluded from the PUT (server keeps existing value)
Object.keys(payload).forEach(k => payload[k] === undefined && delete payload[k]);
await api.put('/settings/', payload);
toast('Settings saved!');
document.getElementById('settings-status').textContent = 'Saved!';
@@ -779,7 +805,7 @@ window.App = {
showAddVarietyModal, showEditVarietyModal, submitAddVariety, submitEditVariety, deleteVariety,
showAddBatchModal, showEditBatchModal, submitAddBatch, submitEditBatch, deleteBatch,
filterVarieties, filterBatches,
saveSettings, sendTestNotification, sendDailySummary,
saveSettings, toggleNtfyAuth, sendTestNotification, sendDailySummary,
closeModal: (e) => closeModal(e),
};