Add SMTP email: password reset + per-frame battery-threshold alerts
Build and push server image / build-and-push (push) Successful in 40s
Build and push server image / build-and-push (push) Successful in 40s
Admin-configured SMTP (server/port/username/password/from address/ STARTTLS, a singleton server_settings row set from /admin -- not env vars, since it's operator infrastructure a household admin sets up once through the UI) powers two features, both requiring the relevant user to have an email set in their own Settings: - "Forgot password?" on /login emails a one-hour single-use reset link (password_reset_tokens table). The endpoint always returns the same generic "check your email" response regardless of whether the address matched an account, so it can't be used to enumerate registered users. - A frame's Configuration tab can set a battery-alert threshold (Frame.battery_alert_threshold_pct, -1 = disabled); POST /frame/battery emails the owner the first time a report drops to or below it, then stays quiet for the rest of that discharge cycle (battery_alert_sent, reset alongside battery_history whenever the existing recharge-jump detection fires) -- not once per wake. New app/mail.py wraps stdlib smtplib (no new dependency); send_email() never raises, so a broken mail server can't 500 a battery report or a password-reset request. Schema migration v2 adds users.email and the two frame columns via ALTER TABLE (safe against the live, already- populated database) plus the two new tables via the existing create_all-based migration runner. Verified against a real (already-migrated, real user/frame data) database: the v1->v2 migration, admin SMTP config + test-email button, full forgot/reset-password roundtrip (including single-use token invalidation and the no-enumeration response), and the battery alert firing exactly once per crossing against a hand-rolled fake SMTP server -- all via curl end-to-end, plus the standing legacy-device curl suite to confirm the device protocol is untouched.
This commit is contained in:
+37
-1
@@ -27,7 +27,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .db import get_db
|
||||
from .migration import new_device_token, new_manage_token
|
||||
from .models import Frame, PendingClaim, User, UserFrame, UserSession
|
||||
from .models import Frame, PasswordResetToken, PendingClaim, ServerSettings, User, UserFrame, UserSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,6 +35,7 @@ MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
|
||||
SESSION_COOKIE = "session"
|
||||
SESSION_LIFETIME_S = 30 * 86400
|
||||
SESSION_REFRESH_BELOW_S = 15 * 86400 # rolling expiry: extend when under this much left
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_S = 3600
|
||||
|
||||
# stdlib scrypt instead of a passlib/argon2 dependency: zero new deps,
|
||||
# and the parameters are baked into each stored hash so they can be
|
||||
@@ -128,6 +129,41 @@ def users_exist(db: Session) -> bool:
|
||||
return db.scalars(select(User).limit(1)).first() is not None
|
||||
|
||||
|
||||
def get_server_settings(db: Session) -> ServerSettings:
|
||||
"""The SMTP config singleton -- migration.py guarantees row id=1
|
||||
exists (created at startup if missing), so this is never None."""
|
||||
settings = db.get(ServerSettings, 1)
|
||||
assert settings is not None
|
||||
return settings
|
||||
|
||||
|
||||
def create_password_reset_token(db: Session, user: User) -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = time.time()
|
||||
# Opportunistic prune, same pattern as sessions/pending claims.
|
||||
for stale in db.scalars(select(PasswordResetToken).where(PasswordResetToken.expires_at < now)):
|
||||
db.delete(stale)
|
||||
db.add(PasswordResetToken(
|
||||
token=token, user_id=user.id, created_at=now,
|
||||
expires_at=now + PASSWORD_RESET_TOKEN_LIFETIME_S,
|
||||
))
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
def consume_password_reset_token(db: Session, token: str) -> User | None:
|
||||
"""Looks up the token and, if valid, deletes it (single-use) and
|
||||
returns the user it was issued for. None for an unknown/expired
|
||||
token -- callers show a generic error either way."""
|
||||
row = db.get(PasswordResetToken, token)
|
||||
if row is None or row.expires_at < time.time():
|
||||
return None
|
||||
user = db.get(User, row.user_id)
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _csrf_ok(request: Request, session: UserSession) -> bool:
|
||||
supplied = request.headers.get("X-CSRF-Token") or ""
|
||||
return hmac.compare_digest(supplied, session.csrf_token)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""SMTP email sending -- password resets and battery-threshold alerts.
|
||||
|
||||
Config lives in the server_settings singleton row (admin-configured via
|
||||
/admin, see routers/pages.py), not env vars -- it's operator
|
||||
infrastructure a household admin sets up once through the UI, same
|
||||
spirit as the rest of this project's "no separate config file" stance
|
||||
post-redesign. Uses stdlib smtplib; no new dependency.
|
||||
|
||||
send_email() never raises -- a broken mail server shouldn't 500 a
|
||||
password-reset request or a battery report; callers get a bool and log
|
||||
a warning on failure."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from .models import ServerSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SMTP_TIMEOUT_S = 10
|
||||
|
||||
|
||||
def send_email(settings: ServerSettings, to_address: str, subject: str, body: str) -> bool:
|
||||
if not settings.smtp_host or not to_address:
|
||||
return False
|
||||
|
||||
msg = MIMEText(body)
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = settings.smtp_from_address or settings.smtp_username or "noreply@localhost"
|
||||
msg["To"] = to_address
|
||||
|
||||
try:
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=SMTP_TIMEOUT_S) as smtp:
|
||||
if settings.smtp_use_tls:
|
||||
smtp.starttls()
|
||||
if settings.smtp_username:
|
||||
smtp.login(settings.smtp_username, settings.smtp_password)
|
||||
smtp.send_message(msg)
|
||||
return True
|
||||
except (OSError, smtplib.SMTPException) as e:
|
||||
logger.warning("Failed to send email to %s: %s", to_address, e)
|
||||
return False
|
||||
+24
-1
@@ -19,7 +19,7 @@ from sqlalchemy import select, text
|
||||
|
||||
from . import config
|
||||
from .db import SessionLocal, engine
|
||||
from .models import Base, BatteryLog, Frame
|
||||
from .models import Base, BatteryLog, Frame, ServerSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,8 +28,20 @@ def _migration_1(conn) -> None:
|
||||
Base.metadata.create_all(bind=conn)
|
||||
|
||||
|
||||
def _migration_2(conn) -> None:
|
||||
"""Adds email (users) and battery-alert threshold (frames) columns,
|
||||
plus the new server_settings/password_reset_tokens tables. ALTER
|
||||
TABLE ADD COLUMN with a default is safe on SQLite against a live,
|
||||
already-populated database -- existing rows just get the default."""
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN battery_alert_threshold_pct INTEGER NOT NULL DEFAULT -1"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN battery_alert_sent INTEGER NOT NULL DEFAULT 0"))
|
||||
Base.metadata.create_all(bind=conn) # creates the two new tables only; existing ones untouched
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
]
|
||||
|
||||
|
||||
@@ -50,6 +62,7 @@ def run_migrations() -> None:
|
||||
else:
|
||||
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
||||
_ensure_frame_one()
|
||||
_ensure_server_settings()
|
||||
|
||||
|
||||
def new_device_token() -> str:
|
||||
@@ -144,3 +157,13 @@ def _ensure_frame_one() -> None:
|
||||
)
|
||||
else:
|
||||
logger.info("Fresh install: created default frame #%d", frame.id)
|
||||
|
||||
|
||||
def _ensure_server_settings() -> None:
|
||||
"""The SMTP config singleton (id=1) -- created with everything blank
|
||||
(email sending disabled) the first time this runs; /admin edits it in
|
||||
place from then on."""
|
||||
with SessionLocal() as db:
|
||||
if db.get(ServerSettings, 1) is None:
|
||||
db.add(ServerSettings(id=1))
|
||||
db.commit()
|
||||
|
||||
@@ -44,6 +44,10 @@ class User(Base):
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
immich_url: Mapped[str] = mapped_column(String, default="")
|
||||
immich_api_key: Mapped[str] = mapped_column(String, default="")
|
||||
# Password-reset emails and battery-threshold alerts (frames.owner's
|
||||
# email -- see routers/device.py's frame_battery) go here; blank = no
|
||||
# email configured, both features silently no-op for this user.
|
||||
email: Mapped[str] = mapped_column(String, default="")
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
|
||||
__table_args__ = (
|
||||
@@ -139,6 +143,13 @@ class Frame(Base):
|
||||
device_firmware_version: Mapped[str] = mapped_column(String, default="")
|
||||
device_board_variant: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
# Battery-low email alert (see routers/device.py's frame_battery).
|
||||
# -1 = disabled. Sent to the owner's email once per discharge cycle
|
||||
# (battery_alert_sent resets alongside battery_history whenever a
|
||||
# recharge is detected, same trigger as stats_recharge_cycles).
|
||||
battery_alert_threshold_pct: Mapped[int] = mapped_column(Integer, default=-1)
|
||||
battery_alert_sent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# -- firmware / OTA (per frame; image lives at /data/firmware/<id>.bin) --
|
||||
firmware_available_version: Mapped[str] = mapped_column(String, default="")
|
||||
firmware_update_repo_url: Mapped[str] = mapped_column(String, default="")
|
||||
@@ -191,6 +202,39 @@ class PendingClaim(Base):
|
||||
expires_at: Mapped[float] = mapped_column(Float)
|
||||
|
||||
|
||||
class ServerSettings(Base):
|
||||
"""Singleton row (id always 1) holding operator-level SMTP config, set
|
||||
from /admin -- not env vars, since this is infrastructure a household
|
||||
admin configures once through the UI rather than at container
|
||||
deploy time. Used for password-reset emails and battery-threshold
|
||||
alerts (see app/mail.py). smtp_host empty = email sending disabled;
|
||||
every send site checks that and no-ops rather than erroring."""
|
||||
|
||||
__tablename__ = "server_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
smtp_host: Mapped[str] = mapped_column(String, default="")
|
||||
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
|
||||
smtp_username: Mapped[str] = mapped_column(String, default="")
|
||||
smtp_password: Mapped[str] = mapped_column(String, default="")
|
||||
smtp_from_address: Mapped[str] = mapped_column(String, default="")
|
||||
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class PasswordResetToken(Base):
|
||||
"""A single-use, time-limited "forgot password" link. token is the
|
||||
URL-safe secret itself (not hashed, like PendingClaim/manage_token --
|
||||
it's a short-lived bearer credential emailed once, not a long-lived
|
||||
session secret)."""
|
||||
|
||||
__tablename__ = "password_reset_tokens"
|
||||
|
||||
token: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
expires_at: Mapped[float] = mapped_column(Float)
|
||||
|
||||
|
||||
class BatteryLog(Base):
|
||||
"""Every battery report ever, per frame -- the permanent record behind
|
||||
the battery history chart (was a 20k-entry JSON array in config.json)."""
|
||||
|
||||
@@ -78,6 +78,7 @@ def api_config_save(
|
||||
timezone: str | None = Form(None),
|
||||
firmware_update_repo_url: str | None = Form(None),
|
||||
firmware_auto_update: bool | None = Form(None),
|
||||
battery_alert_threshold_pct: int | None = Form(None),
|
||||
frame: Frame = Depends(require_frame_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -118,6 +119,11 @@ def api_config_save(
|
||||
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
|
||||
if firmware_auto_update is not None:
|
||||
cfg.firmware_auto_update = firmware_auto_update
|
||||
if battery_alert_threshold_pct is not None:
|
||||
cfg.battery_alert_threshold_pct = max(-1, min(100, battery_alert_threshold_pct))
|
||||
# A changed threshold should be able to fire again immediately,
|
||||
# not stay suppressed by a flag set under the old value.
|
||||
cfg.battery_alert_sent = False
|
||||
cfg.stats_config_saves += 1
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import photo_queue, quiet_hours
|
||||
from ..auth import require_device
|
||||
from .. import mail, photo_queue, quiet_hours
|
||||
from ..auth import get_server_settings, require_device
|
||||
from ..db import frame_locked, get_db
|
||||
from ..face_labels import compute_face_labels
|
||||
from ..firmware import firmware_path
|
||||
@@ -194,14 +194,19 @@ def frame_battery(
|
||||
if not 0 <= body.percent <= 100:
|
||||
raise HTTPException(400, "percent must be 0-100")
|
||||
now = time.time()
|
||||
should_alert = False
|
||||
alert_email = ""
|
||||
alert_frame_name = ""
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.stats_battery_reports += 1
|
||||
if locked.battery_history and body.percent >= locked.battery_history[-1][1] + RECHARGE_JUMP_PCT:
|
||||
# Percent jumped up meaningfully -- the battery was recharged
|
||||
# (or swapped). Start a fresh discharge cycle so runtime and
|
||||
# discharge-rate estimates never span a charge.
|
||||
# discharge-rate estimates never span a charge -- and let a
|
||||
# low-battery alert fire again next time it actually gets low.
|
||||
locked.battery_history = []
|
||||
locked.stats_recharge_cycles += 1
|
||||
locked.battery_alert_sent = False
|
||||
locked.battery_history.append([now, body.percent])
|
||||
locked.battery_history = locked.battery_history[-BATTERY_HISTORY_MAX:]
|
||||
locked.battery_percent = body.percent
|
||||
@@ -216,6 +221,35 @@ def frame_battery(
|
||||
BatteryLog.ts
|
||||
).limit(count + 1 - BATTERY_LOG_MAX)
|
||||
db.execute(delete(BatteryLog).where(BatteryLog.id.in_(cutoff_ids)))
|
||||
|
||||
# Once per discharge cycle (see the recharge reset above), not
|
||||
# once per report -- a frame idling at 4% would otherwise get an
|
||||
# email every wake.
|
||||
if (
|
||||
locked.battery_alert_threshold_pct >= 0
|
||||
and body.percent <= locked.battery_alert_threshold_pct
|
||||
and not locked.battery_alert_sent
|
||||
and locked.owner is not None
|
||||
and locked.owner.email
|
||||
):
|
||||
should_alert = True
|
||||
alert_email = locked.owner.email
|
||||
alert_frame_name = locked.name or f"Frame {locked.id}"
|
||||
|
||||
if should_alert:
|
||||
# Network I/O outside the lock, same convention as everywhere
|
||||
# else in this file -- then a short re-lock to record that it
|
||||
# went out, only on actual success (an SMTP hiccup should let
|
||||
# the next report's still-below-threshold reading try again
|
||||
# rather than silently giving up for the rest of the cycle).
|
||||
settings = get_server_settings(db)
|
||||
sent = mail.send_email(
|
||||
settings, alert_email, f"{alert_frame_name}: battery low",
|
||||
f"{alert_frame_name}'s battery is at {body.percent}%.",
|
||||
)
|
||||
if sent:
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.battery_alert_sent = True
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
|
||||
+119
-1
@@ -18,19 +18,23 @@ from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import mail
|
||||
from ..auth import (
|
||||
SESSION_COOKIE,
|
||||
SESSION_LIFETIME_S,
|
||||
consume_password_reset_token,
|
||||
create_password_reset_token,
|
||||
create_session,
|
||||
current_session,
|
||||
current_user,
|
||||
destroy_session,
|
||||
get_server_settings,
|
||||
hash_password,
|
||||
users_exist,
|
||||
verify_password,
|
||||
)
|
||||
from ..db import get_db
|
||||
from ..models import Frame, PendingClaim, User, UserFrame
|
||||
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -181,6 +185,72 @@ def logout(request: Request, csrf_token: str = Form(""), db: Session = Depends(g
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/forgot-password", response_class=HTMLResponse)
|
||||
def forgot_password_page(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"forgot_password.html", {"request": request, "sent": False, "error": None}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/forgot-password", response_class=HTMLResponse)
|
||||
def forgot_password_submit(
|
||||
request: Request, email: str = Form(...), db: Session = Depends(get_db)
|
||||
):
|
||||
"""Always shows the same "check your email" result regardless of
|
||||
whether the address matches an account -- otherwise this endpoint
|
||||
would let anyone enumerate registered emails. Silently no-ops (same
|
||||
response) if SMTP isn't configured or the user has no email set."""
|
||||
email = email.strip().lower()
|
||||
user = db.scalars(select(User).where(User.email != "").where(User.email == email)).first()
|
||||
if user is not None:
|
||||
token = create_password_reset_token(db, user)
|
||||
reset_url = str(request.base_url).rstrip("/") + f"/reset-password/{token}"
|
||||
settings = get_server_settings(db)
|
||||
mail.send_email(
|
||||
settings, user.email, "Reset your ESPresso Frame password",
|
||||
f"Someone (hopefully you) asked to reset the password for '{user.username}'.\n\n"
|
||||
f"Reset it here (valid for 1 hour): {reset_url}\n\n"
|
||||
"If you didn't request this, ignore this email.",
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"forgot_password.html", {"request": request, "sent": True, "error": None}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/reset-password/{token}", response_class=HTMLResponse)
|
||||
def reset_password_page(token: str, request: Request, db: Session = Depends(get_db)):
|
||||
row = db.get(PasswordResetToken, token)
|
||||
valid = row is not None and row.expires_at > time.time()
|
||||
return templates.TemplateResponse(
|
||||
"reset_password.html", {"request": request, "token": token, "valid": valid, "error": None}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reset-password/{token}", response_class=HTMLResponse)
|
||||
def reset_password_submit(
|
||||
token: str, request: Request, password: str = Form(...), db: Session = Depends(get_db)
|
||||
):
|
||||
if len(password) < PASSWORD_MIN_LEN:
|
||||
return templates.TemplateResponse(
|
||||
"reset_password.html",
|
||||
{"request": request, "token": token, "valid": True,
|
||||
"error": f"Password must be at least {PASSWORD_MIN_LEN} characters."},
|
||||
)
|
||||
user = consume_password_reset_token(db, token)
|
||||
if user is None:
|
||||
return templates.TemplateResponse(
|
||||
"reset_password.html",
|
||||
{"request": request, "token": token, "valid": False, "error": None},
|
||||
)
|
||||
user.password_hash = hash_password(password)
|
||||
db.commit()
|
||||
logger.info("Password reset via email link for user '%s'", user.username)
|
||||
cookie_value, _ = create_session(db, user)
|
||||
response = RedirectResponse("/", status_code=303)
|
||||
_set_session_cookie(response, cookie_value)
|
||||
return response
|
||||
|
||||
|
||||
def _normalize_device_id(device_id: str) -> str:
|
||||
device_id = device_id.strip().lower()
|
||||
if len(device_id) != 12 or any(c not in "0123456789abcdef" for c in device_id):
|
||||
@@ -349,6 +419,7 @@ def settings_submit(
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
display_name: str = Form(""),
|
||||
email: str = Form(""),
|
||||
immich_url: str = Form(""),
|
||||
immich_api_key: str = Form(""),
|
||||
current_password: str = Form(""),
|
||||
@@ -362,6 +433,7 @@ def settings_submit(
|
||||
|
||||
error = None
|
||||
user.display_name = display_name.strip() or user.username
|
||||
user.email = email.strip().lower()
|
||||
user.immich_url = immich_url.strip()
|
||||
# Blank API key field = keep the existing one (it's never echoed back
|
||||
# into the form -- a secret that round-trips through HTML is a secret
|
||||
@@ -406,6 +478,7 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None
|
||||
"users": users,
|
||||
"frames": frames,
|
||||
"links_by_frame": links_by_frame,
|
||||
"smtp": get_server_settings(db),
|
||||
"notice": notice,
|
||||
"error": error,
|
||||
})
|
||||
@@ -535,6 +608,51 @@ def admin_end_legacy(
|
||||
return _render_admin(request, db, admin, notice=f"Legacy token disabled for frame #{frame_id}.")
|
||||
|
||||
|
||||
@router.post("/admin/smtp", response_class=HTMLResponse)
|
||||
def admin_smtp_save(
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
smtp_host: str = Form(""),
|
||||
smtp_port: int = Form(587),
|
||||
smtp_username: str = Form(""),
|
||||
smtp_password: str = Form(""),
|
||||
smtp_from_address: str = Form(""),
|
||||
smtp_use_tls: bool = Form(False),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Saves the SMTP config used for password-reset emails and battery-
|
||||
threshold alerts. Blank password = keep the existing one, same
|
||||
round-trip-avoidance as the Immich API key field in /settings."""
|
||||
admin = _require_admin_page(request, db)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
settings = get_server_settings(db)
|
||||
settings.smtp_host = smtp_host.strip()
|
||||
settings.smtp_port = max(1, min(65535, smtp_port))
|
||||
settings.smtp_username = smtp_username.strip()
|
||||
if smtp_password.strip():
|
||||
settings.smtp_password = smtp_password.strip()
|
||||
settings.smtp_from_address = smtp_from_address.strip()
|
||||
settings.smtp_use_tls = smtp_use_tls
|
||||
db.commit()
|
||||
return _render_admin(request, db, admin, notice="SMTP settings saved.")
|
||||
|
||||
|
||||
@router.post("/admin/smtp/test", response_class=HTMLResponse)
|
||||
def admin_smtp_test(request: Request, csrf_token: str = Form(""), db: Session = Depends(get_db)):
|
||||
admin = _require_admin_page(request, db)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
if not admin.email:
|
||||
return _render_admin(request, db, admin, error="Set an email on your own account (Settings) to test SMTP.")
|
||||
settings = get_server_settings(db)
|
||||
ok = mail.send_email(
|
||||
settings, admin.email, "ESPresso Frame test email",
|
||||
"If you're reading this, SMTP is configured correctly.",
|
||||
)
|
||||
if ok:
|
||||
return _render_admin(request, db, admin, notice=f"Test email sent to {admin.email}.")
|
||||
return _render_admin(request, db, admin, error="Failed to send -- check the SMTP settings and server logs.")
|
||||
|
||||
|
||||
@router.post("/admin/frames/{frame_id}/delete", response_class=HTMLResponse)
|
||||
def admin_delete_frame(
|
||||
frame_id: int,
|
||||
|
||||
@@ -66,6 +66,26 @@ async function loadControl() {
|
||||
|
||||
document.getElementById('take-control').addEventListener('click', takeControl);
|
||||
|
||||
// ---- Battery alerts card ----
|
||||
|
||||
document.getElementById('battery-alert-save').addEventListener('click', async () => {
|
||||
const raw = document.getElementById('battery_alert_threshold_pct').value.trim();
|
||||
const body = new URLSearchParams({
|
||||
battery_alert_threshold_pct: raw === '' ? '-1' : raw,
|
||||
});
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Saved.');
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Firmware card ----
|
||||
|
||||
document.getElementById('firmware-upload').addEventListener('click', async () => {
|
||||
|
||||
@@ -41,6 +41,39 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Email (SMTP)</h2>
|
||||
<p class="sub">Used for "forgot password" links and battery-low
|
||||
alerts (set per frame in its Configuration tab). Each user needs
|
||||
an email set in their own Settings for either to reach them.</p>
|
||||
<form method="post" action="/admin/smtp">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>SMTP server
|
||||
<input type="text" name="smtp_host" placeholder="smtp.example.com" value="{{ smtp.smtp_host }}">
|
||||
</label>
|
||||
<label>Port
|
||||
<input type="number" name="smtp_port" min="1" max="65535" value="{{ smtp.smtp_port }}">
|
||||
</label>
|
||||
<label>Username
|
||||
<input type="text" name="smtp_username" autocomplete="off" value="{{ smtp.smtp_username }}">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="smtp_password" autocomplete="off"
|
||||
placeholder="{% if smtp.smtp_password %}(unchanged -- enter a new one to replace){% else %}smtp password{% endif %}">
|
||||
</label>
|
||||
<label>From address
|
||||
<input type="text" name="smtp_from_address" placeholder="[email protected]" value="{{ smtp.smtp_from_address }}">
|
||||
</label>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="smtp_use_tls" name="smtp_use_tls" value="true" {% if smtp.smtp_use_tls %}checked{% endif %}>
|
||||
<label for="smtp_use_tls">Use STARTTLS</label>
|
||||
</div>
|
||||
<button type="submit">Save SMTP settings</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/smtp/test" style="margin-top: 8px;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary">Send test email to myself</button>
|
||||
</form>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Enroll a user</h2>
|
||||
<form method="post" action="/admin/users">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">Reset your password</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h2 class="card-title">Forgot password</h2>
|
||||
{% if sent %}
|
||||
<div class="status ok">If that email is on an account, a reset link is on its way.</div>
|
||||
<p class="sub" style="margin-top: 14px;">Nothing arriving? The server's
|
||||
SMTP settings may not be configured yet -- ask your admin.</p>
|
||||
{% else %}
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
<p class="sub">Enter the email on your account and we'll send a reset link.</p>
|
||||
<form method="post" action="/forgot-password">
|
||||
<label>Email
|
||||
<input type="email" name="email" required autofocus autocomplete="email">
|
||||
</label>
|
||||
<button type="submit">Send reset link</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<p class="sub" style="margin-top: 14px;"><a href="/login">Back to log in</a></p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -99,6 +99,19 @@
|
||||
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
|
||||
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Battery alerts</h2>
|
||||
<label>Email me when battery drops below (%)
|
||||
<input type="number" id="battery_alert_threshold_pct" min="0" max="100"
|
||||
value="{% if frame.battery_alert_threshold_pct >= 0 %}{{ frame.battery_alert_threshold_pct }}{% endif %}"
|
||||
placeholder="disabled">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Sent once per discharge cycle
|
||||
to the frame owner's email (set in Settings) -- clear the field to
|
||||
disable. Needs SMTP configured by an admin.</p>
|
||||
<button type="button" class="secondary" id="battery-alert-save">Save</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,5 +20,6 @@
|
||||
</label>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
<p class="sub" style="margin-top: 14px;"><a href="/forgot-password">Forgot your password?</a></p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">Reset your password</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h2 class="card-title">Set a new password</h2>
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
{% if valid %}
|
||||
<form method="post" action="/reset-password/{{ token }}">
|
||||
<label>New password
|
||||
<input type="password" name="password" minlength="8" required autofocus autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit">Set password</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="sub">This reset link is invalid or has expired -- links are
|
||||
only good for an hour.</p>
|
||||
<p class="sub" style="margin-top: 14px;"><a href="/forgot-password">Request a new one</a></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -14,6 +14,12 @@
|
||||
<label>Display name
|
||||
<input type="text" name="display_name" maxlength="64" value="{{ user.display_name }}">
|
||||
</label>
|
||||
<label>Email
|
||||
<input type="email" name="email" value="{{ user.email }}" placeholder="[email protected]">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Used for password-reset links
|
||||
and, for frames you own, battery-low alerts (set a threshold in a
|
||||
frame's Configuration tab).</p>
|
||||
<label>Immich URL
|
||||
<input type="text" name="immich_url" placeholder="http://your-immich-host:2283"
|
||||
value="{{ user.immich_url }}">
|
||||
|
||||
Reference in New Issue
Block a user