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:
17
README.md
17
README.md
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 & 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" />
|
||||
|
||||
@@ -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,6 +422,12 @@ 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,
|
||||
@@ -415,7 +436,12 @@ async function saveSettings() {
|
||||
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),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user