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:
12 files changed
+361
-19
No files matched your search
@@ -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');
|
||||
}
|
||||
@@ -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`. */
|
||||
|
||||
Reference in new issue
Block a user