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 = `
+
+
+ ${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 `
`;
+ }
+ 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,
+ });
})
);