From b46c727c85d17fd6423c29d10033091a9d4d3063 Mon Sep 17 00:00:00 2001 From: Nathan FONTEYNE Date: Fri, 10 Jul 2026 11:27:08 +0200 Subject: [PATCH] update: ics utl verification, custom in app name --- README.md | 2 +- public/js/admin.js | 5 +++ public/js/calendar.js | 6 +++ public/js/profile.js | 45 ++++++++++++++++++++- src/db/migrations/012_user_display_name.sql | 5 +++ src/repositories/usersRepo.js | 35 +++++++++++++--- src/routes/calendar.js | 16 +++++++- src/routes/users.js | 18 ++++++++- src/services/calendarSync.js | 40 ++++++++++++------ 9 files changed, 149 insertions(+), 23 deletions(-) create mode 100644 src/db/migrations/012_user_display_name.sql diff --git a/README.md b/README.md index 8422f32..cda8520 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ erDiagram | `/index.html` | Tous (lecture et écriture) | Répertoire des morceaux travaillés, avec recherche instantanée (titre/artiste), liens/vignettes YouTube et Spotify, tutos embarqués par morceau et par instrument. Ajout avec autocomplete titre/artiste + liens auto-trouvés ([détails](#recherche-automatique-de-morceaux)) | | `/suggestions.html` | Tous | Proposer un morceau (liens YouTube et Spotify + note libre), voter approuver/rejeter avec commentaire (attribué nominativement), ajouter une suggestion au répertoire | | `/concerts.html` | Tous (lecture et écriture) | Deux onglets sur une même page. **Prochain concert** : choix des morceaux du répertoire, ordre, notes, section rappel, lien "Écouter la setlist sur YouTube". **Historique** : concerts passés, modifiables (date, morceaux, ordre, rappel) et supprimables, avec le même lien playlist ; deux vues, chronologique (par défaut, tous les concerts détaillés avec YouTube embarqué par morceau, du plus récent au plus ancien) et réduite (liste compacte) ; ouvrir un concert passé (`?tab=history&id=...`) reste partageable en lien direct | -| `/profile.html` | Chacun voit le sien | Profil issu d'Authentik (nom, avatar, groupes) + activité personnelle (morceaux ajoutés, suggestions, votes) | +| `/profile.html` | Chacun voit le sien | Profil issu d'Authentik (nom, avatar, groupes) + activité personnelle (morceaux ajoutés, suggestions, votes) ; nom affiché personnalisable (par défaut celui d'Authentik) ; gestion de ses calendriers ICS pour les disponibilités | | `/calendar.html` | Tous (lecture et écriture) | Disponibilités du groupe pour les 3 prochaines semaines (calendrier, filtres par personne, modale par jour) — [détails](#disponibilités-calendrier) | Le mode par défaut est la consultation ; les pages Répertoire, Concerts et Suggestions sont interactives pour toute personne connectée (chaque action reste attribuée nominativement via Authentik). diff --git a/public/js/admin.js b/public/js/admin.js index 41985ec..b785412 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -95,11 +95,16 @@ async function onAddFeed(e, userId) { const form = e.target; const label = form.label.value.trim(); const icsUrl = form.icsUrl.value.trim(); + const submitBtn = form.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.textContent = 'Vérification…'; try { await api.post(`/api/calendar/people/${userId}/feeds`, { label, icsUrl }); await loadCalendarUsers(); } catch (err) { showError(err.message); + submitBtn.disabled = false; + submitBtn.textContent = '+ Ajouter'; } } diff --git a/public/js/calendar.js b/public/js/calendar.js index d6b2e62..ad6c5f0 100644 --- a/public/js/calendar.js +++ b/public/js/calendar.js @@ -348,6 +348,9 @@ async function onAddMyFeed(e) { const form = e.target; const label = form.label.value.trim(); const icsUrl = form.icsUrl.value.trim(); + const submitBtn = form.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.textContent = 'Vérification…'; try { await api.post('/api/calendar/my-feeds', { label, icsUrl }); form.reset(); @@ -357,6 +360,9 @@ async function onAddMyFeed(e) { buildFilters(); } catch (err) { showError(err.message); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = '+ Ajouter'; } } diff --git a/public/js/profile.js b/public/js/profile.js index d16bbb8..9e8d9d4 100644 --- a/public/js/profile.js +++ b/public/js/profile.js @@ -54,12 +54,18 @@ async function onAddMyFeed(e) { const form = e.target; const label = form.label.value.trim(); const icsUrl = form.icsUrl.value.trim(); + const submitBtn = form.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.textContent = 'Vérification…'; try { await api.post('/api/calendar/my-feeds', { label, icsUrl }); form.reset(); await loadMyFeeds(); } catch (err) { showError(err.message); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = '+ Ajouter'; } } @@ -72,6 +78,27 @@ async function onRemoveMyFeed(feedId) { } } +async function onSaveDisplayName(e) { + e.preventDefault(); + const form = e.target; + const displayName = form.displayName.value.trim(); + try { + await api.patch('/api/users/me/display-name', { displayName }); + window.location.reload(); + } catch (err) { + showError(err.message); + } +} + +async function onResetDisplayName() { + try { + await api.patch('/api/users/me/display-name', { displayName: '' }); + window.location.reload(); + } catch (err) { + showError(err.message); + } +} + (async function init() { await initNav(null); try { @@ -88,6 +115,19 @@ async function onRemoveMyFeed(feedId) { +

Nom affiché

+

+ Par défaut, le nom fourni par Authentik est utilisé. Vous pouvez le remplacer par un nom + personnalisé ci-dessous — cela n'affecte pas votre compte Authentik. +

+
+
+ + + ${profile.hasCustomName ? '' : ''} +
+
+ ${profile.groups && profile.groups.length ? `

Groupes Authentik

@@ -120,7 +160,7 @@ async function onRemoveMyFeed(feedId) {

- Identité gérée par Authentik — pour changer votre nom, email ou mot de passe, + Identité gérée par Authentik — pour changer votre email ou mot de passe, ${profile.authentikAccountUrl ? `rendez-vous sur votre compte Authentik.` : 'rendez-vous sur votre compte Authentik.'} @@ -128,6 +168,9 @@ async function onRemoveMyFeed(feedId) {

`; + document.getElementById('display-name-form').addEventListener('submit', onSaveDisplayName); + const resetBtn = document.getElementById('reset-display-name-btn'); + if (resetBtn) resetBtn.addEventListener('click', onResetDisplayName); document.getElementById('add-my-feed-form').addEventListener('submit', onAddMyFeed); await loadMyFeeds(); } catch (err) { diff --git a/src/db/migrations/012_user_display_name.sql b/src/db/migrations/012_user_display_name.sql new file mode 100644 index 0000000..d67f2d8 --- /dev/null +++ b/src/db/migrations/012_user_display_name.sql @@ -0,0 +1,5 @@ +ALTER TABLE users ADD COLUMN authentik_name TEXT; +UPDATE users SET authentik_name = name; +ALTER TABLE users ALTER COLUMN authentik_name SET NOT NULL; + +ALTER TABLE users ADD COLUMN display_name TEXT; diff --git a/src/repositories/usersRepo.js b/src/repositories/usersRepo.js index 0f34bc2..97f59a3 100644 --- a/src/repositories/usersRepo.js +++ b/src/repositories/usersRepo.js @@ -1,24 +1,49 @@ const pool = require('../db/pool'); -const PROFILE_FIELDS = 'id, authentik_sub, name, username, email, avatar_url, groups, is_admin, created_at'; +const PROFILE_FIELDS = + 'id, authentik_sub, name, display_name, username, email, avatar_url, groups, is_admin, created_at'; async function findById(id) { const { rows } = await pool.query(`SELECT ${PROFILE_FIELDS} FROM users WHERE id = $1`, [id]); return rows[0] || null; } +// `name` is always the effective display name (what every other join in the +// app already reads via `u.name`) — it tracks the Authentik-provided name +// unless the user has set a display_name override, in which case that takes +// precedence and survives future logins even if their Authentik name changes. async function upsertFromClaims({ sub, name, username, email, avatarUrl, groups, isAdmin }) { const { rows } = await pool.query( - `INSERT INTO users (authentik_sub, name, username, email, avatar_url, groups, is_admin) - VALUES ($1, $2, $3, $4, $5, $6, $7) + `INSERT INTO users (authentik_sub, name, authentik_name, username, email, avatar_url, groups, is_admin) + VALUES ($1, $2, $2, $3, $4, $5, $6, $7) ON CONFLICT (authentik_sub) - DO UPDATE SET name = $2, username = $3, email = $4, avatar_url = $5, groups = $6, is_admin = $7, updated_at = now() + DO UPDATE SET + authentik_name = $2, + name = COALESCE(users.display_name, $2), + username = $3, email = $4, avatar_url = $5, groups = $6, is_admin = $7, updated_at = now() RETURNING ${PROFILE_FIELDS}`, [sub, name, username || null, email, avatarUrl || null, groups || [], isAdmin] ); return rows[0]; } +// Sets (or, if displayName is empty, clears) the user's display-name +// override. Also updates `name` immediately so every other page reflects the +// change right away, without waiting for the next login. +async function updateDisplayName(userId, displayName) { + const trimmed = displayName ? displayName.trim() : ''; + const { rows } = await pool.query( + `UPDATE users + SET display_name = NULLIF($2, ''), + name = COALESCE(NULLIF($2, ''), authentik_name), + updated_at = now() + WHERE id = $1 + RETURNING ${PROFILE_FIELDS}`, + [userId, trimmed] + ); + return rows[0] || null; +} + async function getActivityStats(userId) { const [songs, suggestions, votes] = await Promise.all([ pool.query('SELECT COUNT(*)::int AS count FROM songs WHERE added_by = $1', [userId]), @@ -47,4 +72,4 @@ async function findAllWithActivity() { return rows; } -module.exports = { findById, upsertFromClaims, getActivityStats, findAllWithActivity }; +module.exports = { findById, upsertFromClaims, updateDisplayName, getActivityStats, findAllWithActivity }; diff --git a/src/routes/calendar.js b/src/routes/calendar.js index ae41bd2..282a55e 100644 --- a/src/routes/calendar.js +++ b/src/routes/calendar.js @@ -93,9 +93,15 @@ router.post( if (!icsUrl || !icsUrl.trim()) { return res.status(400).json({ error: 'ics_url_required' }); } + const trimmedUrl = icsUrl.trim(); + try { + await calendarSync.testFeed(trimmedUrl); + } catch (err) { + return res.status(400).json({ error: 'ics_url_unreachable', message: err.message }); + } const feed = await calendarRepo.addFeed(req.user.id, { label: label ? label.trim() : null, - icsUrl: icsUrl.trim(), + icsUrl: trimmedUrl, }); res.status(201).json({ id: feed.id, label: feed.label, icsUrl: feed.ics_url }); }) @@ -142,9 +148,15 @@ router.post( if (!user) { return res.status(404).json({ error: 'user_not_found' }); } + const trimmedUrl = icsUrl.trim(); + try { + await calendarSync.testFeed(trimmedUrl); + } catch (err) { + return res.status(400).json({ error: 'ics_url_unreachable', message: err.message }); + } const feed = await calendarRepo.addFeed(userId, { label: label ? label.trim() : null, - icsUrl: icsUrl.trim(), + icsUrl: trimmedUrl, }); res.status(201).json({ id: feed.id, userId: feed.user_id, label: feed.label, icsUrl: feed.ics_url }); }) diff --git a/src/routes/users.js b/src/routes/users.js index d69e2fc..a5bf971 100644 --- a/src/routes/users.js +++ b/src/routes/users.js @@ -13,10 +13,11 @@ function authentikAccountUrl() { router.get( '/me', asyncHandler(async (req, res) => { - const { id, name, username, email, avatar_url, is_admin } = req.user; + const { id, name, display_name, username, email, avatar_url, is_admin } = req.user; res.json({ id, name, + hasCustomName: !!display_name, username, email, avatarUrl: avatar_url, @@ -29,11 +30,12 @@ router.get( router.get( '/me/profile', asyncHandler(async (req, res) => { - const { id, name, username, email, avatar_url, groups, is_admin, created_at } = req.user; + const { id, name, display_name, username, email, avatar_url, groups, is_admin, created_at } = req.user; const stats = await usersRepo.getActivityStats(id); res.json({ id, name, + hasCustomName: !!display_name, username, email, avatarUrl: avatar_url, @@ -46,4 +48,16 @@ router.get( }) ); +router.patch( + '/me/display-name', + asyncHandler(async (req, res) => { + const { displayName } = req.body || {}; + if (displayName && displayName.trim().length > 60) { + return res.status(400).json({ error: 'display_name_too_long' }); + } + const user = await usersRepo.updateDisplayName(req.user.id, displayName || ''); + res.json({ name: user.name, hasCustomName: !!user.display_name }); + }) +); + module.exports = router; diff --git a/src/services/calendarSync.js b/src/services/calendarSync.js index 830333b..33a670d 100644 --- a/src/services/calendarSync.js +++ b/src/services/calendarSync.js @@ -6,12 +6,9 @@ const { parisWallClockToUTC } = require('../lib/calendarDates'); const RANGE_DAYS = 22; // slightly more than the 3 weeks generateSlots() covers const FETCH_TIMEOUT_MS = 15000; -// Fetches and parses one ICS feed, immediately reducing every VEVENT down to -// {start, end} — nothing else (title, description, location, attendees) is -// ever read out of the parsed calendar, so it can't end up logged, stored, or -// returned by an API by mistake. This holds regardless of whether the -// provider's feed itself is busy/free-only or full-detail. -async function fetchBusyIntervals(icsUrl) { +// Fetches one ICS feed and parses it into node-ical's raw component map. +// Shared by fetchBusyIntervals (real sync) and testFeed (add-time validation). +async function fetchIcsCalendar(icsUrl) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); let text; @@ -22,8 +19,16 @@ async function fetchBusyIntervals(icsUrl) { } finally { clearTimeout(timeout); } + return ical.parseICS(text); +} - const parsed = ical.parseICS(text); +// Fetches and parses one ICS feed, immediately reducing every VEVENT down to +// {start, end} — nothing else (title, description, location, attendees) is +// ever read out of the parsed calendar, so it can't end up logged, stored, or +// returned by an API by mistake. This holds regardless of whether the +// provider's feed itself is busy/free-only or full-detail. +async function fetchBusyIntervals(icsUrl) { + const parsed = await fetchIcsCalendar(icsUrl); const rangeFrom = new Date(); const rangeTo = new Date(rangeFrom.getTime() + RANGE_DAYS * 86400000); @@ -44,6 +49,21 @@ async function fetchBusyIntervals(icsUrl) { return intervals; } +// Validates a feed URL when a user adds it — catches the exact mistake that +// motivated this: a URL that "succeeds" (200 OK) but isn't actually that +// person's calendar (e.g. an HTML page, wrong link) parses to zero components +// and would otherwise silently sit there doing nothing until the next sync. +// Returns { eventCount } on success; throws a user-facing message otherwise. +async function testFeed(icsUrl) { + const parsed = await fetchIcsCalendar(icsUrl); + const componentCount = Object.keys(parsed).length; + if (componentCount === 0) { + throw new Error("Aucune donnée de calendrier trouvée à cette adresse — vérifiez le lien."); + } + const eventCount = Object.values(parsed).filter((c) => c && c.type === 'VEVENT').length; + return { eventCount }; +} + function toInterval({ start, end, datetype, isFullDay }) { if (datetype === 'date' || isFullDay) { // node-ical builds date-only (VALUE=DATE) values with the server's local @@ -91,10 +111,6 @@ async function syncAvailability() { } const existing = intervalsByUser.get(feed.user_id) || []; intervalsByUser.set(feed.user_id, existing.concat(result.value)); - console.log( - `[calendar] feed id=${feed.id} (user id=${feed.user_id}): ${result.value.length} busy interval(s) — ` + - result.value.slice(0, 30).map((iv) => `${iv.start.toISOString()}->${iv.end.toISOString()}`).join(', ') - ); }); const slots = generateSlots(3, slotConfig); @@ -116,4 +132,4 @@ async function syncAvailability() { return { ...summary, failedFeeds }; } -module.exports = { syncAvailability, fetchBusyIntervals }; +module.exports = { syncAvailability, fetchBusyIntervals, testFeed };