diff --git a/public/calendar.html b/public/calendar.html index 9db8452..6175fad 100644 --- a/public/calendar.html +++ b/public/calendar.html @@ -19,10 +19,15 @@
+
+
@@ -63,6 +68,23 @@
+ + diff --git a/public/js/calendar.js b/public/js/calendar.js index 2ec869f..d6b2e62 100644 --- a/public/js/calendar.js +++ b/public/js/calendar.js @@ -143,6 +143,7 @@ function renderCalendar() { function buildFilters() { const listEl = document.getElementById('people-list'); + listEl.innerHTML = ''; for (const person of state.people) { const row = document.createElement('label'); row.className = 'person-toggle'; @@ -172,24 +173,6 @@ function buildFilters() { } buildLegend(); - - document.getElementById('btn-refresh').addEventListener('click', async () => { - const btn = document.getElementById('btn-refresh'); - btn.disabled = true; - btn.textContent = 'Actualisation…'; - showToast('Synchronisation des calendriers…'); - try { - await api.post('/api/calendar/refresh', {}); - showToast('Synchronisation en cours — en attente des résultats…'); - pollWorkflowStatus(btn); - } catch (err) { - const message = err.message === 'no_feeds_configured' - ? "Aucun calendrier n'est configuré — voir la page Administration." - : err.message; - showToast(message, true); - resetRefreshButton(btn); - } - }); } function resetRefreshButton(btn) { @@ -278,6 +261,7 @@ function closeFilters() { function buildLegend() { const el = document.getElementById('legend'); + el.innerHTML = ''; for (const person of state.people) { const item = document.createElement('div'); item.className = 'legend-item'; @@ -307,6 +291,87 @@ function showError(message) { document.getElementById('error').innerHTML = `
${escapeHtml(message)}
`; } +// ---------- Mes calendriers (self-service ICS feeds) ---------- + +let myFeeds = []; + +// Shown for a registered feed instead of its full URL — consistent with the +// admin/profile calendar-management panels. +function maskIcsUrl(url) { + try { + const u = new URL(url); + return `${u.hostname}/••••`; + } catch { + return '••••'; + } +} + +function myFeedRowTemplate(feed) { + return ` +
+ ${feed.label ? `${escapeHtml(feed.label)} — ` : ''}${escapeHtml(maskIcsUrl(feed.icsUrl))} + +
+ `; +} + +async function loadMyFeeds() { + myFeeds = await api.get('/api/calendar/my-feeds'); + renderMyFeedBanner(); +} + +function renderMyFeedBanner() { + document.getElementById('my-feed-banner').style.display = myFeeds.length ? 'none' : ''; +} + +function renderMyCalendarsModal() { + const container = document.getElementById('my-calendars-list'); + container.innerHTML = myFeeds.length + ? myFeeds.map(myFeedRowTemplate).join('') + : '

Aucun calendrier configuré.

'; + container.querySelectorAll('.remove-my-feed-btn').forEach((btn) => { + btn.addEventListener('click', () => onRemoveMyFeed(parseInt(btn.dataset.feedId, 10))); + }); +} + +function openMyCalendarsModal() { + renderMyCalendarsModal(); + document.getElementById('my-calendars-modal-overlay').classList.remove('hidden'); +} + +function closeMyCalendarsModal() { + document.getElementById('my-calendars-modal-overlay').classList.add('hidden'); +} + +async function onAddMyFeed(e) { + e.preventDefault(); + const form = e.target; + const label = form.label.value.trim(); + const icsUrl = form.icsUrl.value.trim(); + try { + await api.post('/api/calendar/my-feeds', { label, icsUrl }); + form.reset(); + await loadMyFeeds(); + renderMyCalendarsModal(); + await loadPeople(); + buildFilters(); + } catch (err) { + showError(err.message); + } +} + +async function onRemoveMyFeed(feedId) { + try { + await api.del(`/api/calendar/my-feeds/${feedId}`); + await loadMyFeeds(); + renderMyCalendarsModal(); + await loadPeople(); + buildFilters(); + } catch (err) { + showError(err.message); + } +} + (async function init() { await initNav('calendar'); @@ -314,22 +379,49 @@ function showError(message) { document.getElementById('modal-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) closeModal(); }); + document.getElementById('my-calendars-modal-close').addEventListener('click', closeMyCalendarsModal); + document.getElementById('my-calendars-modal-overlay').addEventListener('click', (e) => { + if (e.target === e.currentTarget) closeMyCalendarsModal(); + }); + document.getElementById('my-calendars-add-form').addEventListener('submit', onAddMyFeed); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeModal(); closeFilters(); + closeMyCalendarsModal(); } }); document.getElementById('btn-filters-toggle').addEventListener('click', openFilters); document.getElementById('btn-filters-close').addEventListener('click', closeFilters); document.getElementById('filters-backdrop').addEventListener('click', closeFilters); + document.getElementById('btn-my-calendars').addEventListener('click', openMyCalendarsModal); + document.getElementById('btn-my-feed-banner-add').addEventListener('click', openMyCalendarsModal); + + document.getElementById('btn-refresh').addEventListener('click', async () => { + const btn = document.getElementById('btn-refresh'); + btn.disabled = true; + btn.textContent = 'Actualisation…'; + showToast('Synchronisation des calendriers…'); + try { + await api.post('/api/calendar/refresh', {}); + showToast('Synchronisation en cours — en attente des résultats…'); + pollWorkflowStatus(btn); + } catch (err) { + const message = err.message === 'no_feeds_configured' + ? "Aucun calendrier n'est configuré — voir la page Administration." + : err.message; + showToast(message, true); + resetRefreshButton(btn); + } + }); try { await loadPeople(); buildFilters(); await loadSlots(); await loadLastChecked(); + await loadMyFeeds(); } catch (err) { showError(err.message); } diff --git a/public/js/nav.js b/public/js/nav.js index dfa4e9a..2c02e55 100644 --- a/public/js/nav.js +++ b/public/js/nav.js @@ -31,7 +31,6 @@ async function initNav(activePage) { Prochain concert Disponibilités Historique - ${me.isAdmin ? `Administration` : ''} +

Mes calendriers

+

+ Ajoutez le lien ICS (iCal) d'un ou plusieurs de vos calendriers (Google, Outlook, Apple...) pour + apparaître dans les disponibilités du groupe sur la page Disponibilités. + L'application ne conserve jamais le contenu de vos calendriers — seul un statut disponible/occupé + par créneau est déduit et enregistré. +

+
+

Chargement…

+
+ + + +
+
+

Identité gérée par Authentik — pour changer votre nom, email ou mot de passe, @@ -55,6 +127,8 @@ function statTile(value, label) {

`; + document.getElementById('add-my-feed-form').addEventListener('submit', onAddMyFeed); + await loadMyFeeds(); } catch (err) { showError(err.message); } diff --git a/src/repositories/calendarRepo.js b/src/repositories/calendarRepo.js index 10e8e5a..d50b179 100644 --- a/src/repositories/calendarRepo.js +++ b/src/repositories/calendarRepo.js @@ -166,6 +166,18 @@ async function removeFeed(feedId) { await pool.query('DELETE FROM calendar_feeds WHERE id = $1', [feedId]); } +// Scoped delete for the self-service "my calendars" endpoints — a user must +// only ever be able to delete their own feeds, never guess another user's +// feed id. Returns whether a row actually matched (both wrong id and +// someone-else's feed look identical from the caller's side: nothing deleted). +async function removeFeedForUser(feedId, userId) { + const { rowCount } = await pool.query('DELETE FROM calendar_feeds WHERE id = $1 AND user_id = $2', [ + feedId, + userId, + ]); + return rowCount > 0; +} + // Every app user (not just ones already on the calendar) with their // registered feeds attached — lets an admin give someone their first feed, // not just manage existing entries. The public getPeople() above deliberately @@ -227,6 +239,7 @@ module.exports = { findFeedsForUser, addFeed, removeFeed, + removeFeedForUser, getUsersWithFeeds, getSlotSettings, updateSlotSettings, diff --git a/src/routes/calendar.js b/src/routes/calendar.js index a57a88d..ae41bd2 100644 --- a/src/routes/calendar.js +++ b/src/routes/calendar.js @@ -75,6 +75,43 @@ router.post( }) ); +// Self-service: any authenticated user manages their own calendar feeds here +// (no requireAdmin) — distinct from the /people/:id/feeds admin routes below, +// which let an admin manage anyone's feeds on their behalf. +router.get( + '/my-feeds', + asyncHandler(async (req, res) => { + const feeds = await calendarRepo.findFeedsForUser(req.user.id); + res.json(feeds.map((f) => ({ id: f.id, label: f.label, icsUrl: f.ics_url }))); + }) +); + +router.post( + '/my-feeds', + asyncHandler(async (req, res) => { + const { label, icsUrl } = req.body || {}; + if (!icsUrl || !icsUrl.trim()) { + return res.status(400).json({ error: 'ics_url_required' }); + } + const feed = await calendarRepo.addFeed(req.user.id, { + label: label ? label.trim() : null, + icsUrl: icsUrl.trim(), + }); + res.status(201).json({ id: feed.id, label: feed.label, icsUrl: feed.ics_url }); + }) +); + +router.delete( + '/my-feeds/:feedId', + asyncHandler(async (req, res) => { + const deleted = await calendarRepo.removeFeedForUser(parseInt(req.params.feedId, 10), req.user.id); + if (!deleted) { + return res.status(404).json({ error: 'feed_not_found' }); + } + res.status(204).end(); + }) +); + router.get( '/people/admin', requireAdmin,