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
70 lines
1.7 KiB
TypeScript
70 lines
1.7 KiB
TypeScript
'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');
|
|
}
|