From f6ff81bf16413d6daf64ed7269e2cb887d0784a8 Mon Sep 17 00:00:00 2001 From: vl Date: Sun, 20 Sep 2026 18:33:02 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd --- app/admin/page.tsx | 57 ++++++++++++++++++++++++++++++++++++++++++++-- lib/auth/index.ts | 25 +++++++++++++++++++- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/app/admin/page.tsx b/app/admin/page.tsx index fae14d7..3a1c88c 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -4,14 +4,67 @@ export const metadata = { title: 'Tableau de bord — ITA ITO' }; export default async function AdminHome() { const session = await auth(); + const user = session?.user; + const isAdmin = user?.role === 'admin'; return (

Tableau de bord

- Connecté en tant que {session?.user?.email} ( - {session?.user?.role === 'admin' ? 'administrateur' : 'lecture seule'}). + Connecté en tant que {user?.email} ({isAdmin ? 'administrateur' : 'lecture seule'}).

+ + {!isAdmin ? : null}
); } + +/** + * A read-only account has exactly two causes, fixed in completely different + * places: either the identity provider sent no groups at all, or it sent + * groups that do not include the one that grants write access. Saying which + * turns a support conversation into a thirty-second fix. + */ +function ReadOnlyExplanation({ groups, expected }: { groups: string[]; expected: string }) { + const claimMissing = groups.length === 0; + + return ( +
+

Pourquoi ce compte est-il en lecture seule ?

+ +
+
+
Groupe donnant l’accès en écriture
+
{expected || '(non configuré)'}
+
+
+
Groupes reçus d’Authentik
+
+ {claimMissing ? 'aucun — le claim « groups » est absent' : groups.join(', ')} +
+
+
+ +

+ {claimMissing ? ( + <> + Authentik n’envoie aucun groupe. Dans le provider OAuth2/OpenID, vérifiez que le scope{' '} + profile est bien sélectionné et que{' '} + Include claims in id_token est activé. + + ) : ( + <> + Authentik envoie bien des groupes, mais pas celui attendu. Ajoutez ce compte au groupe{' '} + {expected}, ou corrigez{' '} + AUTHENTIK_ADMIN_GROUP pour qu’il corresponde à l’un + des groupes ci-dessus. + + )} +

+ +

+ Le rôle est fixé à la connexion : après correction, déconnectez-vous et reconnectez-vous. +

+
+ ); +} diff --git a/lib/auth/index.ts b/lib/auth/index.ts index 68b21e2..ad72983 100644 --- a/lib/auth/index.ts +++ b/lib/auth/index.ts @@ -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; }, },