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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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$/);
|
||||
});
|
||||
@@ -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';
|
||||
Reference in new issue
Block a user