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