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

+3
View File
@@ -50,6 +50,9 @@ jobs:
- name: Appliquer les migrations
run: pnpm db:deploy
- name: Installer le jeu de données
run: pnpm db:seed
- run: pnpm typecheck
- run: pnpm lint
+29 -11
View File
@@ -1,5 +1,17 @@
import { defineConfig, devices } from '@playwright/test';
import { STORAGE_STATE } from './tests/e2e/storage';
/**
* Certains environnements fournissent déjà un Chromium dont la révision ne
* correspond pas à celle qu'attend cette version de Playwright.
* PLAYWRIGHT_CHROMIUM_PATH permet de le réutiliser plutôt que d'en télécharger
* un second.
*/
const chromiumOverride = process.env.PLAYWRIGHT_CHROMIUM_PATH
? { launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH } }
: {};
const PORT = Number(process.env.E2E_PORT ?? 3100);
const baseURL = `http://127.0.0.1:${PORT}`;
@@ -18,21 +30,27 @@ export default defineConfig({
},
projects: [
// Ouvre une session et enregistre le cookie ; les autres projets
// le réutilisent.
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
use: { ...devices['Desktop Chrome'], ...chromiumOverride },
},
// Parcours d'authentification : doit partir d'un navigateur vierge.
{
name: 'anonyme',
testMatch: /auth\.spec\.ts/,
use: { ...devices['Desktop Chrome'], ...chromiumOverride },
},
{
name: 'chromium',
testIgnore: /auth\.(setup|spec)\.ts/,
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
// Certains environnements fournissent déjà un Chromium dont la révision
// ne correspond pas à celle qu'attend cette version de Playwright.
// PLAYWRIGHT_CHROMIUM_PATH permet de le réutiliser plutôt que d'en
// télécharger un second.
...(process.env.PLAYWRIGHT_CHROMIUM_PATH
? {
launchOptions: {
executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH,
},
}
: {}),
...chromiumOverride,
storageState: STORAGE_STATE,
},
},
],
+4 -1
View File
@@ -134,7 +134,10 @@ async function main() {
for (const [index, person] of people.entries()) {
const user = await prisma.user.upsert({
where: { email: person.email },
update: {},
// Remet le compteur d'échecs à zéro : sans cela, des exécutions
// répétées des tests finissent par verrouiller le compte au bout de
// huit tentatives, et l'échec suivant est incompréhensible.
update: { failedAttempts: 0, lockedUntil: null, passwordHash },
create: {
email: person.email,
firstName: person.firstName,
+24 -2
View File
@@ -1,7 +1,29 @@
import { redirect } from 'next/navigation';
import type { ReactNode } from 'react';
import { AppShell } from '@/components/shell/AppShell';
import { currentSession } from '@/server/auth/session';
export default function AppLayout({ children }: { children: ReactNode }) {
return <AppShell>{children}</AppShell>;
/**
* Toutes les routes applicatives passent par ici.
*
* Le contrôle est fait côté serveur, dans le layout, et non dans le proxy : le
* proxy ne peut pas interroger la base pour vérifier qu'une session n'a pas été
* révoquée, et c'est précisément la révocation qui justifie de tenir les
* sessions en base (matrice n° 23).
*/
export default async function AppLayout({ children }: { children: ReactNode }) {
const session = await currentSession();
if (!session) redirect('/connexion');
return (
<AppShell
initials={session.user.initials}
fullName={`${session.user.firstName} ${session.user.lastName}`}
roleName={session.roleName}
accountName={session.accountName}
>
{children}
</AppShell>
);
}
+66
View File
@@ -0,0 +1,66 @@
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { Button } from '@/components/ui/Button';
import { signInAction, type SignInState } from '@/server/auth/actions';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button
type="submit"
variant="primary"
className="w-full"
disabled={pending}
>
{pending ? 'Connexion…' : 'Se connecter'}
</Button>
);
}
export function SignInForm() {
const [state, formAction] = useActionState<SignInState, FormData>(
signInAction,
{},
);
return (
<form action={formAction} className="flex flex-col gap-4">
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Adresse électronique</span>
<input
name="email"
type="email"
autoComplete="username"
required
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1 outline-none focus-visible:border-focus"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Mot de passe</span>
<input
name="password"
type="password"
autoComplete="current-password"
required
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1 outline-none focus-visible:border-focus"
/>
</label>
{state.error ? (
// `role="alert"` : l'échec doit être annoncé, pas seulement coloré.
<p
role="alert"
className="rounded-2 border border-danger bg-danger-soft px-3 py-2 text-xs text-danger-soft-ink"
>
{state.error}
</p>
) : null}
<SubmitButton />
</form>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { redirect } from 'next/navigation';
import { SignInForm } from '@/app/(auth)/connexion/SignInForm';
import { currentSession } from '@/server/auth/session';
export const metadata = { title: 'Connexion · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function ConnexionPage() {
if (await currentSession()) redirect('/');
return (
<main className="flex min-h-dvh items-center justify-center bg-canvas p-6">
<div className="w-full max-w-sm">
<div className="mb-6 flex items-center gap-2.5">
<span aria-hidden className="size-6 rounded-2 bg-accent" />
<span className="text-xl font-semibold tracking-[-0.015em]">
PlanFlow
</span>
</div>
<div className="rounded-3 border border-line-1 bg-surface p-6 shadow-e1">
<h1 className="text-lg font-semibold">Connexion</h1>
<p className="mt-1 mb-5 text-sm text-ink-2">
Accédez à vos plannings et à votre équipe.
</p>
<SignInForm />
</div>
<p className="mt-4 text-center text-micro text-ink-3">
Instance auto-hébergée · Maison Rivage
</p>
</div>
</main>
);
}
+21 -4
View File
@@ -8,6 +8,7 @@ import { ThemeToggle } from '@/components/shell/ThemeToggle';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { cx } from '@/lib/cx';
import { signOutAction } from '@/server/auth/actions';
import {
isActive,
NAVIGATION,
@@ -19,9 +20,19 @@ import {
export interface AppShellProps {
children: ReactNode;
initials: string;
fullName: string;
roleName: string;
accountName: string;
}
export function AppShell({ children }: AppShellProps) {
export function AppShell({
children,
initials,
fullName,
roleName,
accountName,
}: AppShellProps) {
const pathname = usePathname();
const section = sectionForPath(pathname);
@@ -67,11 +78,17 @@ export function AppShell({ children }: AppShellProps) {
</Button>
<ThemeToggle />
<span
aria-hidden
className="flex size-7 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-ink"
title={`${fullName} · ${roleName}`}
>
MR
<span aria-hidden>{initials}</span>
<span className="sr-only">{`${fullName}, ${roleName}`}</span>
</span>
<form action={signOutAction}>
<Button size="sm" type="submit">
Déconnexion
</Button>
</form>
</header>
<div className="flex min-h-0 flex-1">
@@ -86,7 +103,7 @@ export function AppShell({ children }: AppShellProps) {
<p className="border-t border-line-1 px-2.5 pt-3 text-micro leading-relaxed text-ink-3">
Instance auto-hébergée
<br />
Maison Rivage · 34 établissements
{accountName}
</p>
</aside>
+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`. */
+19
View File
@@ -0,0 +1,19 @@
import { test as setup, expect } from '@playwright/test';
import { STORAGE_STATE } from './storage';
/**
* Ouvre une session une fois et enregistre le cookie pour les autres tests.
*
* Chaque test se connecterait sinon, ce qui coûterait un argon2 par test —
* volontairement lent — et ferait grimper le compteur d'échecs partagé.
*/
setup('authentifie la direction', async ({ page }) => {
await page.goto('/connexion');
await page.getByLabel('Adresse électronique').fill('direction@example.test');
await page.getByLabel('Mot de passe').fill('planflow-demo-2026');
await page.getByRole('button', { name: 'Se connecter' }).click();
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
await page.context().storageState({ path: STORAGE_STATE });
});
+58
View File
@@ -0,0 +1,58 @@
import { expect, test } from '@playwright/test';
const EMAIL = 'direction@example.test';
const PASSWORD = 'planflow-demo-2026';
test('une route applicative redirige vers la connexion', async ({ page }) => {
await page.goto('/planning/semaine');
await expect(page).toHaveURL(/\/connexion$/);
await expect(page.getByRole('heading', { name: 'Connexion' })).toBeVisible();
});
test('un mot de passe faux ne dit pas si le compte existe', async ({ page }) => {
async function attempt(email: string): Promise<string> {
// Une page neuve par tentative : le message précédent resterait sinon à
// l'écran et le test comparerait deux fois le même.
await page.goto('/connexion');
await page.getByLabel('Adresse électronique').fill(email);
await page.getByLabel('Mot de passe').fill('mauvais-mot-de-passe');
await page.getByRole('button', { name: 'Se connecter' }).click();
// Restreint au formulaire : Next pose un annonceur de route qui porte lui
// aussi role="alert" et qui est vide.
const alert = page.locator('form').getByRole('alert');
await expect(alert).toBeVisible();
return (await alert.textContent()) ?? '';
}
const knownAccount = await attempt(EMAIL);
const unknownAccount = await attempt('inconnu@example.test');
// Un message différent laisserait énumérer les adresses du personnel.
expect(unknownAccount).toBe(knownAccount);
expect(knownAccount).toContain('Identifiants incorrects');
});
test('connexion, navigation, puis déconnexion', async ({ page }) => {
await page.goto('/connexion');
await page.getByLabel('Adresse électronique').fill(EMAIL);
await page.getByLabel('Mot de passe').fill(PASSWORD);
await page.getByRole('button', { name: 'Se connecter' }).click();
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
// L'identité affichée vient de la base, pas d'un libellé en dur.
await expect(page.getByTitle(/Camille Ferrand/)).toBeVisible();
await page.getByRole('link', { name: 'Plannings' }).click();
await expect(
page.getByRole('heading', { name: /Planning · semaine 33/ }),
).toBeVisible();
await page.getByRole('button', { name: 'Déconnexion' }).click();
await expect(page).toHaveURL(/\/connexion$/);
// La session est révoquée en base : revenir en arrière ne doit pas rouvrir
// l'application.
await page.goto('/equipe');
await expect(page).toHaveURL(/\/connexion$/);
});
+7
View File
@@ -0,0 +1,7 @@
/**
* Emplacement de l'état de session partagé entre les tests.
*
* Dans son propre module : le fichier de configuration Playwright ne peut pas
* importer un fichier qui appelle `test()`.
*/
export const STORAGE_STATE = 'test-results/.auth/direction.json';