@@ -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';