feat: explain why an account is read-only

A viewer account has exactly two causes, fixed in completely different
places: the identity provider sent no groups at all, or it sent groups
that do not include the one granting write access. From the outside the
two look identical, so the dashboard now says which it is and what to
do about it, and the sign-in logs the same thing server-side.

The groups are carried in the session for that purpose, capped so the
cookie cannot grow with someone's group membership. Group names are not
secrets, and a support conversation that starts with the actual claim is
a thirty-second fix rather than a guessing game.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
2026-09-20 18:33:02 +02:00
co-authored by Claude Opus 5
parent ac6be97d9b
commit f6ff81bf16
2 changed files with 79 additions and 3 deletions
+24 -1
View File
@@ -19,6 +19,9 @@ import { groupsFromClaim, roleFromGroups, type Role } from './roles';
const ADMIN_GROUP = process.env.AUTHENTIK_ADMIN_GROUP?.trim() || 'horaires-admins';
/** Enough to explain a read-only account, not enough to bloat the cookie. */
const MAX_REPORTED_GROUPS = 20;
declare module 'next-auth' {
interface Session {
user: {
@@ -26,6 +29,10 @@ declare module 'next-auth' {
name?: string | null;
image?: string | null;
role: Role;
/** The groups the identity provider sent, so the UI can explain itself. */
groups: string[];
/** The group that would grant write access. */
adminGroup: string;
};
}
}
@@ -33,6 +40,7 @@ declare module 'next-auth' {
declare module '@auth/core/jwt' {
interface JWT {
role?: Role;
groups?: string[];
}
}
@@ -48,12 +56,27 @@ const nextAuth = NextAuth({
// `profile` is only present on the request that follows a sign-in, so
// the group membership is resolved once and carried in the token.
if (profile) {
token.role = roleFromGroups(groupsFromClaim(profile.groups), ADMIN_GROUP);
const groups = groupsFromClaim(profile.groups);
token.groups = groups.slice(0, MAX_REPORTED_GROUPS);
token.role = roleFromGroups(groups, ADMIN_GROUP);
if (token.role !== 'admin') {
// The two failure modes look identical from the outside and are
// fixed in completely different places, so say which one it is.
// Group names are not secrets.
console.info(
`[auth] ${token.email ?? 'inconnu'} est en lecture seule. ` +
`Groupes reçus : ${groups.length > 0 ? groups.join(', ') : '(aucun — le claim « groups » est absent)'}. ` +
`Groupe attendu : ${ADMIN_GROUP}.`,
);
}
}
return token;
},
session({ session, token }) {
session.user.role = token.role ?? 'viewer';
session.user.groups = token.groups ?? [];
session.user.adminGroup = ADMIN_GROUP;
return session;
},
},