diff --git a/server/README.md b/server/README.md index 9d5d996..e0a484f 100644 --- a/server/README.md +++ b/server/README.md @@ -79,6 +79,18 @@ algorithm itself -- it just streams the response straight to the panel. view current + upcoming, "show next", advance, back -- nothing else. The share QR stays public (it creates a 30-minute Immich share link for exactly the photo shown). +- **Email (optional).** An admin sets an SMTP server once (`/admin` -- + server, port, username/password, from address, STARTTLS on/off; a + "send test email to myself" button, delivered to the admin's own + email); each user sets their own email in Settings. Once both are in + place: **"Forgot password?"** on the login page emails a one-hour + reset link (a generic "check your email" response either way, so the + endpoint can't be used to enumerate accounts), and a frame's + Configuration tab can set a **battery-alert threshold** -- an email to + the frame's owner the first time a report drops to or below it, not + again until a recharge is detected and it crosses again. No SMTP + configured, or no email on the relevant account, and both features + silently no-op rather than erroring. ## Endpoints @@ -117,7 +129,9 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`, flat-scalar parser. - `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle history (feeds the runtime estimate) plus a permanent per-frame - battery log (the Stats chart). Only sent on battery power. + battery log (the Stats chart). Only sent on battery power. Also where + the battery-alert threshold (below) is checked and, at most once per + discharge cycle, emailed to the owner. - `GET /frame/firmware` -- streams the frame's staged OTA image. ### Web API (`/api/frames/{id}/...` -- session auth; *view* for reads, *control* for writes) @@ -138,7 +152,8 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`, on-device manage overlay still renders native, a known limitation), `quiet_hours_*` + `timezone` (a pure server-side decision shaping what `refresh_interval_s` gets handed to the device), - `firmware_update_repo_url`, `firmware_auto_update`). + `firmware_update_repo_url`, `firmware_auto_update`, + `battery_alert_threshold_pct` -- percent, or `-1`/blank to disable). - `POST .../take-control` -- always succeeds for a linked user. - `GET .../stats`, `GET .../battery-log`, `GET .../thumbnail/{asset_id}`. - `POST .../firmware` (manual .bin upload, esp_app_desc_t-validated), diff --git a/server/app/auth.py b/server/app/auth.py index 69d5461..fe960f0 100644 --- a/server/app/auth.py +++ b/server/app/auth.py @@ -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) diff --git a/server/app/mail.py b/server/app/mail.py new file mode 100644 index 0000000..f32227e --- /dev/null +++ b/server/app/mail.py @@ -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 diff --git a/server/app/migration.py b/server/app/migration.py index c106f78..8989620 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -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() diff --git a/server/app/models.py b/server/app/models.py index b254fcc..f0bd5b2 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -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/.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).""" diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index 7b79b28..adec110 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -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"} diff --git a/server/app/routers/device.py b/server/app/routers/device.py index 8f161ec..095f86a 100644 --- a/server/app/routers/device.py +++ b/server/app/routers/device.py @@ -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"} diff --git a/server/app/routers/pages.py b/server/app/routers/pages.py index 93765e5..4aa6a72 100644 --- a/server/app/routers/pages.py +++ b/server/app/routers/pages.py @@ -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, diff --git a/server/app/static/frame_config.js b/server/app/static/frame_config.js index cfe28b7..416a5b9 100644 --- a/server/app/static/frame_config.js +++ b/server/app/static/frame_config.js @@ -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 () => { diff --git a/server/app/templates/admin.html b/server/app/templates/admin.html index a90dfd6..5e75b6d 100644 --- a/server/app/templates/admin.html +++ b/server/app/templates/admin.html @@ -41,6 +41,39 @@ +

Email (SMTP)

+

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.

+
+ + + + + + +
+ + +
+ +
+
+ + +
+

Enroll a user

diff --git a/server/app/templates/forgot_password.html b/server/app/templates/forgot_password.html new file mode 100644 index 0000000..4cd1b6c --- /dev/null +++ b/server/app/templates/forgot_password.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block page_class %}page-narrow{% endblock %} + +{% block subtitle %} +

Reset your password

+{% endblock %} + +{% block content %} +
+

Forgot password

+ {% if sent %} +
If that email is on an account, a reset link is on its way.
+

Nothing arriving? The server's + SMTP settings may not be configured yet -- ask your admin.

+ {% else %} + {% if error %}
{{ error }}
{% endif %} +

Enter the email on your account and we'll send a reset link.

+ + + + + {% endif %} +

Back to log in

+
+{% endblock %} diff --git a/server/app/templates/frame_config.html b/server/app/templates/frame_config.html index f3afdb8..ebafb02 100644 --- a/server/app/templates/frame_config.html +++ b/server/app/templates/frame_config.html @@ -99,6 +99,19 @@ + +
+

Battery alerts

+ +

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.

+ +
diff --git a/server/app/templates/login.html b/server/app/templates/login.html index 94510e9..8300eae 100644 --- a/server/app/templates/login.html +++ b/server/app/templates/login.html @@ -20,5 +20,6 @@ +

Forgot your password?

{% endblock %} diff --git a/server/app/templates/reset_password.html b/server/app/templates/reset_password.html new file mode 100644 index 0000000..ea69e7d --- /dev/null +++ b/server/app/templates/reset_password.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block page_class %}page-narrow{% endblock %} + +{% block subtitle %} +

Reset your password

+{% endblock %} + +{% block content %} +
+

Set a new password

+ {% if error %}
{{ error }}
{% endif %} + {% if valid %} +
+ + +
+ {% else %} +

This reset link is invalid or has expired -- links are + only good for an hour.

+

Request a new one

+ {% endif %} +
+{% endblock %} diff --git a/server/app/templates/settings.html b/server/app/templates/settings.html index 640116f..7278d75 100644 --- a/server/app/templates/settings.html +++ b/server/app/templates/settings.html @@ -14,6 +14,12 @@ + +

Used for password-reset links + and, for frames you own, battery-low alerts (set a threshold in a + frame's Configuration tab).