[grade=B urn:ump:dbxmyhvr7cpkeu3mbfdevrco7utsok743udjsj7atih6qk4frdiq] fix(DC-168): calendar widget CSRF writes, error path, modal UX (audit batch 2)

Audit 2026-10-27 fixes (46-finding sweep, AUDIT-2026-10-27.md):
- P0-2: all 6 calendar API calls via secureFetch — raw fetch sent no
  X-CSRF-Token so every mutation (save/toggle/delete/test) was rejected
  403 [DC-100]; GETs gain the standard 15s timeout
- P0-6: refresh() catch called nonexistent errorHandler.log() — the
  error path itself threw; now logError(context, error)
- P0-7: modal sizing off inline min-width:560px onto .cal-modal-content
  with 768px override (inline style beat the mobile media query)
- refresh coalescing via _pendingForce (forced refreshes were dropped
  mid-flight); single fetch sequence on modal open; toggle/delete get
  resp.ok + try/catch + toast + disabled-during-flight
- modal on class-based .show: click-outside, Escape, focus move/return
  on all three dismissal paths (judge polish applied)
- chip honors Enter/Space (role=button); poll skips hidden tabs
- --cal-chip-when/--cal-when/--cal-dot re-scoped to :root (were scoped
  to elements that are never ancestors of .cal-dot — DC-168 WCAG dot
  colors never rendered)
- calendar-cache eventKey no longer throws RangeError on invalid start

Bundles rebuilt (sw dashcaddy-shell-7fc13e7762). status suite 68/68.
Judge B (polish items applied pre-commit): urn:ump:dbxmyhvr7cpkeu3mbfdevrco7utsok743udjsj7atih6qk4frdiq
This commit is contained in:
Hermes committed 2026-10-27 23:58:55 -07:00
1 parent 253a5fbf75
commit 3a0f595a17
5 files changed
+369 -303

No files matched your search

+6 -1
View File
@@ -5,7 +5,12 @@
'use strict';
(function () {
function eventKey(ev) {
const startIso = new Date(ev.start).toISOString();
// Audit 2026-10-27: a null/invalid start threw RangeError out of merge()
// and flipped the whole widget offline — degrade to a string key instead.
const d = new Date(ev.start);
const startIso = isNaN(d.getTime())
? `invalid:${ev.start == null ? 'null' : String(ev.start)}`
: d.toISOString();
return ev.uid ? `${ev.uid}@${startIso}` : `${startIso}|${ev.title || ''}`;
}
+76 -25
View File
@@ -15,8 +15,8 @@
const errorHandler = new ErrorHandler();
injectModal('calendar-modal', `<div id="calendar-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width:560px;max-width:720px;">
<h3>📅 Calendar</h3>
<div class="weather-modal-content cal-modal-content" role="dialog" aria-modal="true" aria-labelledby="calendar-modal-title">
<h3 id="calendar-modal-title">📅 Calendar</h3>
<div class="cal-monthbar">
<button id="cal-prev" class="cal-nav-btn" aria-label="Previous month">‹</button>
<span id="cal-month-label" class="cal-month-label"></span>
@@ -152,7 +152,7 @@
async function fetchCalendar(force) {
const range = windowFor(viewMonth);
if (!force && covers(range)) return lastCached;
const resp = await fetch(`/api/v1/calendar/events?start=${range.start.toISOString()}&end=${range.end.toISOString()}`, { cache: 'no-store' });
const resp = await secureFetch(`/api/v1/calendar/events?start=${range.start.toISOString()}&end=${range.end.toISOString()}`, { cache: 'no-store' });
if (resp.status === 404) return { notConfigured: true };
if (!resp.ok) throw new Error(`Calendar API HTTP ${resp.status}`);
const body = await resp.json();
@@ -245,7 +245,7 @@
if (!listEl) return;
let sources = [];
try {
const resp = await fetch('/api/v1/calendar/sources', { cache: 'no-store' });
const resp = await secureFetch('/api/v1/calendar/sources', { cache: 'no-store' });
if (resp.ok) {
const body = await resp.json();
sources = (body && Array.isArray(body.sources)) ? body.sources
@@ -266,22 +266,41 @@
listEl.querySelectorAll('.cal-src-toggle').forEach(btn => btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const enable = btn.dataset.enabled !== '1';
await fetch(`/api/v1/calendar/sources/${encodeURIComponent(id)}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: enable })
});
renderSources(); refresh(true);
btn.disabled = true;
try {
const resp = await secureFetch(`/api/v1/calendar/sources/${encodeURIComponent(id)}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: enable })
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
renderSources(); refresh(true);
} catch (e) {
showNotification(`Calendar source update failed: ${e.message}`, 'error');
} finally {
btn.disabled = false;
}
}));
listEl.querySelectorAll('.cal-src-del').forEach(btn => btn.addEventListener('click', async () => {
const id = btn.dataset.id;
await fetch(`/api/v1/calendar/sources/${encodeURIComponent(id)}`, { method: 'DELETE' });
renderSources(); refresh(true);
btn.disabled = true;
try {
const resp = await secureFetch(`/api/v1/calendar/sources/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
renderSources(); refresh(true);
} catch (e) {
showNotification(`Calendar source removal failed: ${e.message}`, 'error');
} finally {
btn.disabled = false;
}
}));
}
let lastBody = null;
async function refresh(force) {
if (refresh._inFlight) return;
// Coalesce instead of dropping (audit 2026-10-27): a forced refresh that
// lands while another is mid-flight previously vanished, leaving the
// chip/grid stale until the next 5-minute tick.
if (refresh._inFlight) { if (force) refresh._pendingForce = true; return; }
refresh._inFlight = true;
try {
// force=true (periodic tick, source mutations): bypass the covered-
@@ -321,31 +340,41 @@
}
lastBody = body;
renderWidget(body);
if (document.getElementById('calendar-modal').style.display === 'flex') {
if (document.getElementById('calendar-modal').classList.contains('show')) {
renderGrid(body);
renderUpcoming(body);
}
} catch (e) {
errorHandler.log(e, 'calendar-widget');
errorHandler.logError('calendar-widget', e); // was errorHandler.log() — which never existed (only logError does), so the catch itself threw
const els = widgetEls();
if (els.text) els.text.innerHTML = '<span class="cal-chip-muted">Calendar offline</span>';
} finally {
refresh._inFlight = false;
if (refresh._pendingForce) {
refresh._pendingForce = false;
refresh(true);
}
}
}
// ---- Modal wiring ----
// ---- Modal wiring (class-based .show like every other modal, so
// click-outside via wireModal + Escape + focus management all work) ----
let calLastFocus = null;
function openModal() {
document.getElementById('calendar-modal').style.display = 'flex';
calLastFocus = document.activeElement;
const modal = document.getElementById('calendar-modal');
modal.classList.add('show');
renderGrid(lastBody || { events: [] });
renderUpcoming(lastBody || { events: [] });
renderSources();
// refresh() renders grid+upcoming itself once the modal is showing — one
// fetch sequence, not two racing ones (audit 2026-10-27).
refresh();
// the visible month may be outside the loaded window — fetch follows view
fetchCalendar(false).then((b) => { if (b) { renderGrid(b); renderUpcoming(b); } }).catch(() => {});
document.getElementById('calendar-close').focus();
}
function closeModal() {
document.getElementById('calendar-modal').style.display = 'none';
document.getElementById('calendar-modal').classList.remove('show');
if (calLastFocus && typeof calLastFocus.focus === 'function') calLastFocus.focus();
}
function formCreds() {
@@ -367,7 +396,7 @@
if (!f.url) { setStatus('Enter a URL first.'); return; }
setStatus('Testing…');
try {
const resp = await fetch('/api/v1/calendar/discover', {
const resp = await secureFetch('/api/v1/calendar/discover', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: f.url, user: f.user, pass: f.pass })
});
@@ -384,7 +413,7 @@
const f = formCreds();
if (!f.name || !f.url) { setStatus('Name and URL are required.'); return; }
try {
const resp = await fetch('/api/v1/calendar/sources', {
const resp = await secureFetch('/api/v1/calendar/sources', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: f.name, type: f.type, url: f.url, user: f.user, pass: f.pass })
});
@@ -403,11 +432,31 @@
});
const calBtn = document.getElementById('calendar-widget');
if (calBtn) calBtn.addEventListener('click', openModal);
if (calBtn) {
calBtn.addEventListener('click', openModal);
// role="button" + tabindex=0 promise keyboard support — honor it
// (audit 2026-10-27: Enter/Space previously did nothing).
calBtn.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
openModal();
}
});
}
const calSettings = document.getElementById('calendar-settings');
if (calSettings) calSettings.addEventListener('click', (e) => { e.stopPropagation(); openModal(); });
const calClose = document.getElementById('calendar-close');
if (calClose) calClose.addEventListener('click', closeModal);
const calModalEl = document.getElementById('calendar-modal');
if (calModalEl) {
// All three dismissal paths (Close button, backdrop click, Escape) route
// through closeModal so focus returns to the widget every time (judge
// polish, urn:ump:dbxmyhvr7cpkeu3mbfdevrco7utsok743udjsj7atih6qk4frdiq —
// wireModal's own handlers bypass focus restore, so they're not used here).
document.getElementById('calendar-close').addEventListener('click', closeModal);
calModalEl.addEventListener('click', (e) => { if (e.target === calModalEl) closeModal(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && calModalEl.classList.contains('show')) closeModal();
});
}
async function navigate(delta) {
if (!viewMonth) { viewMonth = new Date(); viewMonth.setDate(1); }
viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + delta, 1);
@@ -422,6 +471,8 @@
window.refreshCalendarWidget = refresh;
const POLL = (typeof DC !== 'undefined' && DC.POLL && DC.POLL.CALENDAR) || 300000;
setInterval(() => refresh(true), POLL); // forced: catch server-side deletions
// Skip ticks in hidden tabs; catch up once on return (audit 2026-10-27).
setInterval(() => { if (!document.hidden) refresh(true); }, POLL); // forced: catch server-side deletions
document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(true); });
setTimeout(refresh, 400);
})();