Wire authentication into the application

Adds the sign-in screen, sign-out, and a server-side guard on every
application route. The guard lives in the layout rather than the proxy
because the proxy cannot query the database to check whether a session
was revoked — and revocation is the reason sessions are stored there.

Sign-in returns one message for an unknown account and for a wrong
password, and verifies a dummy hash when the account does not exist, so
neither the wording nor the timing enumerates staff addresses. An
end-to-end test compares the two messages rather than trusting the
code to keep them aligned.

The shell now shows the signed-in person and their role from the
database instead of hardcoded initials.

Playwright signs in once in a setup project and shares the cookie;
argon2 is deliberately slow, and logging in per test would also drive
the shared failed-attempt counter toward a lockout. The seed resets
that counter so repeated local runs cannot lock the demo account.

Two test locators had to be scoped to the form: Next's route announcer
carries role="alert" and an empty string, which silently satisfied the
assertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-07 22:45:24 +00:00
1 parent 11763142d5
commit 2c0e9e8dd4
12 files changed
+361 -19

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
'use server';
import { cookies, headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import {
SESSION_COOKIE,
SESSION_COOKIE_OPTIONS,
signIn,
signOut,
} from '@/server/auth/session';
const credentials = z.object({
email: z.email('Adresse électronique invalide'),
password: z.string().min(1, 'Mot de passe requis'),
});
export interface SignInState {
error?: string;
}
export async function signInAction(
_previous: SignInState,
formData: FormData,
): Promise<SignInState> {
const parsed = credentials.safeParse({
email: formData.get('email'),
password: formData.get('password'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const requestHeaders = await headers();
const result = await signIn({
email: parsed.data.email,
password: parsed.data.password,
ip: requestHeaders.get('x-forwarded-for'),
userAgent: requestHeaders.get('user-agent'),
});
if (!result.ok) {
// Un seul message pour « compte inconnu » et « mot de passe faux » : les
// distinguer permettrait d'énumérer les adresses du personnel.
return {
error:
result.reason === 'locked'
? 'Compte temporairement verrouillé après plusieurs échecs. Réessayez dans quelques minutes.'
: 'Identifiants incorrects.',
};
}
(await cookies()).set(SESSION_COOKIE, result.token, {
...SESSION_COOKIE_OPTIONS,
expires: result.expiresAt,
});
redirect('/');
}
export async function signOutAction(): Promise<void> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (token) await signOut(token);
store.delete(SESSION_COOKIE);
redirect('/connexion');
}
+25 -1
View File
@@ -138,9 +138,19 @@ export async function revokeAllSessions(
return result.count;
}
export interface SessionUser {
firstName: string;
lastName: string;
email: string;
initials: string;
}
export interface SessionContext {
actor: Actor;
sessionId: string;
user: SessionUser;
accountName: string;
roleName: string;
}
/**
@@ -162,6 +172,7 @@ export async function resolveSession(
memberships: {
where: { status: 'ACTIVE', archivedAt: null },
include: {
account: { select: { name: true } },
role: { include: { permissions: { include: { permission: true } } } },
scopes: true,
},
@@ -199,7 +210,20 @@ export async function resolveSession(
scope,
};
return { actor, sessionId: session.id };
const { firstName, lastName, email } = session.user;
return {
actor,
sessionId: session.id,
user: {
firstName,
lastName,
email,
initials: `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(),
},
accountName: membership.account.name,
roleName: membership.role.name,
};
}
/** Session courante depuis le cookie, ou `null`. */