From 2797da16977073f0459ec2f54db49657a617d14b Mon Sep 17 00:00:00 2001 From: badbread Date: Sat, 8 Aug 2026 10:38:31 -0700 Subject: [PATCH 1/2] feat(notifications): redesign the console Notifications pane (channels, alert-text editor, quiet hours) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notifications-pane UX overhaul (server + web console): WU-1 Pushover/global channel visibility - The engine fans out to every enabled channel regardless of owner, but the console listed only the caller's own + global channels, hiding a channel created under another account. Admins now list ALL channels with owner attribution (list_all_notification_channels, LEFT JOIN users); non-admin scope (own only) unchanged. - ChannelResponse gains `global` (bool) and `owner_username`; the global checkbox was cosmetic (missing field) and a no-op on edit (UpdateChannelRequest dropped it). UpdateChannelRequest/Params gain an admin-only owner toggle; a non-admin supplying `global` on update is rejected (403). WU-3 System-alert editor - Replace the per-row inline "Customize alert text" wall with a compact "Customize" button + "Customized" chip and ONE shared modal (#sysalert-editor-modal): message textarea, conditional Title (only when a Pushover/ntfy destination is configured, named in the hint), a per-alert token legend (click-to-insert), and a live preview rendered as a generic notification card with a sample-image slot gated on real delivery. - Save issues the per-alert PUT, decoupled from the row-toggle bulk save (notifSaveSystemAlerts no longer reads/sends templates). WU-4 Quiet hours - Both quiet-hours pairs become whole-hour `s (0..=23, server rejects out-of-range) inside collapsed `details.detail-section`. Destinations show an owner chip; the "global" checkbox is admin-only (`GET /auth/me` gates it) and admins list ALL channels (`owner_username` attribution) | | Clients | android polling/local-notification path, desktop toasts, iOS `Settings` | Delivery ends on a client | | Env/config | rows B and I for any new channel credential (`ALERT_WEBHOOK_URL`, ntfy/Pushover keys) | Never log or hardcode channel secrets | | Docs | `docs/AI-INSTALL.md` section 9 (monitoring/alerting); `docs-site/docs/notifications/*` | | diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 970bdfdf..41d12247 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -8,6 +8,82 @@ revisit. --- +## 2026-08-08, Notifications pane: one shared modal for alert-text editing, whole-hour quiet-hours pickers validated 0..=23 server-side, admins see every channel + +**Context.** A Notifications-pane UX pass surfaced four issues. (1) The engine +fans out to **every enabled channel regardless of owner** +(`db::list_enabled_channels`), but the console's Destinations list called +`list_notification_channels` scoped to the caller's own + global channels, so a +channel created under a different account was invisible even to an admin — and +two round-trip defects made the "global" checkbox cosmetic (`ChannelResponse` +had no `global` field; `UpdateChannelRequest` silently dropped `global`). +(2) The system-alerts table rendered a full per-row `
` "Customize alert +text" block for all ~13 alerts — a wall of collapsibles. (3) Quiet hours were +raw `type=number` inputs; a value like `2200` was stored verbatim and only +clamped at read time (`in_quiet_hours` `clamp(0,23)`) to the zero-width window +`23..23`, so quiet hours **silently never fired** — no server-side validation +existed. (4) The Title field showed for every provider though only Pushover/ntfy +consume a rendered title. + +**Decisions.** + +- **Admins list ALL channels with owner attribution.** `list_notification_channels` + now returns every channel for an admin (via `list_all_notification_channels`, + a `LEFT JOIN users`), each with `owner_username`; non-admin scope (own only) + is unchanged. `ChannelResponse` gained `global: bool` and `owner_username`, and + `UpdateChannelRequest`/`UpdateChannelParams` gained an admin-only owner toggle + (`global` → `user_id = NULL`/claim). A non-admin supplying `global` on update + is rejected (403), not silently ignored. **Rejected:** filtering the engine + fan-out by owner instead (that fan-out is correct — a global/foreign channel + is a real destination); leaving the console blind to foreign channels (the + reported bug). + +- **One shared modal (`#sysalert-editor-modal`) for alert-text editing**, opened + per alert from a compact "✎ Customize" row button (+ a "Customized" chip when + a template is set). The modal holds the message textarea, a conditional Title + field, a per-alert token legend (click-to-insert, with sample values), and a + live preview rendered as ONE generic notification-card (not per-provider + chrome); it saves via the existing per-alert PUT, decoupled from the row-toggle + bulk save. **Rejected:** the status-quo inline `
` per row (13× noise); + a per-row popover (positioning/collision math, and it clips inside the table's + overflow wrap). The `.modal-overlay` pattern already exists in `admin.html`, + works identically in the desktop WebView2 embed and any Android WebView (fixed + overlay, no anchoring), and gives room for legend + preview without inflating + the list. + +- **Whole-hour `s replace the old raw number inputs so an out-of-range value + (e.g. "2200" pasted as military time) is unrepresentable — that value used to + be stored verbatim and clamped to a zero-width window at read time, silently + disabling quiet hours. Whole hours only; minutes are not representable. */ +function hourLabel(h) { + const ap = h < 12 ? 'AM' : 'PM'; + const h12 = (h % 12) === 0 ? 12 : (h % 12); + return `${h12}:00 ${ap}`; +} +/* Options for a quiet-hour select. `sel` is the stored 0..23 value or null. + First option is "— off —" (value ""). A stored value outside 0..23 selects + none (the caller shows a warning); it is never silently coerced to an hour. */ +function quietHourSelectOptions(sel) { + const valid = (typeof sel === 'number' && sel >= 0 && sel <= 23); + let out = ``; + for (let h = 0; h < 24; h++) { + out += ``; + } + return out; +} +/* True when a stored quiet-hour value is present but out of the 0..23 range + (legacy bad data written before server-side validation existed). */ +function quietHourOutOfRange(v) { + return v != null && !(typeof v === 'number' && v >= 0 && v <= 23); +} +/* Human summary of a quiet-hours window for a collapsed line. */ +function quietHoursSummary(start, end) { + if (start == null || end == null || quietHourOutOfRange(start) || quietHourOutOfRange(end)) { + return 'off'; + } + return `${hourLabel(start)} to ${hourLabel(end)}`; +} + +/* Compact "as of Ns ago" from an ISO timestamp (motion-cache staleness). */ +function fmtAgo(iso) { + if (!iso) return ''; + const t = Date.parse(iso); + if (isNaN(t)) return ''; + let s = Math.max(0, Math.round((Date.now() - t) / 1000)); + if (s < 60) return s + 's ago'; + const m = Math.floor(s / 60); + if (m < 60) return m + 'm ago'; + const h = Math.floor(m / 60); + if (h < 24) return h + 'h ago'; + return Math.floor(h / 24) + 'd ago'; +} + /* Per-kind connection-field descriptors. */ const NOTIF_PROVIDER_FIELDS = { discord: [{ id:'webhook_url', label:'Webhook URL', type:'url', hint:'Settings → Integrations → Webhooks → Copy URL.' }], @@ -10586,12 +10659,15 @@ `; try { - const [channels, rules, settings, sysAlerts] = await Promise.all([ + const [channels, rules, settings, sysAlerts, me] = await Promise.all([ api('/notifications/channels').catch(() => []), api('/notifications/rules').catch(() => []), api('/notifications/settings').catch(() => ({ enabled: true })), api('/notifications/system-alerts').catch(() => []), + api('/auth/me').catch(() => null), ]); + NOTIF_ME_ID = me ? me.id : null; + NOTIF_IS_ADMIN = !!(me && me.is_admin); NOTIF_CHANNELS = channels; NOTIF_RULES = rules; NOTIF_ENABLED = settings && settings.enabled !== false; @@ -10626,6 +10702,18 @@ } } +/* Owner-attribution chip for a destination row. "Global" for an ownerless + channel, "Yours" when owned by this session's user, otherwise the owner's + username (so an admin sees a channel created under another account). The + server only sends owner_username in the admin (all-channels) listing. */ +function notifOwnerChipHtml(ch) { + let label, color; + if (ch.global) { label = 'Global'; color = 'var(--accent2)'; } + else if (NOTIF_ME_ID && ch.user_id === NOTIF_ME_ID) { label = 'Yours'; color = 'var(--dim)'; } + else { label = ch.owner_username ? ch.owner_username : 'Other user'; color = 'var(--warn)'; } + return `${esc(label)}`; +} + function _renderNotificationsPane() { const kindOpts = Object.keys(NOTIF_PROVIDER_FIELDS) .map(k => ``) @@ -10638,11 +10726,16 @@ const kindLabel = NOTIF_KIND_LABEL[ch.kind] || ch.kind; const scopeNote = (ch.camera_ids && ch.camera_ids.length) ? `${ch.camera_ids.length} camera${ch.camera_ids.length!==1?'s':''}` : 'All cameras'; + /* Owner attribution: Global (no owner), Yours (owned by this session's + user), or the owner's username — so an admin can tell a channel + created under another account apart from their own. */ + const ownerChip = notifOwnerChipHtml(ch); return `
${esc(kindLabel)} ${esc(ch.name)} + ${ownerChip} ${ch.enabled?'Enabled':'Disabled'} ${esc(scopeNote)}
@@ -10743,41 +10836,21 @@ thresholdField = ``; } const k = r.event_key; - const tokenHint = _sysAlertTokens(k).map(t => '%' + t + '%').join(' '); - const msgVal = r.message_template != null ? r.message_template : ''; - const titleVal = r.title_template != null ? r.title_template : ''; - const previewSrc = msgVal !== '' ? msgVal : SYS_ALERT_DEFAULT_MSG_TPL; - const templateRow = ` - - -
- Customize alert text -
- - - - -
- Tokens for this alert: ${esc(tokenHint)}. - An unknown token is left as-is so a typo is visible. Times are UTC. -
-
- Preview: ${esc(_previewAlertTemplate(previewSrc, k))} -
- -
-
- - `; + /* A "Customized" chip when either template override is set, so a modified + alert is visible at a glance without opening the editor. */ + const customized = (r.message_template != null || r.title_template != null); + const chip = customized + ? `Customized` + : ''; return `
${esc(meta.title)}
${esc(meta.desc)}
+
+ + ${chip} +
- ${templateRow}`; + `; }).join(''); + /* Quiet-hours window (system alerts) — whole-hour selects, collapsed. */ + const badRange = quietHourOutOfRange(NOTIF_SYS_QUIET_START) || quietHourOutOfRange(NOTIF_SYS_QUIET_END); + const rangeWarn = badRange + ? `
A stored quiet-hours value is out of range (0–23) and is ignored. Pick an hour and save to fix it.
` + : ''; + return `
System alerts
@@ -10806,18 +10885,25 @@ even during the quiet-hours window below (recommended for footage-loss-critical events).
-
-
- - -
-
- - +
+ + + Quiet hours + ${esc(quietHoursSummary(NOTIF_SYS_QUIET_START, NOTIF_SYS_QUIET_END))} + +
+
+ Server local time. Applies only to system alerts that do NOT bypass quiet hours. Set both to "— off —" to disable. +
+
+ Quiet from + + to + +
+ ${rangeWarn}
-
+
@@ -10831,7 +10917,11 @@ async function notifSaveSystemAlerts() { setMsg('notif-sysalerts-msg', ''); - const rows = document.querySelectorAll('#notif-sysalerts-section tr[data-key]:not(.sysalert-tpl-row)'); + // Row toggles + thresholds only. Alert-text templates are edited and saved + // independently through the per-alert editor modal (sysAlertEditorSave), so + // this bulk save no longer reads or sends message_template/title_template + // (absent fields = keep, per the all-optional PUT). + const rows = document.querySelectorAll('#notif-sysalerts-section tr[data-key]'); const calls = []; rows.forEach(tr => { const key = tr.dataset.key; @@ -10847,12 +10937,6 @@ body.threshold_fraction = raw !== '' ? (parseFloat(raw) / 100) : null; } } - // Alert-text templates (migration 0079). A blank field clears to null on the - // server = "use the built-in default" (one-click restore). - const msgEl = document.querySelector('.sysalert-msg-tpl[data-key="' + CSS.escape(key) + '"]'); - const titleEl = document.querySelector('.sysalert-title-tpl[data-key="' + CSS.escape(key) + '"]'); - if (msgEl) { const v = msgEl.value.trim(); body.message_template = v !== '' ? v : null; } - if (titleEl) { const v = titleEl.value.trim(); body.title_template = v !== '' ? v : null; } calls.push(api('/notifications/system-alerts/' + encodeURIComponent(key), { method: 'PUT', body: JSON.stringify(body) })); }); @@ -10878,24 +10962,200 @@ } } -/* Live-preview the message template for one alert type using sample token - values. Falls back to the default template when the field is blank. */ -function sysAlertPreview(key) { - const msgEl = document.querySelector('.sysalert-msg-tpl[data-key="' + CSS.escape(key) + '"]'); - const out = document.querySelector('.sysalert-preview[data-key="' + CSS.escape(key) + '"]'); - if (!out) return; - const src = (msgEl && msgEl.value.trim() !== '') ? msgEl.value : SYS_ALERT_DEFAULT_MSG_TPL; - out.textContent = _previewAlertTemplate(src, key); +/* ── System-alert text editor (one shared modal) ── + Opened per alert from the "✎ Customize" row button. Contains the message + textarea, a conditional title field, a per-alert token legend, and a live + preview rendered as a generic notification card. Save issues the per-alert + PUT (only message_template/title_template), decoupled from the row-toggle + bulk "Save system alerts". */ +let SAE_IMG_URL = null; /* fetched sample snapshot URL for the preview card, or null */ + +/* True when the current message/title (as typed) would deliver an image for + this alert: a channel must attach one AND the event type must carry one. */ +function sysAlertImageWouldDeliver(key) { + if (key !== 'plate_watchlist_hit') return false; // only this event carries an image today + return NOTIF_CHANNELS.some(ch => + ch.snapshot_mode && ch.snapshot_mode !== 'none' && (NOTIF_IMG_CAP[ch.kind] || 'none') !== 'none'); +} +/* True when a configured destination consumes the rendered title (Pushover/ntfy). */ +function sysAlertTitleRelevant() { + return NOTIF_CHANNELS.some(ch => ch.kind === 'pushover' || ch.kind === 'ntfy'); +} +/* Names of the Pushover/ntfy destinations that would use the title. */ +function sysAlertTitleDestNames() { + const names = NOTIF_CHANNELS.filter(ch => ch.kind === 'pushover' || ch.kind === 'ntfy').map(ch => ch.name); + return names.length ? names.join(', ') : 'Pushover and ntfy'; +} +/* Inline 16:9 gray placeholder (camera glyph) used when the sample snapshot + can't load — data URI so it needs no network. */ +const SAE_IMG_PLACEHOLDER = + 'data:image/svg+xml;utf8,' + encodeURIComponent( + '' + + '' + + '' + + '' + + ''); + +async function sysAlertOpenEditor(key) { + SYS_ALERT_EDIT_KEY = key; + const meta = SYS_ALERT_META[key] || { title: key }; + const r = (NOTIF_SYS_ALERTS.find(x => x.event_key === key)) || {}; + const msgVal = r.message_template != null ? r.message_template : ''; + const titleVal = r.title_template != null ? r.title_template : ''; + const titleRelevant = sysAlertTitleRelevant(); + + /* Fetch a sample snapshot once (only when an image would actually deliver); + the preview card reuses it across keystrokes with an SVG fallback. */ + SAE_IMG_URL = null; + if (sysAlertImageWouldDeliver(key) && CAMS.length) { + try { SAE_IMG_URL = await snapshotUrl(CAMS[0].id); } catch { SAE_IMG_URL = null; } + } + + /* Token legend: %token% | sample value, clickable to insert at the caret. */ + const legendRows = _sysAlertTokens(key).map(t => { + const sample = (t === 'event') ? meta.title : (SYS_ALERT_TOKEN_SAMPLES[t] != null ? SYS_ALERT_TOKEN_SAMPLES[t] : ''); + return ` + + `; + }).join(''); + + const titleField = titleRelevant ? ` + + +
Used by: ${esc(sysAlertTitleDestNames())}.
` + : ''; + + $('sae-modal-title').textContent = 'Customize: ' + (meta.title || key); + $('sae-modal-body').innerHTML = ` + + + ${titleField} + +
+
Tokens for this alert
+
Click a token to insert it. An unknown token is left as-is so a typo is visible. Times are UTC.
+
%${esc(t)}%${esc(sample)}
${legendRows}
+ + +
+
Preview
+
+
Delivery varies by provider; Discord and Telegram attach up to two images, Pushover and ntfy one, Slack and webhooks link only.
+
+ + +
`; + + $('sysalert-editor-modal').classList.remove('hidden'); + sysAlertEditorPreview(); +} + +function sysAlertCloseEditor() { + const m = $('sysalert-editor-modal'); + if (m) m.classList.add('hidden'); + SYS_ALERT_EDIT_KEY = null; + SAE_IMG_URL = null; +} + +/* Insert a %token% at the message textarea's caret (no execCommand). */ +function sysAlertEditorInsertToken(tok) { + const el = $('sae-msg'); + if (!el) return; + const s = el.selectionStart != null ? el.selectionStart : el.value.length; + const e = el.selectionEnd != null ? el.selectionEnd : el.value.length; + el.value = el.value.slice(0, s) + tok + el.value.slice(e); + const caret = s + tok.length; + el.focus(); + el.setSelectionRange(caret, caret); + sysAlertEditorPreview(); +} + +/* Render the live preview as a generic push-notification card. */ +function sysAlertEditorPreview() { + const key = SYS_ALERT_EDIT_KEY; + const out = $('sae-preview'); + if (!key || !out) return; + const msgEl = $('sae-msg'); + const titleEl = $('sae-title-input'); + const msgSrc = (msgEl && msgEl.value.trim() !== '') ? msgEl.value : SYS_ALERT_DEFAULT_MSG_TPL; + const titleSrc = (titleEl && titleEl.value.trim() !== '') ? titleEl.value : SYS_ALERT_DEFAULT_TITLE_TPL; + const bodyText = _previewAlertTemplate(msgSrc, key); + const titleText = _previewAlertTemplate(titleSrc, key); + + const showImg = sysAlertImageWouldDeliver(key); + let imgHtml = ''; + if (showImg) { + const src = SAE_IMG_URL || SAE_IMG_PLACEHOLDER; + imgHtml = ``; + } else { + /* Distinguish "this alert never carries an image" from "an image-capable + destination just isn't configured" so the operator knows which to fix. */ + const anyImgChannel = NOTIF_CHANNELS.some(ch => (NOTIF_IMG_CAP[ch.kind]||'none')!=='none' && ch.snapshot_mode && ch.snapshot_mode!=='none'); + const why = (key === 'plate_watchlist_hit' && !anyImgChannel) + ? 'No destination attaches images' + : 'No image for this alert type'; + imgHtml = `${esc(why)}`; + } + + out.innerHTML = ` +
+
+
+ + Crumbnow +
+
${esc(titleText)}
+
${esc(bodyText)}
+
+ ${imgHtml} +
`; } -/* "Restore default": clear both template fields so the next save stores NULL, - which the server renders as the built-in default for this alert type. */ -function sysAlertRestoreDefault(key) { - const msgEl = document.querySelector('.sysalert-msg-tpl[data-key="' + CSS.escape(key) + '"]'); - const titleEl = document.querySelector('.sysalert-title-tpl[data-key="' + CSS.escape(key) + '"]'); - if (msgEl) msgEl.value = ''; - if (titleEl) titleEl.value = ''; - sysAlertPreview(key); +/* Clear both fields (next save stores NULL = built-in default) and re-preview. */ +function sysAlertEditorRestoreDefault() { + const msgEl = $('sae-msg'); if (msgEl) msgEl.value = ''; + const titleEl = $('sae-title-input'); if (titleEl) titleEl.value = ''; + sysAlertEditorPreview(); +} + +/* Save the per-alert templates via PUT, update the in-memory row + chip, close. */ +async function sysAlertEditorSave() { + const key = SYS_ALERT_EDIT_KEY; + if (!key) return; + const line = $('sae-msg-line'); + if (line) line.textContent = ''; + const msgEl = $('sae-msg'); + const titleEl = $('sae-title-input'); + const body = {}; + const msgVal = msgEl ? msgEl.value.trim() : ''; + body.message_template = msgVal !== '' ? msgVal : null; + /* Only send title_template when the field is rendered (Pushover/ntfy present); + when hidden, omit it so a stored title is left untouched (absent = keep). */ + if (titleEl) { const v = titleEl.value.trim(); body.title_template = v !== '' ? v : null; } + + try { + await api('/notifications/system-alerts/' + encodeURIComponent(key), { method: 'PUT', body: JSON.stringify(body) }); + /* Reflect the change in the in-memory row so the "Customized" chip updates. */ + let row = NOTIF_SYS_ALERTS.find(x => x.event_key === key); + if (!row) { row = { event_key: key }; NOTIF_SYS_ALERTS.push(row); } + row.message_template = body.message_template; + if ('title_template' in body) row.title_template = body.title_template; + toast('Alert text saved.'); + sysAlertCloseEditor(); + const sec = $('notif-sysalerts-section'); + if (sec) sec.innerHTML = _renderSystemAlertsSection(); + } catch (e) { + if (line) { line.textContent = e.message; line.classList.add('err'); } + } } /* Render the Configure form, either for a new channel (ch=null, uses NOTIF_NEW_KIND) @@ -10978,10 +11238,10 @@ Enabled - ` : ''} @@ -11065,33 +11325,44 @@ -
-
Quiet hours ${infoHint('Both hours are inclusive (0–23 wall-clock). Leave blank to disable quiet hours.')}
-
-
- - +
+ + + Quiet hours + ${esc(quietHoursSummary(def.quiet_start_hour, def.quiet_end_hour))} + +
+
+ Server local time. Set both to "— off —" to disable quiet hours.
-
- - +
+ Quiet from + + to +
+ ${(quietHourOutOfRange(def.quiet_start_hour) || quietHourOutOfRange(def.quiet_end_hour)) + ? `
A stored quiet-hours value is out of range (0–23) and is ignored. Pick an hour and save to fix it.
` : ''}
-
+
${CAMS.length ? ` -
-
Per-camera overrides
-
"Default" inherits the mode set above. Only non-Default rows are saved.
-
- - - ${camOverrideRows} -
CameraMode
+
+ + + Per-camera overrides + ${(() => { const n = NOTIF_RULES.filter(r => r.camera_id && (r.presence_mode||'default')!=='default').length; return n ? `${n} set` : 'none set'; })()} + +
+
"Default" inherits the mode set above. Only non-Default rows are saved.
+
+ + + ${camOverrideRows} +
CameraMode
+
-
` : ''}`; +
` : ''}`; } /* ── Open / close the Configure panel ── */ @@ -11177,13 +11448,18 @@ omits it and the stored value is untouched. */ const modeEl = $('notif-f-snapshot-mode'); + /* Global toggle is admin-only and only rendered for admins. When the checkbox + is absent (non-admin session) leave it undefined so the save omits it — the + server rejects a non-admin that supplies `global` on edit. */ + const globalEl = $('notif-f-global'); + return { name: name.trim(), kind, camera_ids, snapshot_mode: modeEl ? modeEl.value : undefined, enabled: !!($('notif-f-enabled') || {}).checked, - global: !!($('notif-f-global') || {}).checked, + global: globalEl ? !!globalEl.checked : undefined, _config: config, _anyNewSecrets: anyNew, }; @@ -11203,8 +11479,8 @@ config: body._config, camera_ids: body.camera_ids, enabled: body.enabled, - global: body.global, }; + if (body.global !== undefined) payload.global = body.global; if (body.snapshot_mode !== undefined) payload.snapshot_mode = body.snapshot_mode; try { @@ -11233,8 +11509,8 @@ name: body.name, camera_ids: body.camera_ids, enabled: body.enabled, - global: body.global, }; + if (body.global !== undefined) payload.global = body.global; if (body.snapshot_mode !== undefined) payload.snapshot_mode = body.snapshot_mode; if (body._anyNewSecrets) payload.config = body._config; diff --git a/services/api/src/notifications.rs b/services/api/src/notifications.rs index 30bdc439..b43a7bf9 100644 --- a/services/api/src/notifications.rs +++ b/services/api/src/notifications.rs @@ -266,6 +266,25 @@ async fn list_rules( Ok(Json(rules)) } +/// Reject a quiet-hours value that is not a whole hour in `0..=23`. +/// +/// Quiet-hours are whole-hour, server-local wall-clock values. Before this +/// check a bad value (e.g. `2200` pasted as military time) was stored verbatim +/// and only clamped at read time by [`in_quiet_hours`] (`clamp(0, 23)`), which +/// turned `start=2200,end=700` into the zero-width window `23..23` — quiet +/// hours then silently never fired. Rejecting at write time makes the failure +/// visible instead of a stored no-op. `None` is always valid (means "unset"). +fn validate_quiet_hour(hour: Option, field: &str) -> Result<(), ApiError> { + if let Some(h) = hour { + if !(0..=23).contains(&h) { + return Err(ApiError::BadRequest(format!( + "{field} must be a whole hour in 0..=23 (server local time); got {h}" + ))); + } + } + Ok(()) +} + /// Shared logic for upserting a rule for `(user_id, camera_id)`. async fn do_upsert_rule( pool: &Pool, @@ -279,6 +298,8 @@ async fn do_upsert_rule( "presence_mode must be 'off', 'away_only', or 'always'; got '{presence_mode}'" ))); } + validate_quiet_hour(body.quiet_start_hour, "quiet_start_hour")?; + validate_quiet_hour(body.quiet_end_hour, "quiet_end_hour")?; let p = db::UpsertNotificationRuleParams { user_id, camera_id, @@ -484,6 +505,12 @@ pub struct UpdateChannelRequest { pub snapshot_mode: Option, /// Legacy snapshot toggle; used only when `snapshot_mode` is absent. pub include_snapshot: Option, + /// Admin-only global toggle. Omit to keep the stored owner; `true` → make + /// the channel global (`user_id = NULL`); `false` → claim ownership for the + /// caller. A non-admin supplying this field is rejected (403); create-time + /// `global` was already honored via `CreateChannelRequest`, but on edit this + /// field was previously dropped by serde, so the checkbox did nothing. + pub global: Option, } /// A channel row with secrets masked, safe for API responses. @@ -494,6 +521,16 @@ pub struct UpdateChannelRequest { pub struct ChannelResponse { pub id: Uuid, pub user_id: Option, + /// `true` when the channel is global (no owner, `user_id IS NULL`). Kept as + /// an explicit field because the console renders the "Make available to all + /// users (global)" checkbox from it — deriving it client-side from + /// `user_id` is fragile (older clients read a missing field as unchecked). + pub global: bool, + /// Owner's username for admin attribution, or `None` for a global channel + /// (and always `None` in a non-admin listing, which only returns own + /// channels). Populated by the admin-scoped listing's join on `users`. + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_username: Option, pub kind: String, pub name: String, pub enabled: bool, @@ -515,6 +552,8 @@ impl ChannelResponse { let masked_config = channel_notify::mask_channel_config(&ch.config); Self { id: ch.id, + global: ch.user_id.is_none(), + owner_username: ch.owner_username, user_id: ch.user_id, kind: ch.kind, name: ch.name, @@ -697,6 +736,20 @@ async fn update_channel( existing.snapshot_mode, )?; + // Global toggle is admin-only. Non-admins must not be able to promote a + // channel to global (a system-wide destination) or claim/relinquish + // ownership; reject rather than silently ignore so the behavior is honest. + let set_owner = match body.global { + None => None, + Some(_) if !user.is_admin() => { + return Err(ApiError::Forbidden( + "only admins may change a channel's global scope".to_owned(), + )); + } + Some(true) => Some(None), // → global (user_id = NULL) + Some(false) => Some(Some(user.user_id)), // → claim ownership + }; + let params = db::UpdateChannelParams { id, name: body @@ -710,6 +763,7 @@ async fn update_channel( config: body.config, // None = keep stored camera_ids: body.camera_ids.or(existing.camera_ids), snapshot_mode, + set_owner, }; let ch = db::update_notification_channel(state.pool(), ¶ms) @@ -866,6 +920,8 @@ async fn put_notification_settings( State(state): State, Json(body): Json, ) -> Result, ApiError> { + validate_quiet_hour(body.system_quiet_start_hour, "system_quiet_start_hour")?; + validate_quiet_hour(body.system_quiet_end_hour, "system_quiet_end_hour")?; db::set_notifications_enabled(state.pool(), body.enabled) .await .context("set_notifications_enabled")?; diff --git a/services/api/tests/notification_pane.rs b/services/api/tests/notification_pane.rs new file mode 100644 index 00000000..6568f0e2 --- /dev/null +++ b/services/api/tests/notification_pane.rs @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Integration tests for the Notifications-pane redesign (WU-1 + WU-4 server +//! halves): +//! +//! * **Quiet-hours range validation** — a whole-hour quiet-hours value must be +//! in `0..=23`. Before this, a bad value like `2200` (military time pasted +//! into the old number input) was stored verbatim and only clamped at read +//! time to a zero-width window, so quiet hours silently never fired. Both the +//! per-user rules endpoint and the admin system-alerts settings endpoint are +//! covered. +//! * **Admin lists ALL channels with owner attribution** — the engine fans out +//! to every enabled channel regardless of owner, but the console previously +//! listed only the caller's own + global channels, hiding a channel created +//! under another account. An admin must now see every channel with the +//! owner's username; a non-admin still sees only their own. +//! * **Global-flag round-trip on update** — `ChannelResponse` exposes `global`, +//! and toggling it on an update actually persists (`user_id = NULL`). A +//! non-admin may not change a channel's global scope (403). +//! +//! Same harness as `notification_channel_rbac.rs`: `tests/support` re-includes +//! the real `src/` modules so these exercise the actual handlers. +#![allow(clippy::module_name_repetitions)] +#![allow(clippy::too_many_lines)] + +mod support; + +use axum::http::StatusCode; +use deadpool_postgres::Pool; +use uuid::Uuid; + +use crumb_common::db; + +use support::*; + +/// Read a response body into a JSON value. +async fn into_json(resp: axum::http::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null) +} + +/// Create a channel owned by `user_id` (or global when `None`) directly in the +/// DB, bypassing the create handler so the test controls ownership precisely. +async fn seed_channel(pool: &Pool, user_id: Option, name: &str) -> Uuid { + db::create_notification_channel( + pool, + &db::CreateChannelParams { + user_id, + kind: "webhook".to_owned(), + name: name.to_owned(), + enabled: true, + config: serde_json::json!({}), + camera_ids: None, + snapshot_mode: db::SnapshotMode::None, + }, + ) + .await + .expect("seed channel") + .id +} + +// ─── quiet-hours validation ────────────────────────────────────────────────── + +#[tokio::test] +async fn rule_quiet_hours_out_of_range_is_rejected() { + let app = TestApp::new().await; + let user = seed_viewer(app.pool(), &[]).await; + let token = login(&app, &user.username, &user.password).await; + + // 2200 (military time) must be rejected, not stored + clamped. + for bad in [2200, 24, -1, 700] { + let body = serde_json::json!({ "quiet_start_hour": bad, "quiet_end_hour": 7 }); + let resp = app + .send(put_auth_json("/notifications/rules", &token, &body)) + .await; + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "quiet_start_hour={bad} must be rejected" + ); + } + + // The end hour is validated too. + let body = serde_json::json!({ "quiet_start_hour": 22, "quiet_end_hour": 99 }); + let resp = app + .send(put_auth_json("/notifications/rules", &token, &body)) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "quiet_end_hour=99"); + + // Valid whole-hour windows (including the 0 and 23 boundaries) are accepted. + for (s, e) in [(22, 7), (0, 23), (23, 0)] { + let body = serde_json::json!({ "quiet_start_hour": s, "quiet_end_hour": e }); + let resp = app + .send(put_auth_json("/notifications/rules", &token, &body)) + .await; + assert_eq!( + resp.status(), + StatusCode::OK, + "quiet hours {s}..{e} must be accepted" + ); + } + + // Absent quiet hours (unset) is always valid. + let body = serde_json::json!({ "presence_mode": "always" }); + let resp = app + .send(put_auth_json("/notifications/rules", &token, &body)) + .await; + assert_eq!(resp.status(), StatusCode::OK, "unset quiet hours is valid"); +} + +#[tokio::test] +async fn system_alert_quiet_hours_out_of_range_is_rejected() { + let app = TestApp::new().await; + let admin = seed_admin(app.pool()).await; + let token = login(&app, &admin.username, &admin.password).await; + + let bad = serde_json::json!({ + "enabled": true, + "system_quiet_start_hour": 2200, + "system_quiet_end_hour": 7, + }); + let resp = app + .send(put_auth_json("/notifications/settings", &token, &bad)) + .await; + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "system_quiet_start_hour=2200 must be rejected" + ); + + let ok = serde_json::json!({ + "enabled": true, + "system_quiet_start_hour": 0, + "system_quiet_end_hour": 23, + }); + let resp = app + .send(put_auth_json("/notifications/settings", &token, &ok)) + .await; + assert_eq!( + resp.status(), + StatusCode::OK, + "a valid 0..23 system quiet-hours window must be accepted" + ); +} + +// ─── admin lists all channels with owner attribution ───────────────────────── + +#[tokio::test] +async fn admin_lists_all_channels_with_owner_attribution() { + let app = TestApp::new().await; + let pool = app.pool(); + + let admin = seed_admin(pool).await; + let viewer = seed_viewer(pool, &[]).await; + + let owned_by_viewer = seed_channel(pool, Some(viewer.user_id), &unique("viewer-ch")).await; + let global_ch = seed_channel(pool, None, &unique("global-ch")).await; + + // Admin: sees BOTH, the foreign one attributed to its owner. + let atok = login(&app, &admin.username, &admin.password).await; + let list = into_json(app.send(get_auth("/notifications/channels", &atok)).await).await; + let arr = list.as_array().expect("channels array"); + + let vrow = arr + .iter() + .find(|c| c["id"].as_str() == Some(&owned_by_viewer.to_string())) + .expect("admin must see the viewer-owned channel"); + assert_eq!( + vrow["global"].as_bool(), + Some(false), + "an owned channel is not global" + ); + assert_eq!( + vrow["owner_username"].as_str(), + Some(viewer.username.as_str()), + "the foreign channel must carry its owner's username" + ); + + let grow = arr + .iter() + .find(|c| c["id"].as_str() == Some(&global_ch.to_string())) + .expect("admin must see the global channel"); + assert_eq!(grow["global"].as_bool(), Some(true), "global flag set"); + + // Non-admin: sees ONLY their own channel, never the global one. + let vtok = login(&app, &viewer.username, &viewer.password).await; + let vlist = into_json(app.send(get_auth("/notifications/channels", &vtok)).await).await; + let varr = vlist.as_array().expect("channels array"); + assert!( + varr.iter() + .any(|c| c["id"].as_str() == Some(&owned_by_viewer.to_string())), + "a non-admin sees their own channel" + ); + assert!( + !varr + .iter() + .any(|c| c["id"].as_str() == Some(&global_ch.to_string())), + "a non-admin must not see a global channel in this listing" + ); +} + +// ─── global-flag round-trip on update + RBAC ───────────────────────────────── + +#[tokio::test] +async fn admin_can_toggle_global_and_it_round_trips() { + let app = TestApp::new().await; + let admin = seed_admin(app.pool()).await; + let token = login(&app, &admin.username, &admin.password).await; + + // Create an OWNED channel (global:false) as the admin. + let create = serde_json::json!({ + "kind": "webhook", + "name": unique("toggle-ch"), + "config": {}, + "global": false, + }); + let resp = app + .send(post_auth_json("/notifications/channels", &token, &create)) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let created = into_json(resp).await; + assert_eq!(created["global"].as_bool(), Some(false)); + let id = created["id"].as_str().expect("id").to_owned(); + + // Toggle global ON via update. + let upd = serde_json::json!({ "global": true }); + let resp = app + .send(put_auth_json( + &format!("/notifications/channels/{id}"), + &token, + &upd, + )) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let updated = into_json(resp).await; + assert_eq!( + updated["global"].as_bool(), + Some(true), + "the update response reflects the new global flag" + ); + assert!( + updated["user_id"].is_null(), + "a global channel has no owner (user_id NULL)" + ); + + // Read it back through the admin listing: still global. + let list = into_json(app.send(get_auth("/notifications/channels", &token)).await).await; + let row = list + .as_array() + .unwrap() + .iter() + .find(|c| c["id"].as_str() == Some(id.as_str())) + .expect("channel present"); + assert_eq!( + row["global"].as_bool(), + Some(true), + "global flag persisted across a reload" + ); +} + +#[tokio::test] +async fn non_admin_cannot_toggle_global() { + let app = TestApp::new().await; + let viewer = seed_viewer(app.pool(), &[]).await; + let token = login(&app, &viewer.username, &viewer.password).await; + + // Viewer creates their own channel (global is hidden for them; server + // ignores it on create for non-admins anyway). + let create = serde_json::json!({ "kind": "webhook", "name": unique("v-ch"), "config": {} }); + let resp = app + .send(post_auth_json("/notifications/channels", &token, &create)) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let id = into_json(resp).await["id"].as_str().unwrap().to_owned(); + + // Attempting to promote it to global must be rejected. + let upd = serde_json::json!({ "global": true }); + let resp = app + .send(put_auth_json( + &format!("/notifications/channels/{id}"), + &token, + &upd, + )) + .await; + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "a non-admin must not change a channel's global scope" + ); +} diff --git a/services/api/tests/support/mod.rs b/services/api/tests/support/mod.rs index 1e1bd2e8..59a44161 100644 --- a/services/api/tests/support/mod.rs +++ b/services/api/tests/support/mod.rs @@ -192,6 +192,14 @@ fn ensure_env() { if std::env::var("JWT_EXPIRY_SECONDS").is_err() { std::env::set_var("JWT_EXPIRY_SECONDS", "86400"); } + // Keep each test's pool SMALL. Every `#[tokio::test]` builds its own + // `AppState` (its own pool), and they run concurrently against ONE shared + // Postgres; at the stock code default (32) enough concurrent tests exhaust a + // stock `max_connections = 100` (a prior PR hit exactly this). A handful of + // connections is plenty — no test needs deep concurrency within itself. + if std::env::var("DB_POOL_SIZE").is_err() { + std::env::set_var("DB_POOL_SIZE", "4"); + } } // Guards the migration run so it executes exactly once per test-binary diff --git a/services/common/src/db.rs b/services/common/src/db.rs index d4a983b8..9dfcec47 100644 --- a/services/common/src/db.rs +++ b/services/common/src/db.rs @@ -12256,6 +12256,11 @@ pub struct NotificationChannel { pub snapshot_mode: SnapshotMode, pub created_at: DateTime, pub updated_at: DateTime, + /// Username of the channel's owner, or `None` for a global channel. Only + /// populated by the admin-scoped [`list_all_notification_channels`] listing + /// (which joins `users`); every other query path leaves this `None` because + /// it is display-only owner attribution, not part of the channel identity. + pub owner_username: Option, } fn notification_channel_from_row(row: &tokio_postgres::Row) -> NotificationChannel { @@ -12271,6 +12276,7 @@ fn notification_channel_from_row(row: &tokio_postgres::Row) -> NotificationChann snapshot_mode: SnapshotMode::from_db(&row.get::<_, String>("snapshot_mode")), created_at: row.get("created_at"), updated_at: row.get("updated_at"), + owner_username: None, } } @@ -12342,9 +12348,14 @@ pub async fn create_notification_channel( /// List notification channels visible to `user_id`. /// -/// Returns the caller's own channels plus global channels (`user_id IS NULL`). -/// When `include_globals` is true (Admin callers should pass true so they also -/// see global channels), global channels are included. +/// * `is_admin == false` — returns only the caller's own channels (`user_id = +/// $1`). Non-admins deliberately do not see global channels here (RBAC scope +/// preserved from the original implementation). +/// * `is_admin == true` — returns **every** channel via +/// [`list_all_notification_channels`] with owner attribution, so an admin can +/// see and manage a channel created under a different account (the engine +/// fans out to every enabled channel regardless of owner, so a foreign-owned +/// channel is a live destination the admin must be able to see). /// /// # Errors /// @@ -12352,43 +12363,65 @@ pub async fn create_notification_channel( pub async fn list_notification_channels( pool: &Pool, user_id: Uuid, - include_globals: bool, + is_admin: bool, ) -> Result> { + if is_admin { + return list_all_notification_channels(pool).await; + } let client = get_conn(pool).await?; - let rows = if include_globals { - client - .query( - &format!( - r" - SELECT {CHANNEL_COLS} - FROM notification_channels - WHERE user_id = $1 OR user_id IS NULL - ORDER BY created_at - " - ), - &[&user_id], - ) - .await - .context("list_notification_channels (with globals)")? - } else { - client - .query( - &format!( - r" - SELECT {CHANNEL_COLS} - FROM notification_channels - WHERE user_id = $1 - ORDER BY created_at - " - ), - &[&user_id], - ) - .await - .context("list_notification_channels (own)")? - }; + let rows = client + .query( + &format!( + r" + SELECT {CHANNEL_COLS} + FROM notification_channels + WHERE user_id = $1 + ORDER BY created_at + " + ), + &[&user_id], + ) + .await + .context("list_notification_channels (own)")?; Ok(rows.iter().map(notification_channel_from_row).collect()) } +/// List **all** notification channels (admin scope) with the owner's username +/// joined in for attribution. A global channel (`user_id IS NULL`) has +/// `owner_username = None`. +/// +/// # Errors +/// +/// Returns an error if the query fails. +pub async fn list_all_notification_channels(pool: &Pool) -> Result> { + let client = get_conn(pool).await?; + // Columns qualified with the `c.` alias because the join to `users` makes + // bare `id` ambiguous; `u.username` is exposed as `owner_username`. + let rows = client + .query( + r" + SELECT + c.id, c.user_id, c.kind, c.name, c.enabled, c.config, + c.camera_ids, c.include_snapshot, c.snapshot_mode, + c.created_at, c.updated_at, u.username AS owner_username + FROM notification_channels c + LEFT JOIN users u ON u.id = c.user_id + ORDER BY c.created_at + ", + &[], + ) + .await + .context("list_all_notification_channels")?; + Ok(rows + .iter() + .map(|row| { + let mut ch = notification_channel_from_row(row); + ch.owner_username = row.get("owner_username"); + ch + }) + .collect()) +} + /// Fetch a single notification channel by id. /// /// # Errors @@ -12426,6 +12459,10 @@ pub struct UpdateChannelParams { /// rule before building these params); the legacy `include_snapshot` column /// is written as its synced mirror. pub snapshot_mode: SnapshotMode, + /// Change the channel owner (global toggle), admin-only. `None` → leave + /// `user_id` unchanged; `Some(None)` → make the channel global + /// (`user_id = NULL`); `Some(Some(uid))` → claim ownership for `uid`. + pub set_owner: Option>, } /// Update a notification channel. @@ -12443,6 +12480,11 @@ pub async fn update_notification_channel( params: &UpdateChannelParams, ) -> Result> { let client = get_conn(pool).await?; + // `$9` guards the owner change: when false, `user_id` is left untouched. + // When true, `user_id` is set to `$10` (NULL for a global channel, or the + // claiming user's id). The inner Option flattens to a nullable Uuid param. + let set_owner = params.set_owner.is_some(); + let new_owner: Option = params.set_owner.flatten(); let opt = client .query_opt( &format!( @@ -12454,6 +12496,7 @@ pub async fn update_notification_channel( camera_ids = $6, include_snapshot = $7, snapshot_mode = $8, + user_id = CASE WHEN $9 THEN $10 ELSE user_id END, updated_at = now() WHERE id = $1 RETURNING {CHANNEL_COLS} @@ -12468,6 +12511,8 @@ pub async fn update_notification_channel( ¶ms.camera_ids, ¶ms.snapshot_mode.include_snapshot(), ¶ms.snapshot_mode.as_str(), + &set_owner, + &new_owner, ], ) .await From 12609e3d98c17f72dbfe67a6bd4aff77164d0c72 Mon Sep 17 00:00:00 2001 From: badbread Date: Sat, 8 Aug 2026 11:41:48 -0700 Subject: [PATCH 2/2] feat(notifications): inline pencil icon for per-alert text editing (no modal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-alert alert-text affordance in the console's System alerts table. The compact "Customize" button + "Customized" chip that opened a shared .modal-overlay is gone; each alert row now shows a small inline pencil (✎) icon on its control row, right next to "Bypass quiet hours". Clicking it expands the editor in place as a panel directly under that row (no modal), and clicking it again (or another alert's icon) collapses it, so only one editor is open at a time and the list reads as a clean list with a tiny icon per row. The icon carries the customized state itself (accent border + a dot) when a template override is set, so the separate chip is no longer needed. All of the editor content and behavior is unchanged: message textarea, conditional Title (Pushover/ntfy only), per-alert token legend with samples, live preview, Restore default, and the per-alert PUT save decoupled from the bulk row-toggle save. Update the same-day DECISIONS.md entry so the modal is not "restored". Signed-off-by: badbread --- docs/DECISIONS.md | 34 +++++--- services/api/src/admin.html | 153 ++++++++++++++++++++---------------- 2 files changed, 105 insertions(+), 82 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 0f3875b5..2e1ab189 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -8,7 +8,7 @@ revisit. --- -## 2026-08-08, Notifications pane: one shared modal for alert-text editing, whole-hour quiet-hours pickers validated 0..=23 server-side, admins see every channel +## 2026-08-08, Notifications pane: inline per-alert ✎ icon opens the alert-text editor in place, whole-hour quiet-hours pickers validated 0..=23 server-side, admins see every channel **Context.** A Notifications-pane UX pass surfaced four issues. (1) The engine fans out to **every enabled channel regardless of owner** @@ -38,18 +38,26 @@ consume a rendered title. is a real destination); leaving the console blind to foreign channels (the reported bug). -- **One shared modal (`#sysalert-editor-modal`) for alert-text editing**, opened - per alert from a compact "✎ Customize" row button (+ a "Customized" chip when - a template is set). The modal holds the message textarea, a conditional Title - field, a per-alert token legend (click-to-insert, with sample values), and a - live preview rendered as ONE generic notification-card (not per-provider - chrome); it saves via the existing per-alert PUT, decoupled from the row-toggle - bulk save. **Rejected:** the status-quo inline `
` per row (13× noise); - a per-row popover (positioning/collision math, and it clips inside the table's - overflow wrap). The `.modal-overlay` pattern already exists in `admin.html`, - works identically in the desktop WebView2 embed and any Android WebView (fixed - overlay, no anchoring), and gives room for legend + preview without inflating - the list. +- **A small inline ✎ icon per alert opens the editor in place (no modal).** Each + system-alert row carries a compact pencil icon on its control row, inline right + next to "Bypass quiet hours"; clicking it expands the editor as a panel + directly under that row (`_sysAlertEditorBody` rendered into a `colspan` row by + `_renderSystemAlertsSection`; toggled via `sysAlertToggleEditor`, only one open + at a time). The icon itself carries the "customized" state (accent border + a + dot) when a template override is set, so no separate chip is needed and the + list reads as a clean list with a tiny icon per row. The panel holds the same + message textarea, conditional Title field, per-alert token legend + (click-to-insert, with sample values), live preview (ONE generic + notification-card, not per-provider chrome), Restore default, and it saves via + the existing per-alert PUT, decoupled from the row-toggle bulk save. + **Rejected:** a shared `.modal-overlay` dialog (the maintainer found a labeled + "Customize" button opening a separate modal too heavy for "just a little icon + to click"); the earlier status-quo inline `
` "Customize alert text" + row per alert (13× noise, a wall of collapsibles); a floating anchored popover + (positioning/collision math, and it clips inside the table's `overflow-x` wrap). + Inline expansion needs no anchoring and works identically in the desktop + WebView2 embed and any Android WebView. **NOTE:** this replaces the modal + affordance that the first cut of this PR shipped — do not "restore" the modal. - **Whole-hour ` - Bypass quiet hours - +
+ + ${editIcon} +
- `; + ${editorRow}`; }).join(''); /* Quiet-hours window (system alerts) — whole-hour selects, collapsed. */ @@ -11059,12 +11060,12 @@ } } -/* ── System-alert text editor (one shared modal) ── - Opened per alert from the "✎ Customize" row button. Contains the message - textarea, a conditional title field, a per-alert token legend, and a live - preview rendered as a generic notification card. Save issues the per-alert - PUT (only message_template/title_template), decoupled from the row-toggle - bulk "Save system alerts". */ +/* ── System-alert text editor (inline, expands under the alert's row) ── + Opened per alert from the small inline ✎ icon next to "Bypass quiet hours". + Contains the message textarea, a conditional title field, a per-alert token + legend, and a live preview rendered as a generic notification card. Save + issues the per-alert PUT (only message_template/title_template), decoupled + from the row-toggle bulk "Save system alerts". */ let SAE_IMG_URL = null; /* fetched sample snapshot URL for the preview card, or null */ /* True when the current message/title (as typed) would deliver an image for @@ -11093,21 +11094,16 @@ '' + ''); -async function sysAlertOpenEditor(key) { - SYS_ALERT_EDIT_KEY = key; +/* Build the inline editor panel (message textarea, conditional title, per-alert + token legend, live preview, actions). Rendered directly under the alert's row + by _renderSystemAlertsSection when this alert is the one open — no modal. */ +function _sysAlertEditorBody(key) { const meta = SYS_ALERT_META[key] || { title: key }; const r = (NOTIF_SYS_ALERTS.find(x => x.event_key === key)) || {}; const msgVal = r.message_template != null ? r.message_template : ''; const titleVal = r.title_template != null ? r.title_template : ''; const titleRelevant = sysAlertTitleRelevant(); - /* Fetch a sample snapshot once (only when an image would actually deliver); - the preview card reuses it across keystrokes with an SVG fallback. */ - SAE_IMG_URL = null; - if (sysAlertImageWouldDeliver(key) && CAMS.length) { - try { SAE_IMG_URL = await snapshotUrl(CAMS[0].id); } catch { SAE_IMG_URL = null; } - } - /* Token legend: %token% | sample value, clickable to insert at the caret. */ const legendRows = _sysAlertTokens(key).map(t => { const sample = (t === 'event') ? meta.title : (SYS_ALERT_TOKEN_SAMPLES[t] != null ? SYS_ALERT_TOKEN_SAMPLES[t] : ''); @@ -11124,42 +11120,62 @@
Used by: ${esc(sysAlertTitleDestNames())}.
` : ''; - $('sae-modal-title').textContent = 'Customize: ' + (meta.title || key); - $('sae-modal-body').innerHTML = ` - - - ${titleField} + return ` +
+
Customize: ${esc(meta.title || key)}
+ + + ${titleField} -
-
Tokens for this alert
-
Click a token to insert it. An unknown token is left as-is so a typo is visible. Times are UTC.
-
${legendRows}
-
+
+
Tokens for this alert
+
Click a token to insert it. An unknown token is left as-is so a typo is visible. Times are UTC.
+
${legendRows}
+
-
-
Preview
-
-
Delivery varies by provider; Discord and Telegram attach up to two images, Pushover and ntfy one, Slack and webhooks link only.
-
+
+
Preview
+
+
Delivery varies by provider; Discord and Telegram attach up to two images, Pushover and ntfy one, Slack and webhooks link only.
+
- -
`; + +
+
`; +} - $('sysalert-editor-modal').classList.remove('hidden'); +/* Toggle the inline editor for one alert. Opening another closes the previous + (only one open at a time, keeps the list clean). Re-renders the section so the + expander row appears/collapses, then lazily fetches the sample snapshot for + image-bearing alerts and re-previews. */ +async function sysAlertToggleEditor(key) { + const opening = (SYS_ALERT_EDIT_KEY !== key); + SYS_ALERT_EDIT_KEY = opening ? key : null; + SAE_IMG_URL = null; + const sec = $('notif-sysalerts-section'); + if (sec) sec.innerHTML = _renderSystemAlertsSection(); + if (!opening) return; sysAlertEditorPreview(); + /* Fetch a sample snapshot once (only when an image would actually deliver); + the preview card reuses it across keystrokes with an SVG fallback. */ + if (sysAlertImageWouldDeliver(key) && CAMS.length) { + try { SAE_IMG_URL = await snapshotUrl(CAMS[0].id); } catch { SAE_IMG_URL = null; } + if (SYS_ALERT_EDIT_KEY === key) sysAlertEditorPreview(); + } } +/* Collapse the inline editor (re-renders the section without the expander row). */ function sysAlertCloseEditor() { - const m = $('sysalert-editor-modal'); - if (m) m.classList.add('hidden'); SYS_ALERT_EDIT_KEY = null; SAE_IMG_URL = null; + const sec = $('notif-sysalerts-section'); + if (sec) sec.innerHTML = _renderSystemAlertsSection(); } /* Insert a %token% at the message textarea's caret (no execCommand). */ @@ -11241,15 +11257,14 @@ try { await api('/notifications/system-alerts/' + encodeURIComponent(key), { method: 'PUT', body: JSON.stringify(body) }); - /* Reflect the change in the in-memory row so the "Customized" chip updates. */ + /* Reflect the change in the in-memory row so the icon's customized state + updates when the section re-renders. */ let row = NOTIF_SYS_ALERTS.find(x => x.event_key === key); if (!row) { row = { event_key: key }; NOTIF_SYS_ALERTS.push(row); } row.message_template = body.message_template; if ('title_template' in body) row.title_template = body.title_template; toast('Alert text saved.'); - sysAlertCloseEditor(); - const sec = $('notif-sysalerts-section'); - if (sec) sec.innerHTML = _renderSystemAlertsSection(); + sysAlertCloseEditor(); /* collapses the editor and re-renders the section */ } catch (e) { if (line) { line.textContent = e.message; line.classList.add('err'); } }