[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:
5 files changed
+369
-303
No files matched your search
@@ -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 || ''}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
Reference in new issue
Block a user