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
+55 -2
View File
@@ -4,14 +4,67 @@ export const metadata = { title: 'Tableau de bord — ITA ITO' };
export default async function AdminHome() { export default async function AdminHome() {
const session = await auth(); const session = await auth();
const user = session?.user;
const isAdmin = user?.role === 'admin';
return ( return (
<main className="mx-auto max-w-5xl px-4 py-10"> <main className="mx-auto max-w-5xl px-4 py-10">
<h1 className="text-2xl">Tableau de bord</h1> <h1 className="text-2xl">Tableau de bord</h1>
<p className="mt-2 text-[var(--ink-muted)]"> <p className="mt-2 text-[var(--ink-muted)]">
Connecté en tant que {session?.user?.email} ( Connecté en tant que {user?.email} ({isAdmin ? 'administrateur' : 'lecture seule'}).
{session?.user?.role === 'admin' ? 'administrateur' : 'lecture seule'}).
</p> </p>
{!isAdmin ? <ReadOnlyExplanation groups={user?.groups ?? []} expected={user?.adminGroup ?? ''} /> : null}
</main> </main>
); );
} }
/**
* 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 (
<section className="mt-8 max-w-2xl rounded-[var(--radius-md)] border border-[var(--line-strong)] bg-[var(--surface)] p-5">
<h2 className="text-base">Pourquoi ce compte est-il en lecture seule&nbsp;?</h2>
<dl className="mt-4 space-y-3 text-sm">
<div>
<dt className="text-[var(--ink-muted)]">Groupe donnant l’accès en écriture</dt>
<dd className="mt-0.5 font-mono">{expected || '(non configuré)'}</dd>
</div>
<div>
<dt className="text-[var(--ink-muted)]">Groupes reçus d’Authentik</dt>
<dd className="mt-0.5 font-mono">
{claimMissing ? 'aucun — le claim « groups » est absent' : groups.join(', ')}
</dd>
</div>
</dl>
<p className="mt-4 text-sm text-[var(--ink-muted)]">
{claimMissing ? (
<>
Authentik n’envoie aucun groupe. Dans le provider OAuth2/OpenID, vérifiez que le scope{' '}
<span className="font-mono">profile</span> est bien sélectionné et que{' '}
<em>Include claims in id_token</em> est activé.
</>
) : (
<>
Authentik envoie bien des groupes, mais pas celui attendu. Ajoutez ce compte au groupe{' '}
<span className="font-mono">{expected}</span>, ou corrigez{' '}
<span className="font-mono">AUTHENTIK_ADMIN_GROUP</span> pour qu’il corresponde à l’un
des groupes ci-dessus.
</>
)}
</p>
<p className="mt-3 text-sm text-[var(--ink-muted)]">
Le rôle est fixé à la connexion&nbsp;: après correction, déconnectez-vous et reconnectez-vous.
</p>
</section>
);
}
+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'; 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' { declare module 'next-auth' {
interface Session { interface Session {
user: { user: {
@@ -26,6 +29,10 @@ declare module 'next-auth' {
name?: string | null; name?: string | null;
image?: string | null; image?: string | null;
role: Role; 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' { declare module '@auth/core/jwt' {
interface JWT { interface JWT {
role?: Role; role?: Role;
groups?: string[];
} }
} }
@@ -48,12 +56,27 @@ const nextAuth = NextAuth({
// `profile` is only present on the request that follows a sign-in, so // `profile` is only present on the request that follows a sign-in, so
// the group membership is resolved once and carried in the token. // the group membership is resolved once and carried in the token.
if (profile) { 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; return token;
}, },
session({ session, token }) { session({ session, token }) {
session.user.role = token.role ?? 'viewer'; session.user.role = token.role ?? 'viewer';
session.user.groups = token.groups ?? [];
session.user.adminGroup = ADMIN_GROUP;
return session; return session;
}, },
}, },