fix: let a read-only account sign out

A React server action POSTs to the URL of the page it lives on, so the
middleware's method check over /admin refused every form on the site to
a viewer — including the sign-out button, which surfaced as "an
unexpected response was received from the server".

Gating pages by HTTP method was the wrong instrument: at the edge there
is no way to tell a form that changes the shop's hours from one that
ends a session. The method check now applies to /api/admin only, and
page-level writes are authorised inside the actions themselves, where
the intent is actually known. lib/auth/actions.ts carries that check and
returns an error rather than throwing, since "you do not have
permission" is a normal outcome and not a crash.

The edge decision moves into a pure function with a regression test for
this exact case. These rules are short, but they are the only thing in
front of the administration and one of them has now been got wrong once.

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:51:40 +02:00
co-authored by Claude Opus 5
parent f6ff81bf16
commit 5c0b8119a4
4 changed files with 166 additions and 15 deletions
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { decideAccess } from './access';
describe('decideAccess', () => {
it('sends an anonymous browser to the sign-in screen', () => {
expect(decideAccess({ pathname: '/admin/horaires', method: 'GET', role: undefined })).toEqual({
kind: 'redirect',
});
});
it('answers an anonymous fetch with 401 rather than an HTML page', () => {
expect(decideAccess({ pathname: '/api/admin/messages', method: 'GET', role: undefined })).toEqual(
{ kind: 'deny', status: 401, error: 'Non authentifié' },
);
});
it('lets a read-only account read pages and API routes', () => {
expect(decideAccess({ pathname: '/admin', method: 'GET', role: 'viewer' })).toEqual({
kind: 'allow',
});
expect(decideAccess({ pathname: '/api/admin/messages', method: 'GET', role: 'viewer' })).toEqual(
{ kind: 'allow' },
);
});
it('refuses a write to the API from a read-only account', () => {
expect(decideAccess({ pathname: '/api/admin/messages', method: 'POST', role: 'viewer' })).toEqual(
{ kind: 'deny', status: 403, error: 'Compte en lecture seule' },
);
});
it('allows a write to the API from an administrator', () => {
expect(decideAccess({ pathname: '/api/admin/messages', method: 'DELETE', role: 'admin' })).toEqual(
{ kind: 'allow' },
);
});
it('lets a read-only account POST to a page', () => {
// Regression: a React server action POSTs to the URL of the page it lives
// on. Gating pages by method refused every form to a viewer, sign-out
// included, which showed up as "an unexpected response was received from
// the server". Page writes are checked inside the action instead.
expect(decideAccess({ pathname: '/admin', method: 'POST', role: 'viewer' })).toEqual({
kind: 'allow',
});
expect(decideAccess({ pathname: '/admin/horaires', method: 'POST', role: 'viewer' })).toEqual({
kind: 'allow',
});
});
it('still requires a session before a page POST', () => {
expect(decideAccess({ pathname: '/admin', method: 'POST', role: undefined })).toEqual({
kind: 'redirect',
});
});
});
+46
View File
@@ -0,0 +1,46 @@
/**
* What the edge should do with a request, decided in one pure function.
*
* Extracted from the middleware so it can be tested: the rules here are short
* but they are the only thing standing in front of the administration, and one
* of them has already been got wrong once.
*/
import type { Role } from './roles';
export type AccessDecision =
| { kind: 'allow' }
/** Send a browser to the sign-in screen. */
| { kind: 'redirect' }
/** Answer a fetch with a status code rather than an HTML page. */
| { kind: 'deny'; status: 401 | 403; error: string };
export type AccessRequest = {
pathname: string;
method: string;
role: Role | undefined;
};
export function decideAccess({ pathname, method, role }: AccessRequest): AccessDecision {
const isApi = pathname.startsWith('/api/admin');
if (!role) {
// A fetch that receives an HTML login page is a confusing way to learn
// you are signed out.
return isApi
? { kind: 'deny', status: 401, error: 'Non authentifié' }
: { kind: 'redirect' };
}
const isRead = method === 'GET' || method === 'HEAD';
// Only API routes are gated by method. A React server action POSTs to the
// URL of the page it lives on, so gating pages this way would refuse every
// form to a read-only account — including the sign-out button. Pages carry
// their own check inside the action, where the intent is known.
if (isApi && !isRead && role !== 'admin') {
return { kind: 'deny', status: 403, error: 'Compte en lecture seule' };
}
return { kind: 'allow' };
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Authorisation for React server actions.
*
* A server action POSTs to the URL of the page it sits on, so the edge
* middleware cannot tell a form that changes the shop's hours from a form that
* signs someone out. It therefore only authenticates page requests, and every
* action that writes states its own requirement here.
*/
import { auth } from './index';
import type { Role } from './roles';
import { canWrite } from './roles';
export type Caller = { email: string; role: Role };
export type ActionResult<T = void> = { ok: true; value: T } | { ok: false; error: string };
export async function currentCaller(): Promise<Caller | null> {
const session = await auth();
if (!session?.user) {
return null;
}
return {
// The subject is the e-mail address, which is what the audit trail records.
email: session.user.email ?? 'inconnu',
role: session.user.role,
};
}
/**
* Resolves the caller, or an error to show the user.
*
* Returns rather than throws: an action that throws renders the Next error
* overlay, and "you do not have permission" is a normal outcome, not a crash.
*/
export async function requireAdmin(): Promise<ActionResult<Caller>> {
const caller = await currentCaller();
if (!caller) {
return { ok: false, error: 'Session expirée. Reconnectez-vous.' };
}
if (!canWrite(caller.role)) {
return {
ok: false,
error: `Ce compte est en lecture seule. Demandez à être ajouté au groupe d’administration.`,
};
}
return { ok: true, value: caller };
}
+15 -15
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { decideAccess } from '@/lib/auth/access';
import { auth } from '@/lib/auth';
/**
@@ -10,33 +11,32 @@ import { auth } from '@/lib/auth';
* screen; API routes get a status code, because a fetch that receives an HTML
* login page is a confusing way to learn you are signed out.
*
* Write access is enforced here too: a `viewer` may read anything and change
* nothing. Doing it at the edge means a read-only account cannot reach a
* handler that mutates, whatever that handler remembers to check.
* Write access is enforced here for /api/admin only, and deliberately NOT for
* pages. A React server action POSTs to the URL of the page it lives on, so a
* method check over /admin would refuse every form on the site to a read-only
* account — including the sign-out button, which is not a write by any useful
* definition. Page-level write protection belongs inside the actions
* themselves, via lib/auth/actions.ts, where the intent is actually known.
*
* The device API (/api/setup, /api/display, /api/log) is deliberately outside
* this matcher: the panel cannot sign in, and carries its own bearer token.
*/
export default auth((request) => {
const { pathname } = request.nextUrl;
const isApi = pathname.startsWith('/api/admin');
const session = request.auth;
const decision = decideAccess({
pathname,
method: request.method,
role: request.auth?.user?.role,
});
if (!session?.user) {
if (isApi) {
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
}
if (decision.kind === 'redirect') {
const target = new URL('/login', request.nextUrl.origin);
target.searchParams.set('from', pathname);
return NextResponse.redirect(target);
}
const isRead = request.method === 'GET' || request.method === 'HEAD';
if (!isRead && session.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Compte en lecture seule' },
{ status: 403 },
);
if (decision.kind === 'deny') {
return NextResponse.json({ error: decision.error }, { status: decision.status });
}
return NextResponse.next();