From 2c0e9e8dd473cbe44e081fd13083a9beb8a9b3e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 22:45:24 +0000 Subject: [PATCH] Wire authentication into the application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv --- .github/workflows/ci.yml | 3 ++ playwright.config.ts | 40 ++++++++++---- prisma/seed.ts | 5 +- src/app/(app)/layout.tsx | 26 +++++++++- src/app/(auth)/connexion/SignInForm.tsx | 66 +++++++++++++++++++++++ src/app/(auth)/connexion/page.tsx | 36 +++++++++++++ src/components/shell/AppShell.tsx | 25 +++++++-- src/server/auth/actions.ts | 69 +++++++++++++++++++++++++ src/server/auth/session.ts | 26 +++++++++- tests/e2e/auth.setup.ts | 19 +++++++ tests/e2e/auth.spec.ts | 58 +++++++++++++++++++++ tests/e2e/storage.ts | 7 +++ 12 files changed, 361 insertions(+), 19 deletions(-) create mode 100644 src/app/(auth)/connexion/SignInForm.tsx create mode 100644 src/app/(auth)/connexion/page.tsx create mode 100644 src/server/auth/actions.ts create mode 100644 tests/e2e/auth.setup.ts create mode 100644 tests/e2e/auth.spec.ts create mode 100644 tests/e2e/storage.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb57651..aeac85a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/playwright.config.ts b/playwright.config.ts index 3758a96..8cc6123 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, }, }, ], diff --git a/prisma/seed.ts b/prisma/seed.ts index 0987641..614fc46 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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, diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 91d9b4a..e7aaef8 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -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 {children}; +/** + * 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 ( + + {children} + + ); } diff --git a/src/app/(auth)/connexion/SignInForm.tsx b/src/app/(auth)/connexion/SignInForm.tsx new file mode 100644 index 0000000..633ae8e --- /dev/null +++ b/src/app/(auth)/connexion/SignInForm.tsx @@ -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 ( + + ); +} + +export function SignInForm() { + const [state, formAction] = useActionState( + signInAction, + {}, + ); + + return ( +
+ + + + + {state.error ? ( + // `role="alert"` : l'échec doit être annoncé, pas seulement coloré. +

+ {state.error} +

+ ) : null} + + + + ); +} diff --git a/src/app/(auth)/connexion/page.tsx b/src/app/(auth)/connexion/page.tsx new file mode 100644 index 0000000..966fdea --- /dev/null +++ b/src/app/(auth)/connexion/page.tsx @@ -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 ( +
+
+
+ + + PlanFlow + +
+ +
+

Connexion

+

+ Accédez à vos plannings et à votre équipe. +

+ +
+ +

+ Instance auto-hébergée · Maison Rivage +

+
+
+ ); +} diff --git a/src/components/shell/AppShell.tsx b/src/components/shell/AppShell.tsx index 3f269c4..a06f9d7 100644 --- a/src/components/shell/AppShell.tsx +++ b/src/components/shell/AppShell.tsx @@ -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) { - MR + {initials} + {`${fullName}, ${roleName}`} +
+ +
@@ -86,7 +103,7 @@ export function AppShell({ children }: AppShellProps) {

Instance auto-hébergée
- Maison Rivage · 34 établissements + {accountName}

diff --git a/src/server/auth/actions.ts b/src/server/auth/actions.ts new file mode 100644 index 0000000..4bb5a9f --- /dev/null +++ b/src/server/auth/actions.ts @@ -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 { + 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 { + const store = await cookies(); + const token = store.get(SESSION_COOKIE)?.value; + if (token) await signOut(token); + store.delete(SESSION_COOKIE); + redirect('/connexion'); +} diff --git a/src/server/auth/session.ts b/src/server/auth/session.ts index 829d9c4..f8619c5 100644 --- a/src/server/auth/session.ts +++ b/src/server/auth/session.ts @@ -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`. */ diff --git a/tests/e2e/auth.setup.ts b/tests/e2e/auth.setup.ts new file mode 100644 index 0000000..f64382b --- /dev/null +++ b/tests/e2e/auth.setup.ts @@ -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 }); +}); diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..7b3e790 --- /dev/null +++ b/tests/e2e/auth.spec.ts @@ -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 { + // 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$/); +}); diff --git a/tests/e2e/storage.ts b/tests/e2e/storage.ts new file mode 100644 index 0000000..f356b27 --- /dev/null +++ b/tests/e2e/storage.ts @@ -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';