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:
+24
-1
@@ -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;
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user