diff --git a/README.md b/README.md index 8a668a0..fec1ae2 100644 --- a/README.md +++ b/README.md @@ -241,9 +241,12 @@ erDiagram | `/suggestions.html` | Tous | Proposer un morceau (avec lien YouTube embarqué + note libre), voter approuver/rejeter avec commentaire, attribué nominativement | | `/setlist.html` | Tous (lecture et écriture) | Setlist du prochain concert : choix des morceaux du répertoire, ordre, notes, section rappel | | `/history.html`, `/history-detail.html` | Tous (lecture seule) | Historique des setlists des concerts passés | +| `/profile.html` | Chacun voit le sien | Profil issu d'Authentik (nom, avatar, groupes) + votre activité (morceaux ajoutés, suggestions, votes) | Le mode par défaut est la consultation ; les pages Répertoire, Setlist et Suggestions sont interactives pour toute personne connectée (chaque action reste attribuée nominativement via Authentik). +Un bouton clair/sombre dans la barre de navigation permet de forcer un thème (mémorisé par navigateur) ; sans préférence explicite, l'app suit le thème du système. + ## Rôles - **Membre** : tout le monde — consulte, ajoute/modifie/supprime des morceaux du répertoire et leurs tutos, crée/modifie des concerts et leur setlist, propose des suggestions, vote/commente. @@ -264,6 +267,7 @@ Le rôle admin n'est volontairement pas plus étendu pour l'instant : son périm 3. S'assurer qu'un **scope mapping** expose un claim `groups` dans l'ID token (Authentik a un mapping `groups` intégré dans les versions récentes, sinon créer un mapping personnalisé renvoyant `request.user.ak_groups.all()`). 4. Créer un **groupe** Authentik (ex: `octane-admins`) et y ajouter les membres qui doivent être admins de l'application. 5. Noter le Client ID / Client Secret du provider. +6. Optionnel — pour afficher l'avatar sur la page profil (`/profile.html`) : le scope `profile` doit renvoyer un claim `picture`. Si votre version d'Authentik ne le fait pas nativement, ajoutez un scope mapping personnalisé renvoyant l'URL de l'avatar (ex: `request.user.avatar`). Sans ce claim, un avatar généré à partir des initiales est affiché à la place — aucune configuration n'est requise pour ce cas. ## Variables d'environnement (référence complète) diff --git a/public/css/style.css b/public/css/style.css index 589713e..810cb4e 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -102,9 +102,12 @@ nav#main-nav { .brand { font-weight: 700; - font-size: 1.1rem; + font-size: 1.15rem; letter-spacing: 0.01em; - color: var(--text); + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; display: flex; align-items: center; gap: 0.4rem; @@ -112,6 +115,7 @@ nav#main-nav { } .brand .brand-dot { + -webkit-text-fill-color: initial; color: var(--accent); } @@ -160,12 +164,94 @@ nav#main-nav { margin-left: auto; } -.nav-user a { +.nav-user > a { color: var(--accent); text-decoration: none; font-weight: 500; } +.nav-profile-link { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text) !important; + font-weight: 500; +} + +.nav-profile-link span { color: var(--text); } + +#theme-toggle { + border-radius: 999px; + width: 2rem; + height: 2rem; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1rem; + line-height: 1; +} + +/* ---------- Avatars ---------- */ + +.avatar { + border-radius: 999px; + object-fit: cover; + flex: 0 0 auto; +} + +.avatar-sm { width: 1.7rem; height: 1.7rem; } +.avatar-lg { width: 4.5rem; height: 4.5rem; font-size: 1.4rem; } + +.avatar-initials { + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + color: #fff; + font-weight: 700; + font-size: 0.75rem; +} + +/* ---------- Profile page ---------- */ + +.profile-header { + display: flex; + align-items: center; + gap: 1.25rem; + margin-bottom: 1rem; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; +} + +.stat-tile { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1rem; + text-align: center; +} + +.stat-value { + font-size: 1.8rem; + font-weight: 700; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.stat-label { + color: var(--muted); + font-size: 0.85rem; + margin-top: 0.2rem; +} + .badge { background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #fff; @@ -675,4 +761,6 @@ a.back-link:hover { color: var(--accent); } } .setlist-row .row-note-input { flex-basis: 100%; order: 4; } .setlist-row .row-actions { flex-basis: 100%; order: 5; justify-content: flex-end; } + + .profile-header { flex-direction: column; text-align: center; } } diff --git a/public/history-detail.html b/public/history-detail.html index ecc2839..fff08a2 100644 --- a/public/history-detail.html +++ b/public/history-detail.html @@ -4,6 +4,7 @@ Détail du concert — Octane + diff --git a/public/history.html b/public/history.html index 7c7e195..f468a85 100644 --- a/public/history.html +++ b/public/history.html @@ -4,6 +4,7 @@ Historique des concerts — Octane + diff --git a/public/index.html b/public/index.html index cfcad03..d4fcf61 100644 --- a/public/index.html +++ b/public/index.html @@ -4,6 +4,7 @@ Répertoire — Octane + diff --git a/public/js/nav.js b/public/js/nav.js index d96dd45..1b4159a 100644 --- a/public/js/nav.js +++ b/public/js/nav.js @@ -1,3 +1,21 @@ +function currentTheme() { + const attr = document.documentElement.getAttribute('data-theme'); + if (attr === 'light' || attr === 'dark') return attr; + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function applyTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('octane-theme', theme); + updateThemeToggleIcon(); +} + +function updateThemeToggleIcon() { + const btn = document.getElementById('theme-toggle'); + if (!btn) return; + btn.textContent = currentTheme() === 'dark' ? '☀️' : '🌙'; +} + async function initNav(activePage) { const me = await api.get('/api/users/me'); const nav = document.getElementById('main-nav'); @@ -11,7 +29,11 @@ async function initNav(activePage) { Historique `; @@ -21,5 +43,10 @@ async function initNav(activePage) { toggle.addEventListener('click', () => links.classList.toggle('open')); links.querySelectorAll('a').forEach((a) => a.addEventListener('click', () => links.classList.remove('open'))); + updateThemeToggleIcon(); + document.getElementById('theme-toggle').addEventListener('click', () => { + applyTheme(currentTheme() === 'dark' ? 'light' : 'dark'); + }); + return me; } diff --git a/public/js/profile.js b/public/js/profile.js new file mode 100644 index 0000000..dfe0a89 --- /dev/null +++ b/public/js/profile.js @@ -0,0 +1,56 @@ +function formatDate(dateStr) { + const d = new Date(dateStr); + return d.toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' }); +} + +function showError(message) { + document.getElementById('error').innerHTML = `
${escapeHtml(message)}
`; +} + +function statTile(value, label) { + return ` +
+
${value}
+
${escapeHtml(label)}
+
+ `; +} + +(async function init() { + await initNav(null); + try { + const profile = await api.get('/api/users/me/profile'); + const container = document.getElementById('content'); + container.innerHTML = ` +
+ ${avatarHtml(profile, 'avatar-lg')} +
+

${escapeHtml(profile.name)}${profile.isAdmin ? ' admin' : ''}

+ ${profile.username ? `
@${escapeHtml(profile.username)}
` : ''} + ${profile.email ? `
${escapeHtml(profile.email)}
` : ''} +
Membre depuis ${formatDate(profile.createdAt)}
+
+
+ + ${profile.groups && profile.groups.length ? ` +

Groupes Authentik

+
+ ${profile.groups.map((g) => `${escapeHtml(g)}`).join('')} +
` : ''} + +

Votre activité

+
+ ${statTile(profile.stats.songsAdded, 'Morceaux ajoutés')} + ${statTile(profile.stats.suggestionsProposed, 'Suggestions proposées')} + ${statTile(profile.stats.votesCast, 'Votes exprimés')} +
+ +
+

Identité gérée par Authentik — pour changer votre nom, email ou mot de passe, rendez-vous sur votre compte Authentik.

+ +
+ `; + } catch (err) { + showError(err.message); + } +})(); diff --git a/public/js/render.js b/public/js/render.js index 3f72145..498b83d 100644 --- a/public/js/render.js +++ b/public/js/render.js @@ -39,3 +39,21 @@ function youtubeThumbnailUrl(url) { const videoId = youtubeVideoId(url); return videoId ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` : null; } + +function initials(name) { + return String(name || '?') + .trim() + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((w) => w[0].toUpperCase()) + .join('') || '?'; +} + +function avatarHtml(user, extraClass) { + const cls = `avatar${extraClass ? ` ${extraClass}` : ''}`; + if (user.avatarUrl) { + return `${escapeHtml(user.name)}`; + } + return `${escapeHtml(initials(user.name))}`; +} diff --git a/public/js/theme-init.js b/public/js/theme-init.js new file mode 100644 index 0000000..c9e6e3a --- /dev/null +++ b/public/js/theme-init.js @@ -0,0 +1,10 @@ +(function () { + try { + var saved = localStorage.getItem('octane-theme'); + if (saved === 'light' || saved === 'dark') { + document.documentElement.setAttribute('data-theme', saved); + } + } catch (e) { + /* localStorage unavailable (private mode etc.) — fall back to OS theme */ + } +})(); diff --git a/public/profile.html b/public/profile.html new file mode 100644 index 0000000..1c81be1 --- /dev/null +++ b/public/profile.html @@ -0,0 +1,24 @@ + + + + + +Mon profil — Octane + + + + +
+ +
+
+
+
Chargement…
+
+ + + + + + + diff --git a/public/setlist.html b/public/setlist.html index 839781b..e1feca3 100644 --- a/public/setlist.html +++ b/public/setlist.html @@ -4,6 +4,7 @@ Prochain concert — Octane + diff --git a/public/suggestions.html b/public/suggestions.html index 6db90b8..6767324 100644 --- a/public/suggestions.html +++ b/public/suggestions.html @@ -4,6 +4,7 @@ Suggestions — Octane + diff --git a/src/auth/routes.js b/src/auth/routes.js index ea90620..dcb8b2b 100644 --- a/src/auth/routes.js +++ b/src/auth/routes.js @@ -12,10 +12,14 @@ function escapeHtml(str) { }[c])); } -function establishSession(req, res, user, returnTo) { +function establishSession(req, res, user, returnTo, idToken) { req.session.regenerate((err) => { if (err) throw err; req.session.userId = user.id; + // Kept only so /auth/logout can end the Authentik SSO session too + // (id_token_hint) — without it, logging out locally still leaves an + // active Authentik session that silently re-authenticates the user. + if (idToken) req.session.idToken = idToken; req.session.save((saveErr) => { if (saveErr) throw saveErr; res.redirect(returnTo || '/'); @@ -130,7 +134,10 @@ router.get( const user = await usersRepo.upsertFromClaims({ sub: claims.sub, name: claims.name || claims.preferred_username || claims.email || claims.sub, + username: claims.preferred_username || null, email: claims.email || null, + avatarUrl: claims.picture || null, + groups, isAdmin, }); @@ -138,13 +145,36 @@ router.get( delete req.session.oidc; delete req.session.returnTo; - establishSession(req, res, user, returnTo); + establishSession(req, res, user, returnTo, tokens.id_token); }) ); router.get('/logout', (req, res) => { - req.session.destroy(() => { - res.redirect('/'); + const idToken = req.session.idToken; + + req.session.destroy((err) => { + if (err) throw err; + + // Local session is gone, but Authentik's own SSO session is still + // active — redirecting straight to '/' would just have requireAuth + // send the browser back through /auth/login, which Authentik would + // silently re-approve (same user, no prompt). RP-initiated logout at + // Authentik's end_session_endpoint is what actually signs them out. + if (config.devBypassAuth || !idToken) { + return res.redirect('/'); + } + + try { + const oidcConfig = getOidcConfig(); + const endSessionUrl = client.buildEndSessionUrl(oidcConfig, { + id_token_hint: idToken, + post_logout_redirect_uri: config.postLogoutRedirectUri, + }); + res.redirect(endSessionUrl.href); + } catch (endSessionErr) { + console.warn('[auth] could not build Authentik end-session URL, falling back to local logout only:', endSessionErr.message); + res.redirect('/'); + } }); }); diff --git a/src/config.js b/src/config.js index 55b4b58..63903de 100644 --- a/src/config.js +++ b/src/config.js @@ -33,5 +33,13 @@ module.exports = { oidcClientId: devBypassAuth ? process.env.OIDC_CLIENT_ID : required('OIDC_CLIENT_ID'), oidcClientSecret: devBypassAuth ? process.env.OIDC_CLIENT_SECRET : required('OIDC_CLIENT_SECRET'), oidcRedirectUri: devBypassAuth ? process.env.OIDC_REDIRECT_URI : required('OIDC_REDIRECT_URI'), + // Where Authentik sends the browser back after RP-initiated logout (see + // /auth/logout). Defaults to the app's own origin derived from + // OIDC_REDIRECT_URI; override with POST_LOGOUT_REDIRECT_URI if needed. + // Must be registered as an allowed logout redirect URI on the Authentik + // provider, same as the regular redirect URI. + postLogoutRedirectUri: + process.env.POST_LOGOUT_REDIRECT_URI || + (process.env.OIDC_REDIRECT_URI ? new URL('/', process.env.OIDC_REDIRECT_URI).href : undefined), adminGroupName: process.env.ADMIN_GROUP_NAME || 'octane-admins', }; diff --git a/src/db/migrations/004_user_profile_fields.sql b/src/db/migrations/004_user_profile_fields.sql new file mode 100644 index 0000000..0038590 --- /dev/null +++ b/src/db/migrations/004_user_profile_fields.sql @@ -0,0 +1,3 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS groups TEXT[] NOT NULL DEFAULT '{}'; diff --git a/src/repositories/usersRepo.js b/src/repositories/usersRepo.js index 2756e9f..7bed991 100644 --- a/src/repositories/usersRepo.js +++ b/src/repositories/usersRepo.js @@ -1,23 +1,35 @@ const pool = require('../db/pool'); +const PROFILE_FIELDS = 'id, authentik_sub, name, username, email, avatar_url, groups, is_admin, created_at'; + async function findById(id) { - const { rows } = await pool.query( - 'SELECT id, authentik_sub, name, email, is_admin, created_at FROM users WHERE id = $1', - [id] - ); + const { rows } = await pool.query(`SELECT ${PROFILE_FIELDS} FROM users WHERE id = $1`, [id]); return rows[0] || null; } -async function upsertFromClaims({ sub, name, email, isAdmin }) { +async function upsertFromClaims({ sub, name, username, email, avatarUrl, groups, isAdmin }) { const { rows } = await pool.query( - `INSERT INTO users (authentik_sub, name, email, is_admin) - VALUES ($1, $2, $3, $4) + `INSERT INTO users (authentik_sub, name, username, email, avatar_url, groups, is_admin) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (authentik_sub) - DO UPDATE SET name = $2, email = $3, is_admin = $4, updated_at = now() - RETURNING id, authentik_sub, name, email, is_admin, created_at`, - [sub, name, email, isAdmin] + DO UPDATE SET 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]; } -module.exports = { findById, upsertFromClaims }; +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]), + pool.query('SELECT COUNT(*)::int AS count FROM suggestions WHERE suggested_by = $1', [userId]), + pool.query('SELECT COUNT(*)::int AS count FROM suggestion_votes WHERE user_id = $1', [userId]), + ]); + return { + songsAdded: songs.rows[0].count, + suggestionsProposed: suggestions.rows[0].count, + votesCast: votes.rows[0].count, + }; +} + +module.exports = { findById, upsertFromClaims, getActivityStats }; diff --git a/src/routes/users.js b/src/routes/users.js index 37ed877..4e8a90f 100644 --- a/src/routes/users.js +++ b/src/routes/users.js @@ -1,4 +1,5 @@ const express = require('express'); +const usersRepo = require('../repositories/usersRepo'); const asyncHandler = require('../lib/asyncHandler'); const router = express.Router(); @@ -6,8 +7,27 @@ const router = express.Router(); router.get( '/me', asyncHandler(async (req, res) => { - const { id, name, email, is_admin } = req.user; - res.json({ id, name, email, isAdmin: is_admin }); + const { id, name, username, email, avatar_url, is_admin } = req.user; + res.json({ id, name, username, email, avatarUrl: avatar_url, isAdmin: is_admin }); + }) +); + +router.get( + '/me/profile', + asyncHandler(async (req, res) => { + const { id, name, username, email, avatar_url, groups, is_admin, created_at } = req.user; + const stats = await usersRepo.getActivityStats(id); + res.json({ + id, + name, + username, + email, + avatarUrl: avatar_url, + groups, + isAdmin: is_admin, + createdAt: created_at, + stats, + }); }) );