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.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 };