update: change theme

This commit is contained in:
Nathan FONTEYNE 2026-07-08 14:24:23 +02:00
parent aca521b54f
commit 9426b73940
17 changed files with 326 additions and 21 deletions

View file

@ -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('/');
}
});
});

View file

@ -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',
};

View 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 '{}';

View file

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

View file

@ -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,
});
})
);