mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
update: change theme
This commit is contained in:
parent
aca521b54f
commit
9426b73940
17 changed files with 326 additions and 21 deletions
|
|
@ -241,9 +241,12 @@ erDiagram
|
||||||
| `/suggestions.html` | Tous | Proposer un morceau (avec lien YouTube embarqué + note libre), voter approuver/rejeter avec commentaire, attribué nominativement |
|
| `/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 |
|
| `/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 |
|
| `/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).
|
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
|
## 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.
|
- **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()`).
|
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.
|
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.
|
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)
|
## Variables d'environnement (référence complète)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,9 +102,12 @@ nav#main-nav {
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.1rem;
|
font-size: 1.15rem;
|
||||||
letter-spacing: 0.01em;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
|
@ -112,6 +115,7 @@ nav#main-nav {
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand .brand-dot {
|
.brand .brand-dot {
|
||||||
|
-webkit-text-fill-color: initial;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,12 +164,94 @@ nav#main-nav {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-user a {
|
.nav-user > a {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
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 {
|
.badge {
|
||||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||||
color: #fff;
|
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-note-input { flex-basis: 100%; order: 4; }
|
||||||
.setlist-row .row-actions { flex-basis: 100%; order: 5; justify-content: flex-end; }
|
.setlist-row .row-actions { flex-basis: 100%; order: 5; justify-content: flex-end; }
|
||||||
|
|
||||||
|
.profile-header { flex-direction: column; text-align: center; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Détail du concert — Octane</title>
|
<title>Détail du concert — Octane</title>
|
||||||
|
<script src="/js/theme-init.js"></script>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Historique des concerts — Octane</title>
|
<title>Historique des concerts — Octane</title>
|
||||||
|
<script src="/js/theme-init.js"></script>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Répertoire — Octane</title>
|
<title>Répertoire — Octane</title>
|
||||||
|
<script src="/js/theme-init.js"></script>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
async function initNav(activePage) {
|
||||||
const me = await api.get('/api/users/me');
|
const me = await api.get('/api/users/me');
|
||||||
const nav = document.getElementById('main-nav');
|
const nav = document.getElementById('main-nav');
|
||||||
|
|
@ -11,7 +29,11 @@ async function initNav(activePage) {
|
||||||
<a href="/history.html" class="${activePage === 'history' ? 'active' : ''}">Historique</a>
|
<a href="/history.html" class="${activePage === 'history' ? 'active' : ''}">Historique</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-user">
|
<div class="nav-user">
|
||||||
|
<button type="button" class="icon-btn secondary" id="theme-toggle" title="Changer de thème" aria-label="Changer de thème"></button>
|
||||||
|
<a class="nav-profile-link" href="/profile.html">
|
||||||
|
${avatarHtml(me, 'avatar-sm')}
|
||||||
<span>${escapeHtml(me.name)}${me.isAdmin ? ' <span class="badge">admin</span>' : ''}</span>
|
<span>${escapeHtml(me.name)}${me.isAdmin ? ' <span class="badge">admin</span>' : ''}</span>
|
||||||
|
</a>
|
||||||
<a href="/auth/logout">Se déconnecter</a>
|
<a href="/auth/logout">Se déconnecter</a>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -21,5 +43,10 @@ async function initNav(activePage) {
|
||||||
toggle.addEventListener('click', () => links.classList.toggle('open'));
|
toggle.addEventListener('click', () => links.classList.toggle('open'));
|
||||||
links.querySelectorAll('a').forEach((a) => a.addEventListener('click', () => links.classList.remove('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;
|
return me;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
public/js/profile.js
Normal file
56
public/js/profile.js
Normal file
|
|
@ -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 = `<div class="error-banner">${escapeHtml(message)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statTile(value, label) {
|
||||||
|
return `
|
||||||
|
<div class="stat-tile">
|
||||||
|
<div class="stat-value">${value}</div>
|
||||||
|
<div class="stat-label">${escapeHtml(label)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async function init() {
|
||||||
|
await initNav(null);
|
||||||
|
try {
|
||||||
|
const profile = await api.get('/api/users/me/profile');
|
||||||
|
const container = document.getElementById('content');
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="profile-header">
|
||||||
|
${avatarHtml(profile, 'avatar-lg')}
|
||||||
|
<div>
|
||||||
|
<h1>${escapeHtml(profile.name)}${profile.isAdmin ? ' <span class="badge">admin</span>' : ''}</h1>
|
||||||
|
${profile.username ? `<div class="card-subtitle">@${escapeHtml(profile.username)}</div>` : ''}
|
||||||
|
${profile.email ? `<div class="card-subtitle">${escapeHtml(profile.email)}</div>` : ''}
|
||||||
|
<div class="card-subtitle">Membre depuis ${formatDate(profile.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${profile.groups && profile.groups.length ? `
|
||||||
|
<h2>Groupes Authentik</h2>
|
||||||
|
<div class="tag-list">
|
||||||
|
${profile.groups.map((g) => `<span class="tag">${escapeHtml(g)}</span>`).join('')}
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
|
<h2>Votre activité</h2>
|
||||||
|
<div class="stat-grid">
|
||||||
|
${statTile(profile.stats.songsAdded, 'Morceaux ajoutés')}
|
||||||
|
${statTile(profile.stats.suggestionsProposed, 'Suggestions proposées')}
|
||||||
|
${statTile(profile.stats.votesCast, 'Votes exprimés')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<p class="empty">Identité gérée par Authentik — pour changer votre nom, email ou mot de passe, rendez-vous sur votre compte Authentik.</p>
|
||||||
|
<a href="/auth/logout"><button type="button" class="danger">Se déconnecter</button></a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -39,3 +39,21 @@ function youtubeThumbnailUrl(url) {
|
||||||
const videoId = youtubeVideoId(url);
|
const videoId = youtubeVideoId(url);
|
||||||
return videoId ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` : null;
|
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 `<img class="${cls}" src="${escapeHtml(user.avatarUrl)}" alt="${escapeHtml(user.name)}" loading="lazy">`;
|
||||||
|
}
|
||||||
|
return `<span class="${cls} avatar-initials">${escapeHtml(initials(user.name))}</span>`;
|
||||||
|
}
|
||||||
|
|
|
||||||
10
public/js/theme-init.js
Normal file
10
public/js/theme-init.js
Normal file
|
|
@ -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 */
|
||||||
|
}
|
||||||
|
})();
|
||||||
24
public/profile.html
Normal file
24
public/profile.html
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Mon profil — Octane</title>
|
||||||
|
<script src="/js/theme-init.js"></script>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<nav id="main-nav"></nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div id="error"></div>
|
||||||
|
<div id="content">Chargement…</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/render.js"></script>
|
||||||
|
<script src="/js/nav.js"></script>
|
||||||
|
<script src="/js/profile.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Prochain concert — Octane</title>
|
<title>Prochain concert — Octane</title>
|
||||||
|
<script src="/js/theme-init.js"></script>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Suggestions — Octane</title>
|
<title>Suggestions — Octane</title>
|
||||||
|
<script src="/js/theme-init.js"></script>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,14 @@ function escapeHtml(str) {
|
||||||
}[c]));
|
}[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function establishSession(req, res, user, returnTo) {
|
function establishSession(req, res, user, returnTo, idToken) {
|
||||||
req.session.regenerate((err) => {
|
req.session.regenerate((err) => {
|
||||||
if (err) throw err;
|
if (err) throw err;
|
||||||
req.session.userId = user.id;
|
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) => {
|
req.session.save((saveErr) => {
|
||||||
if (saveErr) throw saveErr;
|
if (saveErr) throw saveErr;
|
||||||
res.redirect(returnTo || '/');
|
res.redirect(returnTo || '/');
|
||||||
|
|
@ -130,7 +134,10 @@ router.get(
|
||||||
const user = await usersRepo.upsertFromClaims({
|
const user = await usersRepo.upsertFromClaims({
|
||||||
sub: claims.sub,
|
sub: claims.sub,
|
||||||
name: claims.name || claims.preferred_username || claims.email || claims.sub,
|
name: claims.name || claims.preferred_username || claims.email || claims.sub,
|
||||||
|
username: claims.preferred_username || null,
|
||||||
email: claims.email || null,
|
email: claims.email || null,
|
||||||
|
avatarUrl: claims.picture || null,
|
||||||
|
groups,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -138,13 +145,36 @@ router.get(
|
||||||
delete req.session.oidc;
|
delete req.session.oidc;
|
||||||
delete req.session.returnTo;
|
delete req.session.returnTo;
|
||||||
|
|
||||||
establishSession(req, res, user, returnTo);
|
establishSession(req, res, user, returnTo, tokens.id_token);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get('/logout', (req, res) => {
|
router.get('/logout', (req, res) => {
|
||||||
req.session.destroy(() => {
|
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('/');
|
res.redirect('/');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,5 +33,13 @@ module.exports = {
|
||||||
oidcClientId: devBypassAuth ? process.env.OIDC_CLIENT_ID : required('OIDC_CLIENT_ID'),
|
oidcClientId: devBypassAuth ? process.env.OIDC_CLIENT_ID : required('OIDC_CLIENT_ID'),
|
||||||
oidcClientSecret: devBypassAuth ? process.env.OIDC_CLIENT_SECRET : required('OIDC_CLIENT_SECRET'),
|
oidcClientSecret: devBypassAuth ? process.env.OIDC_CLIENT_SECRET : required('OIDC_CLIENT_SECRET'),
|
||||||
oidcRedirectUri: devBypassAuth ? process.env.OIDC_REDIRECT_URI : required('OIDC_REDIRECT_URI'),
|
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',
|
adminGroupName: process.env.ADMIN_GROUP_NAME || 'octane-admins',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
3
src/db/migrations/004_user_profile_fields.sql
Normal file
3
src/db/migrations/004_user_profile_fields.sql
Normal file
|
|
@ -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 '{}';
|
||||||
|
|
@ -1,23 +1,35 @@
|
||||||
const pool = require('../db/pool');
|
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) {
|
async function findById(id) {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(`SELECT ${PROFILE_FIELDS} FROM users WHERE id = $1`, [id]);
|
||||||
'SELECT id, authentik_sub, name, email, is_admin, created_at FROM users WHERE id = $1',
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
return rows[0] || null;
|
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(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO users (authentik_sub, name, email, is_admin)
|
`INSERT INTO users (authentik_sub, name, username, email, avatar_url, groups, is_admin)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (authentik_sub)
|
ON CONFLICT (authentik_sub)
|
||||||
DO UPDATE SET name = $2, email = $3, is_admin = $4, updated_at = now()
|
DO UPDATE SET name = $2, username = $3, email = $4, avatar_url = $5, groups = $6, is_admin = $7, updated_at = now()
|
||||||
RETURNING id, authentik_sub, name, email, is_admin, created_at`,
|
RETURNING ${PROFILE_FIELDS}`,
|
||||||
[sub, name, email, isAdmin]
|
[sub, name, username || null, email, avatarUrl || null, groups || [], isAdmin]
|
||||||
);
|
);
|
||||||
return rows[0];
|
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 };
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const usersRepo = require('../repositories/usersRepo');
|
||||||
const asyncHandler = require('../lib/asyncHandler');
|
const asyncHandler = require('../lib/asyncHandler');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -6,8 +7,27 @@ const router = express.Router();
|
||||||
router.get(
|
router.get(
|
||||||
'/me',
|
'/me',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { id, name, email, is_admin } = req.user;
|
const { id, name, username, email, avatar_url, is_admin } = req.user;
|
||||||
res.json({ id, name, email, isAdmin: is_admin });
|
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,
|
||||||
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue